69 KiB
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.
- Bulk actions menu on a list (D-09).
- Record actions on one record (D-10).
- Preview screen: a read-only record view with its own toolbar and a status hint slot (D-11).
- Row state on list rows (D-12).
permissioneditorfield (D-16).- Locked options in a
relationfield and the forbidden (403) presentation (D-07, RESEARCH gap G4). passwordfield (D-19, RESEARCH gaps G1/G2), plus the smallpresetbehaviour (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 (copiesUserMenu.vueitems); 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 (copiesFormErrorBanner). - 18px (
px-[18px]): callout and forbidden-banner horizontal padding (copiesFormErrorBanner). - 20px (
gap-5): vertical gap between page blocks (header, status hint, card). - 6px (
gap-1.5): label-to-control gap ofFormField, reused for the preview'sdt-to-ddgap. - 34px (
h-pagertoken): height of one permission radio segment (same as a form tab). - 18px checkbox box and
rounded-[14px]menu radius: copied fromDataTable.vueandUserMenu.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):
primaryfill: 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 (existingConfirmDialog).ring(3px): focus-visible on every new focusable element. Never removed.seltint: 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
bulkActionscontains 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-indeleteis not in the menu: it stays the existing toolbar button. - Placement: in the
ListToolbarright cluster (gap-2.5), directly after the selection pill and before thetoolbar.buttons(Delete, then custom toolbar actions in declared order). - Trigger:
DropdownMenuTrigger as-childaroundButton variant="outline" size="md", labelbackend::lang.list.bulk_actions, with a trailingChevronDown16pxtext-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"withz-50 flex w-[240px] max-w-[320px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu(theUserMenusurface). 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 localizedLabel. 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'sConfirmtext; when the action declares none,backend::lang.list.bulk_confirmwith:action= the label and:count= the selected count. The confirm button label is the action label. The request runs throughconfirm.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_donewith 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
recordActionswithout 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 = localizedLabel,data-record-action="{name}", in declared order. - Confirm (always):
ConfirmDialog(not danger), message = the action'sConfirm, elsebackend::lang.form.action_confirmwith:action. Confirm button label = the action label. Busy handling as in S1. - Success: success toast with the server
message, elsebackend::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, namepreview, inside the shell.CONTROLLER_ROUTESgainspreview.mapWinterUrlmaps…/preview/:idto it. Opening the route for a form with no preview replaces it with the record (update) route. - Entry: a list whose
recordUrlpoints at preview opens it on row click and on the first-cell link.create.redirectandupdate.redirectClosemay point at it. - Layout: the
FormViewpage frame, unchanged in geometry:section.flex.w-full.flex-col.gap-5.pb-24.- Header: the 40px outline back button (
ArrowLeft, aria-labelbackend::lang.form.return_to_list, goes to the list); the title (record name: the first text field's value, 24/700 inherited) with the subtitlemessages.form.preview;FormTabswhen the visible fields declare tabs. - 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: onebg-skelblock, 68px tall,rounded-inner,aria-hidden. Failure: the existing extension failure box withbackend::lang.extension.partial_failed; the rest of the screen works. - Card:
rounded-card border border-border bg-surface p-7 shadow-card, holding the field grid as a<dl>with theFormGridcolumn and span rules. - 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", labelmessages.form.edit, linking to the update route,data-action="edit". There is no delete and no save on preview.
- Header: the 40px outline back button (
- 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
primaryfill. - Fields shown: every field whose
contextallowspreview(a field with nocontextshows everywhere;context: previewshows 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-labeltricks. - Badge labels: list messages, overridable per list in
config_list.yamlmessages(rowStateDeleted,rowStateNegative,rowStateDisabled), defaulting tobackend::lang.messages.list.row_state_*. - Row and badge styling (the row background is never changed, so the existing selected
bg-selandhover:bg-hoverrules 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 orderdeleted,negative,disabled(flex gap-2). When states combine, the text styles combine (deleted+negative: struck through andtext-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
deletedrow is still openable.
S5. permissioneditor field (D-16)
- Registry:
permissioneditorjoinsrenderers, andgroupLabelledTypes(the visible label is a<span>the control'srole="group"points at witharia-labelledby). It is a value field: its value is part of the save body. - "Tabbed" means grouped. Permissions are grouped by their
tabinto 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 notabgoes to a last section labelledbackend::lang.permissioneditor.other. - Container:
overflow-hidden rounded-inner border border-border(border-dangerwhen 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 headingbackend::lang.permissioneditor.allowat 12/600text-muted. Rendered as an<h3>inside arole="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 withid, then the comment 13/400text-mutedwhen present. Right: the control,shrink-0. Below 640px the row wraps and the control sits under the label. - Radio mode (
mode: radio): oneRadioGroupRoot orientation="horizontal"per row,aria-labelledbythe row label. Styled as a segmented control: trackinline-flex gap-1 rounded-inner border border-border bg-subtle p-1; eachRadioGroupItemisinline-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): oneCheckboxRootper row,aria-labelledbythe row label; the box is the list checkbox (flex size-[18px] items-center justify-center rounded-checkbox border, uncheckedborder-border-strong bg-surface, checkedborder-primary bg-primary text-on-primarywithCheck14px). 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:
1or-1, an inherited code omitted. Checkbox:1for 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
disabledandaria-disabled, the row label is followed byLock14pxtext-muted(aria-hidden), and the comment line is replaced bybackend::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
FormFielderror line and the container border becomesdanger. - 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) withbackend::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 holdingLock14pxtext-muted(aria-hidden), and the chip carries visually hidden textbackend::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, withLock14px 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
Lock14px. - 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,Lock14px, textbackend::lang.form.locked_note. Its id is added to the control'saria-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
FormErrorBannerslot with its geometry (flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger,role="alert",CircleAlert20px,data-forbidden-banner): the servererror.message, elsebackend::lang.form.forbidden. It is not a toast, because a refused save must stay readable. - When
error.detailsnames 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">withcontrolClass(invalid)plush-input pr-12,autocomplete="new-password",spellcheck="false". The label, comment and error line come fromFormFieldas 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×32rounded-pager text-muted hover:bg-hover hover:text-textbutton withEye/EyeOff16px,aria-pressed, aria-labelbackend::lang.form.show_password/hide_password. It switches the input betweenpasswordandtext. 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
passwordfield; 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'smessages.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_inviteon create, password and confirmation, groups, organisation, avatar) and "Permissions" / "Uprawnienia" (newuser.permissions_tab; thepermissioneditorin 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_passwordon create,user.reset_passwordon update; one YAML field needs one label, so use newuser.password"Password" / "Hasło" with new commentuser.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_passwordand its comment. send_invite: the existing checkbox toggle card, on by default, create only; label and comment fromuser.send_invite*.- Groups: the
relationfield 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.", withdetailsongroupsso 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-callout14px 18pxmust stay in step withFormErrorBanner);px-2.5,right-1.5,p-1.5/gap-1.5,gap-3andp-7are 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)