Files
summercms/.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
2026-10-04 17:29:20 +02:00

69 KiB
Raw Blame History

phase, slug, status, reviewed_at, shadcn_initialized, preset, created
phase slug status reviewed_at shadcn_initialized preset created
12.1 user-plugin-admin-screens approved 2026-10-04 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

State coverage from the UI-consideration probe (run 2026-10-04 after checker approval; element kinds confirmed by the user). 99 applicable considerations, plus 4 extra backstop rows: 66 resolved explicit, 5 resolved backstop, 32 dismissed, 0 unresolved.

How the planner lifts a row: resolved / explicit → the statement is a must_haves.truths string; resolved / backstop → the flat scalar shown; dismissed → not lifted. Empty-state and error copy stays in the Copywriting Contract and Plugin screens sections; rows here reference the keys.

Elements: S1 bulk actions menu · S2 record actions · S3 preview screen · S4 row state · S5 permissioneditor · S6a locked relation options · S6b forbidden banner · S7a password field · S7b preset · P1 Users list · P2 Users form · P3 Users preview · P4 User Groups · P5 Organisations · P6 admin navigation.

Element Category Status Verification Statement / Reason
S1 empty resolved explicit With zero permitted bulk actions the bulk menu is not rendered; with nothing selected the trigger is disabled and keeps its label
S1 loading resolved explicit While a bulk action is in flight the confirm dialog stays open with both buttons disabled and a spinner on the confirm button until the POST settles, and the menu trigger is disabled
S1 error resolved explicit A bulk action failure shows a danger toast: 409 uses list.bulk_stale, clears the selection and reloads the list; 403 uses the server message or list.action_forbidden and keeps the selection; any other status uses the server message or extension.action_failed, keeps the selection and reloads nothing
S1 error resolved backstop { statement: "Focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel", verification: backstop }
S1 populated resolved explicit Bulk menu items render in declared order at 14/400 with a 40px minimum height and no icons, groups or separators
S1 partial resolved explicit A bulk action succeeds or rolls back as a whole; the success toast shows the server's affected count, which may be lower than the selection when the plugin skips rows
S1 overflow resolved explicit Many bulk actions extend the menu downward inside the viewport (Reka collision handling) and the toolbar cluster wraps
S1 zero-one-many resolved explicit The default bulk confirm and the list.bulk_done toast use :count with CLDR plural forms; one selected row and many share one flow
S1 long-text resolved explicit A long bulk action label wraps inside the 240–320px menu and is never truncated; the confirm message wraps inside the dialog
S2 empty resolved explicit With no applicable record action the preview footer shows only the edit button
S2 loading resolved explicit While a record action runs the confirm dialog is busy and every footer button is disabled
S2 error resolved explicit A record action failure: 409 shows the form.action_stale toast and reloads the record and hint; 404 shows the form load-failure alert; 403 and other statuses show a danger toast with the server message or the framework fallback
S2 populated resolved explicit After a record action succeeds a success toast shows and the record and status hint reload in place, with the previous content visible during the reload
S2 partial dismissed — Reason: A record action is either applicable and rendered or not rendered; there is no partial state
S2 overflow resolved explicit The preview footer and its right cluster wrap; record action buttons move to a second row instead of shrinking
S2 zero-one-many resolved explicit Zero applicable record actions leave only the edit button; one or many render in declared order before it
S2 long-text resolved explicit Record action buttons are whitespace-nowrap and wrap as whole buttons; a label is never truncated
S3 empty resolved explicit On preview an empty value shows the muted empty-value dash, and an empty multiple relation shows the same dash
S3 loading resolved explicit While the preview loads the title is blank, the card is not rendered and the footer buttons are disabled; the status hint's first fetch shows one 68px skeleton block and a refetch keeps the previous hint visible
S3 error resolved explicit A preview load failure or 404 shows the existing alert with form.load_failed and the back button still works; a failed status hint shows the extension failure box and the rest of the screen works
S3 error resolved backstop { statement: "mapWinterUrlmapspreview/:id to the preview route, and opening the preview route of a form without a preview replaces it with the record route", verification: backstop }
S3 populated resolved explicit The preview renders fields as a dl grid in the form card with read-only boxes per the PreviewField table
S3 partial resolved explicit Preview fields group into the same tabs as the form, and a tab with no preview-visible field is not rendered; a status hint with zero nodes renders nothing and takes no gap
S3 overflow resolved explicit Preview values wrap with overflow-wrap: anywhere, textarea boxes grow with their text, and the page scrolls under the fixed footer
S3 zero-one-many resolved explicit A multiple relation on preview shows the dash for none and wrapping chips for one or many
S3 long-text resolved explicit Long preview values and callout text wrap and are never truncated; the title truncates as on the form
S4 empty dismissed — Reason: Row states arrive with the list rows; list loading, error and paging states belong to the unchanged list view (Phase 10 contract)
S4 loading dismissed — Reason: Row states arrive with the list rows; list loading, error and paging states belong to the unchanged list view (Phase 10 contract)
S4 error dismissed — Reason: Row states arrive with the list rows; list loading, error and paging states belong to the unchanged list view (Phase 10 contract)
S4 error resolved backstop { statement: "A row state outside the fixed set renders no badge and no class, and each known state renders its text badge", verification: backstop }
S4 populated resolved explicit Each row state renders a text badge in the first cell plus the row text style from the state table; the row background is never changed
S4 partial resolved explicit A row may carry any subset of the states; each renders independently and their text styles combine
S4 overflow resolved explicit In a first cell with states the text truncates (min-w-0 truncate) and badges never shrink; the table keeps its horizontal scroll
S4 zero-one-many resolved explicit A row with no state is unchanged; one or several states render badges in the fixed order deleted, negative, disabled
S4 long-text resolved explicit Row state badges are whitespace-nowrap and never struck through; a long first-cell value truncates before the badges
S5 empty resolved explicit A permission editor with no options renders the read-only box with permissioneditor.empty
S5 loading dismissed — Reason: Options arrive with the form schema; there is no separate fetch to show
S5 error resolved explicit A 422 on a permission editor renders on the FormField error line and the container border becomes danger
S5 error resolved backstop { statement: "Radio mode emits 1/-1and omits inherit, checkbox mode emits1 and omits unchecked, a locked row cannot change, and codes outside the options are never sent", verification: backstop }
S5 populated resolved explicit Permissions render as sections grouped by tab in one list (no inner tablist; confirmed by the user as the meaning of D-16 "tabbed"), each row with label, comment and a three-segment radio group or a checkbox
S5 partial resolved explicit A locked permission row shows its stored value, a disabled control, the lock icon and the permissioneditor.locked text while other rows stay editable
S5 overflow resolved explicit A permission editor with many permissions has no inner scroll; the page scrolls and section headers are not sticky
S5 zero-one-many resolved explicit One tab gives one section with its header; permissions without a tab form a last section labelled permissioneditor.other
S5 long-text resolved explicit Permission labels and comments wrap in the left column, the control never shrinks, and below 640px the control moves under the label
S6a empty dismissed — Reason: Locking adds no empty state; an empty option list is the unchanged RelationField contract (Phase 12.2)
S6a loading dismissed — Reason: Locking adds no loading state; option loading is the unchanged RelationField contract (Phase 12.2)
S6a error resolved backstop { statement: "A locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace", verification: backstop }
S6a partial resolved explicit With some options locked, locked chips have no remove button and are skipped by Backspace, unlocked chips work as before, and the locked note shows under the field
S6a long-text dismissed — Reason: Chip and option text overflow is the unchanged RelationField contract (Phase 12.2); the locked note is a fixed framework string
S6b empty resolved explicit A 403 on save with no server message shows the banner with form.forbidden
S6b loading dismissed — Reason: The banner is the result of a save; form loading and save-busy states belong to the unchanged FormView (Phase 10 contract)
S6b error resolved explicit A 403 on create or update saves nothing, keeps every entered value and the dirty state, and shows a persistent role="alert" banner (not a toast) that clears on the next save attempt
S6b partial resolved explicit When the 403 error.details names fields, each is marked through its error line and the first is focused; without details only the banner shows
S6b overflow resolved explicit A long forbidden message wraps inside the banner and is never truncated
S6b long-text resolved explicit A long forbidden message wraps inside the banner and is never truncated
S7a empty resolved explicit A password field is always empty on load, and an empty password on update is not sent
S7a loading dismissed — Reason: The server never sends a password value, so there is nothing to load
S7a error resolved explicit A password 422, including a confirmation mismatch, renders on the field's error line; the SPA does not compare the two fields
S7a partial resolved explicit With only one of password and confirmation filled the save is sent as entered and the server's 422 marks the field
S7a long-text resolved explicit A long password scrolls inside the input and pr-12 keeps it clear of the show/hide toggle; every password field is cleared and hidden again after a successful save
S7b empty resolved explicit With an empty preset source the target stays empty
S7b loading dismissed — Reason: Preset is client-side only; it makes no request
S7b error dismissed — Reason: Preset produces no error of its own; an invalid target value is the server's 422 on that field, as for any text field
S7b partial resolved explicit A preset target follows its source until the first manual edit, then stops for the session; on update nothing is filled
S7b long-text resolved explicit A long preset source is slugged in full with no length cut in the SPA; the server's validation decides
P1 empty resolved explicit The Users list with no rows shows users.list_empty; the empty-search state is the existing one
P1 loading dismissed — Reason: YAML-driven screen; list loading, error and paging states belong to the unchanged list view (Phase 10 contract)
P1 error dismissed — Reason: YAML-driven screen; list loading, error and paging states belong to the unchanged list view (Phase 10 contract)
P1 populated resolved explicit The Users list shows Name, Email, Registered and Last seen, with row-state badges Deactivated, Banned and Not activated, and rows open the preview
P1 partial dismissed — Reason: A missing cell value (for example a user never seen) uses the unchanged list empty-value rule
P1 overflow dismissed — Reason: Column overflow and pagination are the unchanged list view (Phase 10 contract)
P1 zero-one-many resolved explicit Users bulk confirms and success toasts address the selected users; counts come from the S1 :count rule
P1 long-text dismissed — Reason: Cell truncation is the unchanged list view; the first cell with badges is covered by S4
P2 empty resolved explicit The Users create form opens with send_invite on and every other field empty; a user without an avatar shows the Phase 12.2 fileupload empty state
P2 loading dismissed — Reason: YAML-driven screen; form loading and save-busy states belong to the unchanged FormView (Phase 10 contract)
P2 error resolved explicit On the Users form a validation failure marks the field (422) and a refused privileged-group change shows the forbidden banner with users.privileged_group_forbidden and marks groups
P2 populated dismissed — Reason: A present avatar is the unchanged Phase 12.2 fileupload image mode
P2 partial resolved explicit On the Users form send_invite shows on create only and the Permissions tab on update only
P2 long-text dismissed — Reason: Text inputs are the unchanged form field contract
P3 loading resolved explicit The Users preview loads as S3: skeleton hint on first fetch, previous hint kept on refetch
P3 error resolved explicit The Users preview fails as S3, and its record actions fail as S2
P3 overflow resolved explicit The Users preview shows at most one status callout, by precedence banned, deactivated, not activated, suspended; action buttons wrap in the footer
P3 long-text resolved explicit Status callout text wraps and is never truncated
P4 empty resolved explicit The User Groups list with no rows shows groups.list_empty
P4 loading dismissed — Reason: YAML-driven screen; list loading, error and paging states belong to the unchanged list view (Phase 10 contract)
P4 error resolved explicit A refused privileged-group create, re-code or delete shows groups.privileged_forbidden: a banner on save and a toast on delete
P4 populated resolved explicit The User Groups list shows Name, Code, Created and Users, with no checkboxes, bulk actions or preview; rows open the update form
P4 partial dismissed — Reason: A missing cell value uses the unchanged list empty-value rule
P4 overflow dismissed — Reason: Column overflow and pagination are the unchanged list view (Phase 10 contract)
P4 zero-one-many resolved explicit The User Groups Users column shows 0 for a group with no members, not the empty-value dash
P4 long-text dismissed — Reason: Cell truncation and text inputs are the unchanged list and form contracts
P5 empty resolved explicit The Organisations list with no rows shows organisation.list_empty, and an organisation with no members shows organisation.members_empty
P5 loading dismissed — Reason: YAML-driven screen; list loading, error and paging states belong to the unchanged list view (Phase 10 contract); the relation manager is the unchanged Phase 12.2 contract
P5 error dismissed — Reason: List, form and relation manager error states are the unchanged Phase 10 and 12.2 contracts; this screen adds no forbidden case
P5 populated resolved explicit The Organisations list shows Name, Slug and Created; rows open the update form, which has no preview
P5 partial resolved explicit The Organisations Members tab shows on update only
P5 overflow dismissed — Reason: Column overflow, pagination and the members table are the unchanged list and Phase 12.2 relation manager contracts
P5 zero-one-many resolved explicit The members unlink confirm uses :count and states that the users are not deleted
P5 long-text dismissed — Reason: Cell truncation and text inputs are the unchanged list and form contracts
P6 loading dismissed — Reason: The three items join the existing shell navigation; its states are the unchanged Phase 10 contract
P6 error dismissed — Reason: The three items join the existing shell navigation; its states are the unchanged Phase 10 contract
P6 overflow dismissed — Reason: The three items join the existing shell navigation; its overflow is the unchanged Phase 10 contract
P6 long-text dismissed — Reason: The labels are short fixed strings; shell navigation text handling is unchanged

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: FLAG (non-blocking): new roles use 12 / 13 / 14px, so hierarchy rests on weight and colour; the preview screen also renders the inherited 24/700 page title and the 20/700 confirm title
  • Dimension 5 Spacing: FLAG (non-blocking): off-grid values are copied into new code (.summer-callout 14px 18px must stay in step with FormErrorBanner); px-2.5, right-1.5, p-1.5 / gap-1.5, gap-3 and p-7 are used but not listed under exceptions
  • Dimension 6 Registry Safety: PASS
  • Dimension 7 Inventory Provenance: PASS

Checker notes for the planner: write out users.trashed_hint_desc; consider a next step in form.forbidden / list.action_forbidden; plugin bulk success messages are fixed strings and can overstate when rows are skipped; state the user avatar's 260×260 (D-20); S6 and S7 carry locked decisions D-07 and D-19, so their seams (G4, G1/G2) are not freely droppable.

Approval: approved 2026-10-04 (gsd-ui-checker; D-16 "tabbed" as grouped sections confirmed by the user)