docs(12.1): UI design contract
This commit is contained in:
568
.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
Normal file
568
.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
Normal file
@@ -0,0 +1,568 @@
|
||||
---
|
||||
phase: "12.1"
|
||||
slug: "user-plugin-admin-screens"
|
||||
status: draft
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: "2026-10-04"
|
||||
---
|
||||
|
||||
# Phase 12.1 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for frontend phases. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
|
||||
|
||||
**Scope of this contract.** Phase 12.1 adds seven framework-generic surfaces to the existing admin SPA (`admin/`), and three YAML-driven plugin screens that use them. It does not restyle anything that exists.
|
||||
|
||||
1. **Bulk actions menu** on a list (D-09).
|
||||
2. **Record actions** on one record (D-10).
|
||||
3. **Preview screen**: a read-only record view with its own toolbar and a status hint slot (D-11).
|
||||
4. **Row state** on list rows (D-12).
|
||||
5. **`permissioneditor` field** (D-16).
|
||||
6. **Locked options** in a `relation` field and the **forbidden (403) presentation** (D-07, RESEARCH gap G4).
|
||||
7. **`password` field** (D-19, RESEARCH gaps G1/G2), plus the small `preset` behaviour (G7).
|
||||
|
||||
Surfaces 6 and 7 depend on framework seams that RESEARCH.md lists as not yet confirmed by the user (G1, G2, G4, G7). The visual contract below is binding **if** the seam lands; if the user cuts a seam at the plan-count checkpoint, its section is dropped, not redesigned.
|
||||
|
||||
The SPA has a complete design system: Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`), implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css`, on Reka UI primitives. **This contract adds no new tokens, colours, fonts or radii.** It adds one group of plain CSS classes to the partial style kit (`.summer-callout*`), which read only existing `--c-*` variables.
|
||||
|
||||
Framework copy is neutral and never names an application. Plugin copy (the `golem15.user` screens) is listed separately under "Plugin screens".
|
||||
|
||||
Sources: 12.1-CONTEXT.md D-01..D-25 (locked), 12.1-RESEARCH.md (PHP screen inventory, gaps G1–G12, contract names), the 10.1 and 12.2 UI-SPECs (precedent), the codebase (`main.css`, `ListView.vue`, `ListToolbar.vue`, `DataTable.vue`, `CellValue.vue`, `FormView.vue`, `FormTabs.vue`, `FormField.vue`, `FormErrorBanner.vue`, `RelationField.vue`, `UserMenu.vue`, `Button.vue`, `ConfirmDialog.vue`, `confirm.ts`, `registry.ts`, `winterUrl.ts`, `router.ts`, `partialNodes.ts`, `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`, `modules/cabana/messages.go`), and the PHP reference at `plugins/golem15/user` (toolbars, hints, permission editor partial, `lang/{en,pl}/lang.php`).
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none (no shadcn; Vue 3 SPA with its own Direction C system; the shadcn gate does not apply to a Vue codebase with an established system) |
|
||||
| Preset | not applicable |
|
||||
| Component library | Reka UI 2.9.10 (headless primitives) plus the local generic components in `admin/src/components/` |
|
||||
| Icon library | `@lucide/vue` 1.17.0 (named imports only; 16px in buttons and menu items, 14px in chips, badges and error lines) |
|
||||
| Font | DM Sans 400/500/600/700 (self-hosted `@fontsource/dm-sans`); 14px / 1.5 base from `html` |
|
||||
| npm changes | **None.** The exact-pin package gate is unchanged (RESEARCH: "This research recommends no new npm package") |
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-10-04.
|
||||
Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 38 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 0b25a1d) — 2026-10-04.
|
||||
|
||||
This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path.
|
||||
|
||||
| Component | Import path | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuItem | `reka-ui` | Bulk actions menu. Same parts and classes as `shell/UserMenu.vue`. Verified exported. |
|
||||
| RadioGroupRoot, RadioGroupItem | `reka-ui` | `permissioneditor` radio mode (one group per permission row). Verified exported. |
|
||||
| CheckboxRoot, CheckboxIndicator | `reka-ui` | `permissioneditor` checkbox mode. Verified exported. |
|
||||
| AlertDialog* | `reka-ui` via `components/ui/ConfirmDialog.vue` | Every confirmation. Do not build a second confirm. |
|
||||
| ConfirmDialog + `useConfirm` | `admin/src/components/ui/ConfirmDialog.vue`, `confirm.ts` | Use `confirm.ask(request, run)` so the dialog stays open and `busy` while the action's POST runs. |
|
||||
| Button | `admin/src/components/ui/Button.vue` | Variants `primary` / `outline` / `ghost` / `danger`, sizes `md` (42px) / `sm` (38px). Every new text button uses it. |
|
||||
| ListToolbar, DataTable, CellValue, FilterBar, Pagination | `admin/src/components/list/` | ListToolbar and DataTable are extended, not forked. FilterBar and Pagination are unchanged. |
|
||||
| FormGrid, FormField, FormTabs, FormErrorBanner | `admin/src/components/form/` | FormTabs reused unchanged on the preview screen. FormErrorBanner gains the forbidden variant. |
|
||||
| RelationField | `admin/src/components/form/fields/RelationField.vue` | Extended with locked options. |
|
||||
| TextField, `controlClass`, `controlAttributes` | `admin/src/components/form/fields/TextField.vue`, `form/control.ts` | Geometry source of the password input. |
|
||||
| PartialHost | `admin/src/components/partial/PartialHost.vue` | Renders the preview status hint (header variant). |
|
||||
| `showToast` | `admin/src/state/useToasts.ts` | Success (`status`) and failure (`danger`, `alert`) toasts. |
|
||||
| `t`, `tc`, `message` | `admin/src/app/i18n.ts` | All copy. No literal UI strings in components. |
|
||||
| `mapWinterUrl`, `CONTROLLER_ROUTES` | `admin/src/app/winterUrl.ts`, `app/router.ts` | Learn `preview/:id` and the `preview` route name. |
|
||||
| Icons used by this phase | `@lucide/vue` | `ChevronDown`, `Pencil`, `ArrowLeft`, `Lock`, `Eye`, `EyeOff`, `Check`, `CircleAlert`, `Trash2`, `LoaderCircle` (all verified present in 1.17.0). |
|
||||
|
||||
**New components (framework):**
|
||||
|
||||
| Component | Path | Purpose |
|
||||
|-----------|------|---------|
|
||||
| BulkActionsMenu | `admin/src/components/list/BulkActionsMenu.vue` | The menu of declared bulk actions in the list toolbar. |
|
||||
| RowStateBadges | `admin/src/components/list/RowStateBadges.vue` | Text badges for a row's states, rendered by DataTable in the first cell. |
|
||||
| PreviewView (or a `preview` mode of `FormView.vue`, planner's choice) | `admin/src/views/` | The read-only record screen. |
|
||||
| PreviewField | `admin/src/components/form/PreviewField.vue` | Read-only rendering of one field value by type. |
|
||||
| PermissionEditorField | `admin/src/components/form/fields/PermissionEditorField.vue` | `type: permissioneditor`. |
|
||||
| PasswordField | `admin/src/components/form/fields/PasswordField.vue` | `type: password` (if G1 lands). |
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values for new layout in this phase (multiples of 4):
|
||||
|
||||
| Token | Value | Tailwind | Usage |
|
||||
|-------|-------|----------|-------|
|
||||
| xs | 4px | `gap-1`, `p-1` | Gap between segments of the permission radio control and its track padding; gap between a callout title and its text |
|
||||
| sm | 8px | `gap-2`, `p-2` | Gap between the first-cell text and row-state badges; gap between badges; bulk menu inner padding; gap between a lock icon and its text |
|
||||
| md | 16px | `gap-4`, `px-4` | Permission row horizontal padding and label-to-control gap; segment horizontal padding |
|
||||
| lg | 24px | `gap-6` | Form grid column gap on the preview screen (inherited from FormGrid) |
|
||||
| xl | 32px | `px-8` | Preview footer horizontal padding (inherited from the form footer) |
|
||||
| 2xl | 48px | — | Not used by new components |
|
||||
| 3xl | 64px | — | Not used by new components |
|
||||
|
||||
Fixed component dimensions (all multiples of 4 unless listed under exceptions): bulk menu width 240, maximum 320; menu item height 40; row-state badge height 24; permission row minimum height 56; permission section header height 44 (`h-row-head` token); in-field password toggle button 32×32; lock icon 14.
|
||||
|
||||
Exceptions (inherited from Direction C and existing components, binding, not new):
|
||||
- **10px** (`gap-2.5`): gap between buttons in the list toolbar cluster and in the preview footer; menu item icon-to-text gap.
|
||||
- **12px** (`px-3`, `py-3`): bulk menu item horizontal padding (copies `UserMenu.vue` items); permission row vertical padding. No other new 12px spacing.
|
||||
- **14px** (`px-3.5`, `py-3.5`): control horizontal padding (password input, read-only preview box); callout and forbidden-banner vertical padding (copies `FormErrorBanner`).
|
||||
- **18px** (`px-[18px]`): callout and forbidden-banner horizontal padding (copies `FormErrorBanner`).
|
||||
- **20px** (`gap-5`): vertical gap between page blocks (header, status hint, card).
|
||||
- **6px** (`gap-1.5`): label-to-control gap of `FormField`, reused for the preview's `dt`-to-`dd` gap.
|
||||
- **34px** (`h-pager` token): height of one permission radio segment (same as a form tab).
|
||||
- **18px** checkbox box and **`rounded-[14px]`** menu radius: copied from `DataTable.vue` and `UserMenu.vue`.
|
||||
- **22px** form-grid row gap (`gap-y-[22px]`): inherited by the preview grid.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
New surfaces use exactly **3 sizes** and **2 weights**. All inherit DM Sans and the 14px / 1.5 base.
|
||||
|
||||
| Role | Size | Weight | Line Height | Used by |
|
||||
|------|------|--------|-------------|---------|
|
||||
| Body | 14px | 400 | 1.5 | Bulk menu items, preview values, permission labels, callout text, password input |
|
||||
| Label | 14px | 600 | 1.5 | Button text (existing `Button`), preview field labels (`dt`), permission section headers, selected radio segment, callout title |
|
||||
| Meta | 13px | 400 | 1.5 | Permission comments, the locked-items note, field comments, unselected radio segment text |
|
||||
| Badge | 12px | 600 | 1.5 | Row-state badges and the "Allow" column heading in checkbox mode (the size and weight of the existing list pills and table headers) |
|
||||
|
||||
Weights used by new surfaces: **400** and **600** only. Inherited chrome keeps its own style and is not varied: the preview page title copies the form title (`text-[24px] font-bold tracking-[-0.02em]`), and the confirmation title is the existing `ConfirmDialog` title (`text-[20px] font-bold`). No new surface introduces 500, 700, 17px or 26px text. Every count in a toast or confirm uses the existing plural forms; no new numeric display needs `tabular-nums`.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
All colours are existing CSS variables; light / dark values from `main.css`. Hard-coded hex values are forbidden in new components.
|
||||
|
||||
| Role | Value (light / dark) | Usage |
|
||||
|------|----------------------|-------|
|
||||
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; list card, preview card, bulk menu, permission editor rows |
|
||||
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6 | Read-only preview boxes, permission section headers and radio track, the "disabled" badge, the "deleted" badge border, muted row text, comments |
|
||||
| Accent (10%) | `primary` #22304d / #fcd34d; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45); `sel` #fdf3cf / #3a3622 | See the reserved list below |
|
||||
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | The "negative" badge and first-cell text, the selected "Deny" segment, danger callouts, the forbidden banner, invalid borders, the existing delete buttons and delete confirm |
|
||||
| Positive | `ok-bg` #e3f4e8 / #173826, `ok-text` #1c6b35 / #8fdfa8 | The selected "Allow" segment and the "Yes" pill in preview (the existing "Tak" badge colours) |
|
||||
|
||||
Accent reserved for (exhaustive for this phase):
|
||||
1. `primary` fill: the single primary button of each screen (the preview footer's edit button; the existing create and Save buttons), a checked permission checkbox, and the confirm button of a non-destructive confirmation (existing `ConfirmDialog`).
|
||||
2. `ring` (3px): focus-visible on every new focusable element. Never removed.
|
||||
3. `sel` tint: the warning callout background, and the existing selected-row and selection-pill backgrounds (unchanged).
|
||||
|
||||
Accent is never used for: bulk menu items, record action buttons (outline), row states, the "Inherit" segment, lock icons, or links in hints.
|
||||
|
||||
Destructive colour rule for actions: only the built-in `delete` (list toolbar, form footer) uses the `danger` button and the danger confirm. Declared bulk and record actions (activate, deactivate, restore, ban, unban, unsuspend) are reversible, so their menu items and buttons are neutral and their confirm button is `primary`.
|
||||
|
||||
### Partial style kit addition (framework, `admin/src/styles/main.css` `@layer components`)
|
||||
|
||||
Stable class names a preview status hint (a server partial) may use through the allowlisted `class` attribute. They read only public `--c-*` variables, so dark mode swaps with no extra rule.
|
||||
|
||||
| Class | Rules |
|
||||
|-------|-------|
|
||||
| `.summer-callout` | `display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 14px 18px; border-radius: 12px; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere`. No border. |
|
||||
| `.summer-callout--warning` | `background: var(--c-sel); color: var(--c-text)` |
|
||||
| `.summer-callout--danger` | `background: var(--c-danger-soft); color: var(--c-danger)` |
|
||||
| `.summer-callout__title` | `margin: 0; font-weight: 600` |
|
||||
| `.summer-callout__text` | `margin: 0; font-weight: 400` |
|
||||
|
||||
Recommended markup (framework docs and the neutral fixture). `role="status"` is on the allowlist and makes a hint that appears after an action polite to screen readers:
|
||||
|
||||
```html
|
||||
<div class="summer-callout summer-callout--warning" role="status">
|
||||
<p class="summer-callout__title">{{ trans "…" }}</p>
|
||||
<p class="summer-callout__text">{{ trans "…" }}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Callouts carry no icon (partials cannot render SVG) and no link or button: the action a hint refers to is a record action button in the footer.
|
||||
|
||||
---
|
||||
|
||||
## Surface Contracts
|
||||
|
||||
### S1. Bulk actions menu (D-09)
|
||||
|
||||
- **When shown:** the list schema's `bulkActions` contains at least one declared action after the server's permission filter. An action the admin may not run is **not rendered** (never shown disabled). With zero permitted actions there is no menu. The built-in `delete` is not in the menu: it stays the existing toolbar button.
|
||||
- **Placement:** in the `ListToolbar` right cluster (`gap-2.5`), directly after the selection pill and before the `toolbar.buttons` (Delete, then custom toolbar actions in declared order).
|
||||
- **Trigger:** `DropdownMenuTrigger as-child` around `Button variant="outline" size="md"`, label `backend::lang.list.bulk_actions`, with a trailing `ChevronDown` 16px `text-muted`. `data-action="bulk-actions"`. Disabled while nothing is selected or an action is running. A disabled trigger keeps its label; it does not hide.
|
||||
- **Menu:** `DropdownMenuRoot :modal="false"`, `DropdownMenuContent align="end" :side-offset="8"` with `z-50 flex w-[240px] max-w-[320px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu` (the `UserMenu` surface). Items are in declared order, with no icons, groups or separators.
|
||||
- **Item:** `DropdownMenuItem`, `flex min-h-10 cursor-pointer items-center rounded-control px-3 py-2 outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover`, 14px/400, `data-bulk-action="{name}"`, label = the action's localized `Label`. A long label wraps inside the 240–320px menu; it is never truncated.
|
||||
- **Confirm (always):** choosing an item opens `ConfirmDialog` (not danger). The message is the action's `Confirm` text; when the action declares none, `backend::lang.list.bulk_confirm` with `:action` = the label and `:count` = the selected count. The confirm button label is the action label. The request runs through `confirm.ask(request, run)`: the dialog stays open, both buttons disabled, with the spinner on the confirm button, until the POST settles.
|
||||
- **Request:** the ids of the current selection (selection is always the current page: any query change clears it).
|
||||
- **Success (200):** success toast with the server `message`; when the server sends none, `backend::lang.list.bulk_done` with the affected count. Then clear the selection, reload the list (new row states show), and bump the header-partial reload key.
|
||||
- **Failure:**
|
||||
|
||||
| Status | Presentation |
|
||||
|--------|--------------|
|
||||
| 409 (partial selection) | Danger toast `backend::lang.list.bulk_stale`; clear the selection and reload the list |
|
||||
| 403 | Danger toast: server `error.message`, else `backend::lang.list.action_forbidden`; selection kept |
|
||||
| 422 / other | Danger toast: server `error.message`, else `backend::lang.extension.action_failed`; selection kept; nothing reloaded |
|
||||
|
||||
- **Focus:** after the dialog closes (confirm or cancel), focus returns to the menu trigger. Esc in the menu closes it and returns focus to the trigger (Reka default).
|
||||
- **Delete button (unchanged visuals):** when a list sets `messages.deleteConfirm`, the confirm shows that text. Lists whose delete is permanent must say so there (see Plugin screens).
|
||||
|
||||
### S2. Record actions (D-10)
|
||||
|
||||
- **Where:** in the preview screen's footer (S3). v0.1.3 renders record actions on the preview screen only. A form that declares `recordActions` without a preview has nowhere to show them; the recommended server rule is a boot error (planner's decision, not a UI state).
|
||||
- **Which:** the record response lists the actions that (a) the admin may run and (b) apply to the record's current state. Anything else is **not rendered**.
|
||||
- **Style:** `Button variant="outline" size="md"`, text only, label = localized `Label`, `data-record-action="{name}"`, in declared order.
|
||||
- **Confirm (always):** `ConfirmDialog` (not danger), message = the action's `Confirm`, else `backend::lang.form.action_confirm` with `:action`. Confirm button label = the action label. Busy handling as in S1.
|
||||
- **Success:** success toast with the server `message`, else `backend::lang.form.action_done`. Then reload the record (values, labels, offered actions) and refetch the status hint. The previous content stays visible during the reload; no skeleton flash.
|
||||
- **Failure:**
|
||||
|
||||
| Status | Presentation |
|
||||
|--------|--------------|
|
||||
| 409 (no longer applies) | Danger toast `backend::lang.form.action_stale`; reload the record and the hint |
|
||||
| 404 | The existing form load-failure alert (`backend::lang.form.load_failed`) replaces the card; the footer keeps only the back path |
|
||||
| 403 | Danger toast: server message, else `backend::lang.list.action_forbidden` |
|
||||
| other | Danger toast: server message, else `backend::lang.extension.action_failed` |
|
||||
|
||||
- While one action runs, every footer button is disabled.
|
||||
|
||||
### S3. Preview screen (D-11)
|
||||
|
||||
- **Route:** `/:vendor/:plugin/:controller/:id(\d+)/preview`, name `preview`, inside the shell. `CONTROLLER_ROUTES` gains `preview`. `mapWinterUrl` maps `…/preview/:id` to it. Opening the route for a form with no preview replaces it with the record (update) route.
|
||||
- **Entry:** a list whose `recordUrl` points at preview opens it on row click and on the first-cell link. `create.redirect` and `update.redirectClose` may point at it.
|
||||
- **Layout:** the `FormView` page frame, unchanged in geometry: `section.flex.w-full.flex-col.gap-5.pb-24`.
|
||||
1. **Header:** the 40px outline back button (`ArrowLeft`, aria-label `backend::lang.form.return_to_list`, goes to the list); the title (record name: the first text field's value, 24/700 inherited) with the subtitle `messages.form.preview`; `FormTabs` when the visible fields declare tabs.
|
||||
2. **Status hint slot:** when the form's preview config names a header partial, `PartialHost variant="header"` renders it here, between the header and the card, outside every tab so it is always visible. It is refetched after every record action. Zero nodes: the slot renders nothing and takes no gap. Loading: one `bg-skel` block, 68px tall, `rounded-inner`, `aria-hidden`. Failure: the existing extension failure box with `backend::lang.extension.partial_failed`; the rest of the screen works.
|
||||
3. **Card:** `rounded-card border border-border bg-surface p-7 shadow-card`, holding the field grid as a `<dl>` with the `FormGrid` column and span rules.
|
||||
4. **Footer** (the form footer's geometry: `fixed inset-x-0 bottom-0 z-30 flex flex-wrap items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5`): nothing on the left; on the right (`ml-auto flex flex-wrap items-center gap-2.5`) the record actions (S2), then the one primary button: `Button variant="primary" :icon="Pencil"`, label `messages.form.edit`, linking to the update route, `data-action="edit"`. There is no delete and no save on preview.
|
||||
- **Focal point:** the record title first, then the status hint when present (the only tinted block on the page), then the primary edit button at the bottom right, the only `primary` fill.
|
||||
- **Fields shown:** every field whose `context` allows `preview` (a field with no `context` shows everywhere; `context: preview` shows only here).
|
||||
- **Field rendering (`PreviewField`):** each field is `<div class="flex min-w-0 flex-col gap-1.5">` with `<dt class="font-semibold">` (label, no required asterisk) and `<dd class="m-0">`. Values are text, never disabled inputs, so they keep full contrast and are read in order.
|
||||
|
||||
| Field type | `dd` content |
|
||||
|------------|--------------|
|
||||
| `text`, `number`, `dropdown` | The read-only box `flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5`, value as text (dropdown: the option label), `overflow-wrap: anywhere` |
|
||||
| `textarea` | The same box with `items-start py-3 whitespace-pre-wrap`; it grows with the text |
|
||||
| `checkbox`, `switch` | The list pills from `CellValue.vue`: "Yes" (`bg-ok-bg text-ok-text`) or "No" (outline, muted), using `backend::lang.list.column_switch_true` / `_false` |
|
||||
| `datepicker` | Its existing read-only rendering (12.2) |
|
||||
| `relation` (single) | The read-only box with the label, or the muted `emptyOption` / "—" |
|
||||
| `relation` (multiple) | The box with `flex-wrap gap-1.5 p-1.5` holding the existing chips without remove buttons; none: muted "—" |
|
||||
| `fileupload` | Its existing read-only mode (12.2) |
|
||||
| `partial` | Rendered as on a form |
|
||||
| `permissioneditor` | Rendered with every control disabled (S5 read-only) |
|
||||
| `password`, `widget`, `relation-manager` | Not rendered on preview |
|
||||
|
||||
An empty value shows a muted "—" (`backend::lang.list.empty_value`).
|
||||
- **Loading:** as `FormView`: the title is blank and the card is not rendered until the schema and record arrive; the footer buttons are disabled.
|
||||
- **Load failure / 404:** the existing alert block with `backend::lang.form.load_failed`; the back button still works.
|
||||
- **Update form when a preview exists:** the back arrow and Cancel go to the preview route, not the list (aria-label `backend::lang.form.return_to_preview`); the unsaved-changes confirm applies as today. After a delete, the form goes to the list.
|
||||
|
||||
### S4. Row state (D-12)
|
||||
|
||||
- **States:** a fixed framework set: `deleted`, `negative`, `disabled`. A row may carry several. Any other value from the server is ignored by the SPA.
|
||||
- **Announced in text: yes.** Colour and strike-through alone fail WCAG 1.4.1, so every state also renders a visible text badge in the row's first cell. Screen readers read it as part of the cell; no `aria-label` tricks.
|
||||
- **Badge labels:** list messages, overridable per list in `config_list.yaml` `messages` (`rowStateDeleted`, `rowStateNegative`, `rowStateDisabled`), defaulting to `backend::lang.messages.list.row_state_*`.
|
||||
- **Row and badge styling** (the row background is never changed, so the existing selected `bg-sel` and `hover:bg-hover` rules stay as they are):
|
||||
|
||||
| State | Row text | Badge |
|
||||
|-------|----------|-------|
|
||||
| `deleted` | First-cell text `line-through`; every cell `text-muted` | `border border-border-strong text-muted` (the existing "No" pill) |
|
||||
| `negative` | First-cell text `text-danger`; other cells unchanged | `bg-danger-soft text-danger` |
|
||||
| `disabled` | Every cell `text-muted`; first cell keeps weight 600 | `bg-subtle text-muted` |
|
||||
|
||||
- **Badge geometry:** `inline-flex h-6 shrink-0 items-center rounded-pill px-2.5 text-[12px] font-semibold whitespace-nowrap`, `data-row-state="{state}"`. Badges are never struck through.
|
||||
- **First cell with states:** `flex items-center gap-2`: the link or text (`min-w-0 truncate`), then the badges in the fixed order `deleted`, `negative`, `disabled` (`flex gap-2`). When states combine, the text styles combine (`deleted` + `negative`: struck through and `text-danger`).
|
||||
- **Row attribute:** `<tr data-row-states="deleted negative">` for tests and plugin CSS. No free CSS class from the server is ever applied.
|
||||
- Row click, selection, sorting and links are unchanged for every state. A `deleted` row is still openable.
|
||||
|
||||
### S5. `permissioneditor` field (D-16)
|
||||
|
||||
- **Registry:** `permissioneditor` joins `renderers`, and `groupLabelledTypes` (the visible label is a `<span>` the control's `role="group"` points at with `aria-labelledby`). It is a value field: its value is part of the save body.
|
||||
- **"Tabbed" means grouped.** Permissions are grouped by their `tab` into **sections** of one list, as the PHP editor renders them. The control does not nest a second tablist inside the form's tabs. Sections keep the option order the server sends; a permission with no `tab` goes to a last section labelled `backend::lang.permissioneditor.other`.
|
||||
- **Container:** `overflow-hidden rounded-inner border border-border` (`border-danger` when invalid).
|
||||
- **Section header:** `flex h-row-head items-center justify-between gap-4 bg-subtle px-4`, the tab label 14/600. In checkbox mode the right side shows the column heading `backend::lang.permissioneditor.allow` at 12/600 `text-muted`. Rendered as an `<h3>` inside a `role="group"` named by it.
|
||||
- **Permission row:** `flex min-h-[56px] items-center justify-between gap-4 border-t border-border px-4 py-3`. Left (`min-w-0 flex-col`): the label 14/400 with `id`, then the comment 13/400 `text-muted` when present. Right: the control, `shrink-0`. Below 640px the row wraps and the control sits under the label.
|
||||
- **Radio mode** (`mode: radio`): one `RadioGroupRoot orientation="horizontal"` per row, `aria-labelledby` the row label. Styled as a segmented control: track `inline-flex gap-1 rounded-inner border border-border bg-subtle p-1`; each `RadioGroupItem` is `inline-flex h-pager items-center rounded-tab px-4 text-[13px] text-muted transition-colors duration-150 ease-out hover:text-text`. Segments in fixed order with visible text:
|
||||
|
||||
| Segment | Value | Label key | Selected style |
|
||||
|---------|-------|-----------|----------------|
|
||||
| Allow | `1` | `backend::lang.permissioneditor.allow` | `bg-ok-bg text-ok-text text-[14px] font-semibold` |
|
||||
| Inherit | `0` | `backend::lang.permissioneditor.inherit` | `bg-surface text-text shadow-tab text-[14px] font-semibold` |
|
||||
| Deny | `-1` | `backend::lang.permissioneditor.deny` | `bg-danger-soft text-danger text-[14px] font-semibold` |
|
||||
|
||||
A code missing from the value is "Inherit". The state is carried by the selected segment's text, not by colour alone.
|
||||
- **Checkbox mode** (`mode: checkbox`): one `CheckboxRoot` per row, `aria-labelledby` the row label; the box is the list checkbox (`flex size-[18px] items-center justify-center rounded-checkbox border`, unchecked `border-border-strong bg-surface`, checked `border-primary bg-primary text-on-primary` with `Check` 14px). The row label is a `<label>` for it, so clicking the text toggles.
|
||||
- **Value emitted** (default; the wire shape is the planner's to confirm): an object of code → integer. Radio: `1` or `-1`, an inherited code omitted. Checkbox: `1` for a checked code, an unchecked code omitted. Codes not in the option list are never sent.
|
||||
- **Locked option** (an option the server marks locked): its control is `disabled` and `aria-disabled`, the row label is followed by `Lock` 14px `text-muted` (`aria-hidden`), and the comment line is replaced by `backend::lang.permissioneditor.locked` (13px muted, visible text). The stored value still shows. A locked row is skipped by Tab.
|
||||
- **Read-only** (preview, or `attributes: readonly: true`): every control disabled, no lock icons, no locked text.
|
||||
- **Keyboard:** Tab moves row to row; inside a radio row the arrow keys move and select (Reka). Space toggles a checkbox.
|
||||
- **Invalid:** a 422 on the field renders through the `FormField` error line and the container border becomes `danger`.
|
||||
- **Empty:** the server supplies no options: the read-only box (`flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 text-muted`) with `backend::lang.permissioneditor.empty`.
|
||||
- **Dirty:** any change marks the form dirty and clears the field's error, like any value field.
|
||||
- No "allow all" control, no search and no inner scroll: the page scrolls.
|
||||
|
||||
### S6. Locked relation options and the forbidden presentation (D-07; needs G4)
|
||||
|
||||
**Locked options in `RelationField`:**
|
||||
- **Multiple mode, selected and locked:** the chip keeps its look (`bg-subtle`, initials, label 14/600) but has no remove button. In its place sits a 24px slot holding `Lock` 14px `text-muted` (`aria-hidden`), and the chip carries visually hidden text `backend::lang.form.locked_item` (`:name`). Backspace in the empty search input skips locked chips: it removes the last unlocked chip, or does nothing.
|
||||
- **Listbox, locked and not selected:** the option is `aria-disabled="true"`, `text-muted cursor-not-allowed`, with `Lock` 14px at the right edge; no hover highlight, not chosen by click or Enter, and skipped by arrow-key movement.
|
||||
- **Single mode:** locked options in the listbox behave the same. When the current value itself is locked, the field renders the existing read-only box with a trailing `Lock` 14px.
|
||||
- **Note:** when the field has at least one locked option (selected or seen in the listbox), one line shows under the control: `flex items-center gap-2 text-[13px] text-muted`, `Lock` 14px, text `backend::lang.form.locked_note`. Its id is added to the control's `aria-describedby`.
|
||||
- The lock is a display aid only; the server enforces the rule.
|
||||
|
||||
**Forbidden save (403 on create or update):**
|
||||
- Nothing is saved and the form keeps every value the admin entered; it stays dirty.
|
||||
- A banner renders in the `FormErrorBanner` slot with its geometry (`flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`, `CircleAlert` 20px, `data-forbidden-banner`): the server `error.message`, else `backend::lang.form.forbidden`. It is not a toast, because a refused save must stay readable.
|
||||
- When `error.details` names fields, each message also renders on its field through the normal error line, and the first such field is focused (tab switched when needed), as for a 422.
|
||||
- The banner clears on the next save attempt.
|
||||
- **Forbidden delete, bulk action or record action:** a danger toast with the server message, else `backend::lang.list.action_forbidden`.
|
||||
|
||||
### S7. `password` field (D-19; needs G1/G2) and `preset` (needs G7)
|
||||
|
||||
**`type: password`:**
|
||||
- A wrapper `relative`; `<input type="password">` with `controlClass(invalid)` plus `h-input pr-12`, `autocomplete="new-password"`, `spellcheck="false"`. The label, comment and error line come from `FormField` as for a text field.
|
||||
- A show/hide toggle sits inside the right edge (`absolute top-1/2 right-1.5 -translate-y-1/2`): a 32×32 `rounded-pager text-muted hover:bg-hover hover:text-text` button with `Eye` / `EyeOff` 16px, `aria-pressed`, aria-label `backend::lang.form.show_password` / `hide_password`. It switches the input between `password` and `text`. It is reachable by Tab after the input.
|
||||
- The field is always empty on load: the server never sends a value. An empty field on update means "unchanged" and is not sent. A non-empty value marks the form dirty.
|
||||
- After a successful save, every password field is cleared and returns to hidden.
|
||||
- A confirmation is a second, ordinary `password` field; a mismatch is the server's 422 on the field. The SPA does not compare the two.
|
||||
- Never rendered on preview. Never written to a toast, a log or the URL.
|
||||
|
||||
**`preset` (text fields, create only):** while the target field is untouched, it follows the source field's value on each input (type `slug`: lower-case ASCII, runs of other characters become one `-`, trimmed). The first manual edit of the target stops it for the rest of the session. There is no visual indicator, and the target stays an ordinary editable input. On update nothing is filled.
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
All framework copy is keyed in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`. Overridable list and form copy uses cabana's existing `messages` blocks (`config_list.yaml`, `config_form.yaml`), defaulting to `backend::lang.messages.*`. Plural texts are CLDR maps (`one`, `few`, `many`, `other` for Polish). Placeholders use `:name` syntax. No literal UI string appears in `admin/src`.
|
||||
|
||||
### Framework: contract elements
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Primary CTA (preview footer) | `messages.form.edit` "Edit record" / "Edytuj rekord" (overridable per form) |
|
||||
| Bulk menu trigger | `list.bulk_actions` "Bulk actions" / "Działania masowe" |
|
||||
| Empty state (list) | Unchanged: `messages.list.empty` → `noRecordsMessage` → `list.no_records` "There are no records in this view." / "Brak rekordów w tym widoku." Plugins set their own (see Plugin screens). |
|
||||
| Empty state (permission editor) | `permissioneditor.empty` "No permissions are defined yet." / "Nie zdefiniowano jeszcze żadnych uprawnień." |
|
||||
| Empty state (preview value) | existing `list.empty_value` "—" |
|
||||
| Error state (forbidden save) | `form.forbidden` "You do not have permission to make this change. Nothing was saved." / "Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane." |
|
||||
| Error state (forbidden action) | `list.action_forbidden` "You do not have permission to run this action." / "Nie masz uprawnień do wykonania tej akcji." |
|
||||
| Error state (stale bulk selection) | `list.bulk_stale` "Some of the selected records are no longer available. The list has been refreshed; select the records again." / "Część zaznaczonych rekordów nie jest już dostępna. Lista została odświeżona; zaznacz rekordy ponownie." |
|
||||
| Error state (record action no longer applies) | `form.action_stale` "This action no longer applies to this record. The page has been refreshed." / "Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona." |
|
||||
| Error state (action failed, other) | existing `extension.action_failed` "The action could not be completed. Please try again." / "Nie udało się wykonać akcji. Spróbuj ponownie." |
|
||||
| Error state (preview load, status hint) | existing `form.load_failed`; existing `extension.partial_failed` |
|
||||
|
||||
### Framework: destructive and confirmed actions
|
||||
|
||||
| Action | Confirmation approach | Copy (EN / PL) |
|
||||
|--------|----------------------|----------------|
|
||||
| Declared bulk action | ConfirmDialog, not danger, always | The action's `Confirm`; default `list.bulk_confirm` "Run “:action” on the selected (:count)?" / "Wykonać „:action” na zaznaczonych (:count)?". Confirm button: the action label. |
|
||||
| Record action | ConfirmDialog, not danger, always | The action's `Confirm`; default `form.action_confirm` "Run “:action” on this record?" / "Wykonać „:action” na tym rekordzie?". Confirm button: the action label. |
|
||||
| Bulk delete (existing) | ConfirmDialog, `danger` (unchanged) | `messages.list.delete_confirm` (overridable); confirm button `form.delete` |
|
||||
| Form delete (existing) | ConfirmDialog, `danger` (unchanged) | `messages.form.delete_confirm` (overridable) |
|
||||
| Leaving a dirty form to preview | Existing unsaved-changes ConfirmDialog (unchanged) | `form.unsaved_confirm`, `form.discard` |
|
||||
|
||||
D-13: no typed confirmation anywhere. A permanent delete is made clear by its confirm text.
|
||||
|
||||
### Framework: success feedback (toasts, `role="status"`)
|
||||
|
||||
| Event | Copy (EN / PL) |
|
||||
|-------|----------------|
|
||||
| Bulk action done, no server message | `list.bulk_done` one: "Action completed for :count record." other: "Action completed for :count records." / one: "Wykonano akcję dla :count rekordu." few/many/other: "Wykonano akcję dla :count rekordów." |
|
||||
| Record action done, no server message | `form.action_done` "Action completed." / "Akcja została wykonana." |
|
||||
| Bulk or record action done, with server message | the server message, verbatim |
|
||||
|
||||
### Framework: all new keys (complete list for the planner)
|
||||
|
||||
`backend::lang.list.*`: `bulk_actions`; `bulk_confirm`; `bulk_done`; `bulk_stale`; `action_forbidden`.
|
||||
|
||||
`backend::lang.form.*`: `return_to_preview` "Back to preview" / "Wróć do podglądu"; `action_confirm`; `action_done`; `action_stale`; `forbidden`; `locked_item` "Locked: :name" / "Zablokowane: :name"; `locked_note` "Items marked with a lock need an additional permission to change." / "Zmiana pozycji oznaczonych kłódką wymaga dodatkowego uprawnienia."; `show_password` "Show password" / "Pokaż hasło"; `hide_password` "Hide password" / "Ukryj hasło".
|
||||
|
||||
`backend::lang.permissioneditor.*`: `allow` "Allow" / "Zezwól"; `inherit` "Inherit" / "Dziedzicz"; `deny` "Deny" / "Odmów"; `locked` "You cannot change this permission." / "Nie możesz zmienić tego uprawnienia."; `empty`; `other` "Other" / "Inne".
|
||||
|
||||
`backend::lang.messages.list.*` (new defaults; new keys on cabana's list messages, overridable per list; YAML spellings `rowStateDeleted`, `rowStateNegative`, `rowStateDisabled`): `row_state_deleted` "Deleted" / "Usunięty"; `row_state_negative` "Blocked" / "Zablokowany"; `row_state_disabled` "Inactive" / "Nieaktywny".
|
||||
|
||||
`backend::lang.messages.form.*` (new defaults; new keys on cabana's form messages, overridable per form; YAML spellings `preview`, `edit`): `preview` "Record preview" / "Podgląd rekordu"; `edit` "Edit record" / "Edytuj rekord".
|
||||
|
||||
The single-word segment labels (Allow / Inherit / Deny) and badge labels are state names, not CTAs.
|
||||
|
||||
---
|
||||
|
||||
## Plugin screens (`golem15.user`, repo `sm-user-plugin`)
|
||||
|
||||
Copy lives in the plugin's `lang/{en,pl}/lang.yaml` under the PHP key names (`golem15.user::lang.*`), with the PHP values unless marked **new** or **changed**. These screens are YAML-driven and add no component.
|
||||
|
||||
### Navigation and permissions
|
||||
|
||||
Main item `golem15.user::lang.users.menu_label` "Users" / "Użytkownicy" with side items "Users" / "Użytkownicy", `groups.all_groups` "User Groups" / "Grupy użytkowników", `organisation.menu_label` "Organisations" / "Organizacje". An item the admin may not open is not rendered (existing rule). The extra permission (D-04, discretion): code `golem15.users.manage_privileged_groups`, tab `plugin.tab`, label **new** `plugin.manage_privileged_groups` "Manage privileged group membership" / "Zarządzaj członkostwem w grupach uprzywilejowanych".
|
||||
|
||||
### Users list
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Primary CTA (heading button) | `users.new_user` "New User" / "Nowy użytkownik" |
|
||||
| Columns | Name, Email, Registered (`datetime`), Last seen (`datetime`); labels from `user.*` |
|
||||
| Filters | Group (scope select), Registered (date range), Activated (switch): the existing `FilterBar` controls |
|
||||
| Empty state | **new** `users.list_empty` "No users match this view. Clear the filters, or add one with New User." / "Brak użytkowników w tym widoku. Wyczyść filtry lub dodaj użytkownika przyciskiem Nowy użytkownik." |
|
||||
| Row-state badges | `deleted` → **new** `users.state_deactivated` "Deactivated" / "Dezaktywowany"; `negative` → **new** `users.state_banned` "Banned" / "Zbanowany"; `disabled` → **new** `users.state_not_activated` "Not activated" / "Nieaktywowany" |
|
||||
| Delete confirm (permanent, D-13) | `users.delete_selected_confirm` "Delete the selected users? These users will be permanently removed and unrecoverable." / **changed** "Usunąć wybranych użytkowników? Zostaną trwale usunięci bez możliwości odzyskania." |
|
||||
|
||||
Bulk actions, in menu order (D-14; delete is the toolbar button):
|
||||
|
||||
| Action | Label | Confirm | Success message |
|
||||
|--------|-------|---------|-----------------|
|
||||
| `activate` | "Activate selected" / "Aktywuj wybranych" | "Activate the selected users?" / "Aktywować wybranych użytkowników?" | "Successfully activated the selected users." / "Pomyślnie aktywowano wybranych użytkowników." |
|
||||
| `deactivate` | "Deactivate selected" / "Dezaktywuj wybranych" | "Deactivate the selected users?" / "Dezaktywować wybranych użytkowników?" | "Successfully deactivated the selected users." / "Pomyślnie dezaktywowano wybranych użytkowników." |
|
||||
| `restore` | "Restore selected" / **changed** "Przywróć wybranych" | "Restore the selected users?" / "Przywrócić wybranych użytkowników?" | "Successfully restored the selected users." / "Pomyślnie przywrócono wybranych użytkowników." |
|
||||
| `ban` | "Ban selected" / "Zbanuj wybranych" | "Ban the selected users?" / "Zbanować wybranych użytkowników?" | "Successfully banned the selected users." / **changed** "Pomyślnie zbanowano wybranych użytkowników." |
|
||||
| `unban` | "Unban selected" / "Odbanuj wybranych" | "Unban the selected users?" / "Odbanować wybranych użytkowników?" | "Successfully unbanned the selected users." / **changed** "Pomyślnie odbanowano wybranych użytkowników." |
|
||||
|
||||
Keys: `users.{action}_selected`, `users.{action}_selected_confirm`, `users.{action}_selected_success`.
|
||||
|
||||
### Users preview
|
||||
|
||||
- Row click opens preview; the preview's edit button reads `users.update_details` "Update details" / "Zaktualizuj szczegóły" (the form's `messages.edit`).
|
||||
- Preview-only fields: Created IP Address, Last IP Address. Not shown: password fields, `send_invite`, the Permissions editor (`context: update`).
|
||||
- No scoreboard in this phase (discretion: optional, left out; Registered and Last seen are already list columns).
|
||||
- **Status hint:** one callout, by precedence banned → deactivated → not activated → suspended. No hint when none applies.
|
||||
|
||||
| State | Callout | Title (EN / PL) | Text (EN / PL) |
|
||||
|-------|---------|-----------------|----------------|
|
||||
| Banned | danger | `users.banned_hint_title` "User has been banned" / "Użytkownik został zbanowany" | `users.banned_hint_desc` "This user has been banned by an administrator and will be unable to sign in." / "Ten użytkownik został zbanowany przez administratora i nie będzie mógł się zalogować." |
|
||||
| Deactivated (trashed) | danger | `users.trashed_hint_title` "User has deactivated their account" / "Użytkownik dezaktywował swoje konto" | `users.trashed_hint_desc` (PHP value, unchanged) |
|
||||
| Not activated | warning | `users.activate_warning_title` "User not activated!" / **changed** "Użytkownik nieaktywowany!" | `users.activate_warning_desc` "This user has not been activated and may be unable to sign in." / "Ten użytkownik nie został aktywowany i może nie być w stanie się zalogować." |
|
||||
| Suspended | warning | **new** `users.suspended_hint_title` "User is temporarily suspended" / "Użytkownik jest tymczasowo zawieszony" | **new** `users.suspended_hint_desc` "Too many failed sign-in attempts. The suspension ends on its own, or you can lift it now with Unsuspend user." / "Zbyt wiele nieudanych prób logowania. Blokada wygaśnie sama; możesz też zdjąć ją teraz przyciskiem Odblokuj użytkownika." |
|
||||
|
||||
Record actions (footer buttons, shown only when they apply):
|
||||
|
||||
| Action | Applies when | Label | Confirm | Success message |
|
||||
|--------|--------------|-------|---------|-----------------|
|
||||
| `activate` | not activated | **new** `users.activate_user` "Activate user" / "Aktywuj użytkownika" | `users.activate_confirm` "Do you really want to activate this user?" / "Czy na pewno chcesz aktywować tego użytkownika?" | `users.activated_success` "User has been activated" / "Użytkownik został aktywowany" |
|
||||
| `unban` | banned | `users.unban_user` "Unban this user" / "Odbanuj tego użytkownika" | `users.unban_confirm` "Do you really want to unban this user?" / "Czy na pewno chcesz odbanować tego użytkownika?" | `users.unbanned_success` "User has been unbanned" / "Użytkownik został odbanowany" |
|
||||
| `unsuspend` | suspended | **new** `users.unsuspend_user` "Unsuspend user" / "Odblokuj użytkownika" | `users.unsuspend_confirm` "Unsuspend this user?" / **changed** "Odblokować tego użytkownika?" | `users.unsuspend_success` "User has been unsuspended." / "Udało się odblokować użytkownika." |
|
||||
|
||||
### Users form
|
||||
|
||||
- Tabs: `user.account` "Account" / "Konto" (name, surname, email, `send_invite` on create, password and confirmation, groups, organisation, avatar) and "Permissions" / "Uprawnienia" (**new** `user.permissions_tab`; the `permissioneditor` in radio mode, update only). Name and surname move into the Account tab (the Go dialect has no outside fields); the avatar moves there too (no secondary-tab sidebar).
|
||||
- Password label: `user.create_password` on create, `user.reset_password` on update; one YAML field needs one label, so use **new** `user.password` "Password" / "Hasło" with **new** comment `user.password_comment` "On a new user, enter the sign-in password. On an existing user, leave empty to keep the current password." / "Dla nowego użytkownika wpisz hasło do logowania. Dla istniejącego pozostaw puste, aby nie zmieniać hasła." Confirmation: `user.confirm_password` and its comment.
|
||||
- `send_invite`: the existing checkbox toggle card, on by default, create only; label and comment from `user.send_invite*`.
|
||||
- Groups: the `relation` field in multiple mode; privileged groups are locked (S6) for an admin without the extra permission.
|
||||
- Forbidden save (D-07): server message **new** `users.privileged_group_forbidden` "You do not have permission to change membership of privileged groups. Nothing was saved." / "Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane.", with `details` on `groups` so the field is marked.
|
||||
- Delete confirm (permanent): `users.delete_confirm` "Do you really want to delete this user? This user will be permanently removed and unrecoverable." / **changed** "Czy na pewno chcesz usunąć tego użytkownika? Zostanie trwale usunięty bez możliwości odzyskania."
|
||||
- After create: go to preview. Save and close: go to preview. Cancel and back: go to preview.
|
||||
|
||||
### User Groups
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Primary CTA | `groups.new_group` "New Group" / "Nowa grupa" |
|
||||
| Columns | Name, Code, Created, Users (`users_count`, not sortable) |
|
||||
| Empty state | **new** `groups.list_empty` "No user groups yet. Create the first one with New Group." / "Brak grup użytkowników. Utwórz pierwszą przyciskiem Nowa grupa." |
|
||||
| Form | Name (left), Code (right, preset from name, comment `group.code_comment`), Description (textarea), Permissions (`permissioneditor`, checkbox mode) |
|
||||
| Delete confirm | `groups.delete_confirm` "Do you really want to delete this group?" / "Czy na pewno chcesz usunąć tę grupę?" |
|
||||
| Forbidden (D-06) | **new** `groups.privileged_forbidden` "You do not have permission to create, re-code or delete a privileged group. Nothing was saved." / "Nie masz uprawnień do tworzenia, zmiany kodu ani usuwania grup uprzywilejowanych. Nic nie zostało zapisane." (banner on save, toast on delete) |
|
||||
|
||||
No checkboxes, no bulk actions, no preview: rows open the update form.
|
||||
|
||||
### Organisations
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Primary CTA | `organisation.new` "New Organisation" / "Nowa organizacja" |
|
||||
| Columns | Name, Slug, Created |
|
||||
| Empty state | **new** `organisation.list_empty` "No organisations yet. Create the first one with New Organisation." / "Brak organizacji. Utwórz pierwszą przyciskiem Nowa organizacja." |
|
||||
| Form | Name (required, left), Slug (right, preset `slug` from name, comment `organisation.slug_comment`), Description (textarea), Avatar (`fileupload`, image, 120×120), Members tab (`relation-manager`, update only) |
|
||||
| Members toolbar | `link` → **new** `organisation.add_member` "Add member" / "Dodaj członka"; `unlink` → **new** `organisation.remove_members` "Remove selected" / "Usuń zaznaczonych" |
|
||||
| Members unlink confirm | **new** `organisation.remove_members_confirm` "Remove the selected (:count) from this organisation? The users are not deleted." / "Usunąć zaznaczonych (:count) z tej organizacji? Użytkownicy nie zostaną usunięci." |
|
||||
| Members empty | **new** `organisation.members_empty` "This organisation has no members yet. Add users with Add member." / "Ta organizacja nie ma jeszcze członków. Dodaj użytkowników przyciskiem Dodaj członka." |
|
||||
|
||||
No preview: rows open the update form. The relation manager's visuals are the 12.2 contract, unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Interaction & Accessibility Rules
|
||||
|
||||
- Focus: every new interactive element shows the 3px ring (inherited from `main.css`). Disabled and locked controls are not focusable.
|
||||
- State is never colour-only: row states have text badges, radio segments have text labels, locked items have an icon plus text (visible note, hidden per-chip text).
|
||||
- Keyboard: the bulk menu and radio groups use Reka's roving focus. Every action button is a native `<button type="button">`.
|
||||
- Motion: no new animation. Hover and selection use the 150ms ease-out colour change; the confirm dialog keeps its 200ms fade.
|
||||
- Dark mode: every new surface reads `--c-*` variables only.
|
||||
- Server text (action labels, confirms, messages, hint partials) is rendered as text or through the partial allowlist; never as HTML.
|
||||
|
||||
---
|
||||
|
||||
## UI Considerations
|
||||
|
||||
Applicable state considerations resolved: 37 covered, 5 backstop, 2 dismissed, 0 unresolved.
|
||||
|
||||
| Category | Element(s) | Status | Resolution / Reason |
|
||||
|----------|------------|--------|---------------------|
|
||||
| empty | S1 bulk menu | ✅ covered | With zero permitted bulk actions the menu is not rendered; with nothing selected the trigger is disabled and keeps its label |
|
||||
| loading | S1 bulk action in flight | ✅ covered | The confirm dialog stays open with both buttons disabled and a spinner on the confirm button until the POST settles; the trigger is disabled |
|
||||
| error | S1 bulk action | ✅ covered | 409: danger toast `list.bulk_stale`, selection cleared, list reloaded; 403: danger toast (server message or `list.action_forbidden`); other: danger toast (server message or `extension.action_failed`), selection kept |
|
||||
| populated | S1 bulk menu | ✅ covered | Items in declared order, 14/400, 40px minimum height, no icons or separators |
|
||||
| zero-one-many | S1 bulk action count | ✅ covered | The default confirm and the `list.bulk_done` toast use `:count` with CLDR plural forms; one selected row and many share one flow |
|
||||
| overflow | S1 bulk menu | ✅ covered | Many actions extend the menu downward; Reka keeps it inside the viewport with collision handling; the toolbar cluster wraps with the existing `flex-wrap` |
|
||||
| long-text | S1 menu item and confirm | ✅ covered | A long label wraps inside the 240–320px menu and is never truncated; the confirm message wraps inside the 440px dialog |
|
||||
| partial | S1 bulk result | ✅ covered | The whole batch succeeds or rolls back (one transaction); the toast shows the server's affected count, which may be lower than the selection when the plugin skips rows |
|
||||
| error | S1 focus return | 🧪 backstop | An SPA unit test asserts focus returns to the bulk menu trigger after the confirm dialog closes by confirm and by cancel |
|
||||
| empty | S2 record actions | ✅ covered | With no applicable action the footer shows only the edit button |
|
||||
| loading | S2 record action in flight | ✅ covered | Confirm dialog busy; every footer button disabled |
|
||||
| error | S2 record action | ✅ covered | 409: toast `form.action_stale` and reload; 404: the form load-failure alert; 403 and other: danger toast |
|
||||
| populated | S2 after success | ✅ covered | Toast, then the record and hint reload in place; previous content stays visible during the reload |
|
||||
| overflow / long-text | S2 footer buttons | ✅ covered | The footer and its right cluster use `flex-wrap`; buttons are `whitespace-nowrap` and wrap to a second row instead of shrinking |
|
||||
| loading | S3 preview | ✅ covered | As `FormView`: blank title, no card until schema and record arrive, footer buttons disabled |
|
||||
| error | S3 preview load / 404 | ✅ covered | The existing alert block with `form.load_failed`; the back button works |
|
||||
| empty | S3 preview values | ✅ covered | An empty value shows the muted "—"; an empty multiple relation shows "—" |
|
||||
| populated | S3 preview | ✅ covered | `dl` grid in the form card with read-only boxes per the PreviewField table |
|
||||
| long-text | S3 preview values | ✅ covered | Values wrap with `overflow-wrap: anywhere`; textarea values keep line breaks and the box grows; the title truncates as on the form |
|
||||
| partial | S3 preview with tabs | ✅ covered | Fields group into the same tabs as the form; a tab with no preview-visible field is not rendered |
|
||||
| zero-one-many | S3 multiple relation | ✅ covered | None: "—"; one or many: chips wrap inside the box |
|
||||
| loading | S3 status hint | ✅ covered | First fetch: one 68px `bg-skel` block; refetch keeps the previous hint visible |
|
||||
| empty | S3 status hint | ✅ covered | Zero nodes render nothing and take no gap |
|
||||
| error | S3 status hint | ✅ covered | The extension failure box with `extension.partial_failed`; the rest of the screen works |
|
||||
| long-text | S3 callout | ✅ covered | `.summer-callout` wraps with `overflow-wrap: anywhere` and grows; never truncated |
|
||||
| error | S3 route without preview | 🧪 backstop | An SPA unit test asserts `mapWinterUrl` maps `preview/:id` to the preview route and that opening the preview route of a form without preview replaces it with the record route |
|
||||
| populated | S4 row state | ✅ covered | Text badge per state in the first cell plus the row text style from the state table |
|
||||
| zero-one-many | S4 row states | ✅ covered | No state: the row is unchanged; one or several: badges in the fixed order and combined text styles |
|
||||
| long-text / overflow | S4 first cell | ✅ covered | The first-cell text truncates (`min-w-0 truncate`), badges never shrink (`shrink-0 whitespace-nowrap`); the table keeps its horizontal scroll |
|
||||
| error | S4 unknown state | 🧪 backstop | An SPA unit test asserts a state outside the fixed set renders no badge and no class, and that each known state renders its text badge |
|
||||
| loading / empty / error | S4 row state | ➖ dismissed | States arrive with the list rows; list loading, empty and error states belong to the unchanged list view |
|
||||
| empty | S5 permission editor | ✅ covered | No options: the read-only box with `permissioneditor.empty` |
|
||||
| populated | S5 permission editor | ✅ covered | Sections by tab; rows with label, comment and a radio segment group or a checkbox |
|
||||
| error | S5 permission editor | ✅ covered | A 422 renders on the `FormField` error line and the container border becomes `danger` |
|
||||
| partial | S5 locked options | ✅ covered | A locked row shows its stored value, a disabled control, the lock icon and the `permissioneditor.locked` text; other rows stay editable |
|
||||
| overflow | S5 many permissions | ✅ covered | No inner scroll; the page scrolls; section headers are not sticky |
|
||||
| long-text | S5 labels and comments | ✅ covered | Label and comment wrap in the left column (`min-w-0`); the control never shrinks; below 640px the control moves under the label |
|
||||
| zero-one-many | S5 sections | ✅ covered | One tab: one section with its header; permissions without a tab form a last "Other" section |
|
||||
| loading | S5 permission editor | ➖ dismissed | Options arrive with the form schema; there is no separate fetch to show |
|
||||
| error | S5 keyboard and value | 🧪 backstop | An SPA unit test asserts radio mode emits `1` / `-1` and omits inherit, checkbox mode emits `1` and omits unchecked, a locked row cannot change, and codes outside the options are never sent |
|
||||
| partial | S6 locked chips | ✅ covered | Locked chips have no remove button and are skipped by Backspace; unlocked chips work as before; the note shows under the field |
|
||||
| error | S6 forbidden save | ✅ covered | Banner with the server message or `form.forbidden`; values kept; fields named in `details` are marked and focused |
|
||||
| error | S6 locked option behaviour | 🧪 backstop | An SPA unit test asserts a locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace |
|
||||
| empty / error / partial | S7 password | ✅ covered | Always empty on load; empty on update is not sent; a 422 shows on the field; the field is cleared after a successful save |
|
||||
| long-text | S7 password | ✅ covered | The input scrolls horizontally like any text input; `pr-12` keeps text clear of the toggle |
|
||||
| empty | Plugin lists | ✅ covered | Each list sets its own empty copy (`users.list_empty`, `groups.list_empty`, `organisation.list_empty`); the empty-search state is the existing one |
|
||||
| zero-one-many | Users hint | ✅ covered | At most one callout by precedence; none when no state applies |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | none (shadcn not used: Vue project) | not applicable |
|
||||
| third-party registries | none | not applicable. No npm package or version is added (RESEARCH Package Legitimacy Audit, 2026-10-04) |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
| Source | Decisions used |
|
||||
|--------|----------------|
|
||||
| 12.1-CONTEXT.md | D-01, D-03, D-04, D-06, D-07, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-18, D-19, D-20, D-22, D-23, D-24, D-25, and the discretion list (row state set and text announcement, preview content, permission code and label) |
|
||||
| 12.1-RESEARCH.md | PHP screen inventory, gaps G1, G2, G4, G6, G7, contract names table (`bulkActions`, `recordActions`, route shapes), Pattern 3 (permission filter), Open Questions 3 and 6, the SPA seam list |
|
||||
| 10.1-UI-SPEC.md, 12.2-UI-SPEC.md | Format, tokens, partial style kit, "not rendered when not permitted" rule, toast and confirm patterns, failure box |
|
||||
| Codebase scan (`admin/src`, phrasebook, `cabana/messages.go`) | Every class string, existing keys reused, the `messages` override mechanism, partial allowlist (`class`, `role`) |
|
||||
| PHP reference (`plugins/golem15/user`) | Toolbar and hint partials, permission editor layout, `lang/{en,pl}` values |
|
||||
| Researcher defaults (no user question asked) | Bulk actions always in one menu; always-confirm for bulk and record actions; row-state text badges with overridable labels; status hint as a preview header partial with `.summer-callout`; permission editor as sections with a segmented radio; forbidden save as a banner; suspended hint copy; record actions on preview only; Polish copy corrections marked **changed** |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
- [ ] Dimension 7 Inventory Provenance: PASS
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user