docs(35): UI design contract

This commit is contained in:
Jakub Zych
2026-11-06 11:56:48 +01:00
parent 928ae0097d
commit 5db032f52f

View File

@@ -0,0 +1,964 @@
---
phase: "35"
slug: "mail-templates-and-layouts-editor"
status: approved
reviewed_at: "2026-11-06"
shadcn_initialized: false
preset: none
created: "2026-11-06"
---
# Phase 35 — UI Design Contract
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
Most of this phase is **YAML-declared cabana screens** rendered by the existing generic views in `admin/` (`ListView.vue`, `FormView.vue`, `SettingsFormView.vue`, `SettingsIndexView.vue`). This is the same approach as 19.6.1 (Administrators/Roles). The contract fixes six things:
1. **What the YAML declares** for the five Mail screens: columns, filters, form tabs and field order, record actions, settings entries.
2. **Four new SPA field controls**:
- `codeeditor` (D-09, a published type);
- `colorpicker` (Branding colours, Winter parity);
- `mailimage` (Branding logo, picked from the library);
- `mailpreview` (D-16 live preview, D-17 test send).
Plus one shared dialog, `MailImagePicker` (D-20), opened from the markdown body, the code editors and `mailimage`.
3. **Narrow additions to existing SPA code**:
- FormView renders record actions in its footer and hides Delete when the record says so;
- partial fields reload after a save or a record action;
- SettingsFormView provides the form values;
- RowStateBadges gets two more states;
- MarkdownField gets the image-insert button;
- main.css gets a `.summer-diff` kit.
4. **All copy** (en + pl) for settings entries, permissions, lists, forms, preview, test send, picker, Branding and every refusal (D-03, D-06, D-07, D-08, D-13, D-17).
5. **Where each refusal or failure shows up** in the existing UI (field line, banner, toast, inline status).
6. **The code-editor library**: CodeMirror 6. It needs a `.planning/research/STACK.md` row and a decision note (project dependency rule).
The SPA already has a complete design system, Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`). It is implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` on Reka UI primitives. **This contract adds no colour tokens, fonts, radii or shadows.** The new CSS is the `.summer-diff` component kit (existing tokens only) and the CodeMirror theme (existing CSS variables only). Class strings below are copied from existing components unless marked **new**.
Sources:
- 35-CONTEXT.md D-01..D-21 (locked);
- the approved 19.6.1 UI-SPEC (house conventions: phrasebook keys, CLDR plurals, refusal shapes, accent reserve);
- WinterCMS `modules/system/models/{mailtemplate,maillayout,mailpartial,mailbrandsetting}/{fields,columns}.yaml`, `MailBrandSetting.php` defaults and `lang/{en,pl}/lang.php` (`mail_templates.*`, `mail_brand.*`), for field sets and wording;
- a codebase scan (listed per section).
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none. There is no shadcn: this is a Vue 3 SPA with its own Direction C system, so the shadcn gate does not apply (the same ruling as 10.1, 12.1, 12.2, 16.3, 19.6.1) |
| Preset | not applicable |
| Component library | Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/` |
| Icon library | `@lucide/vue` 1.17.0, named imports only. Settings tiles use registry names already in `admin/src/app/icons.ts`: `mail`, `layout-grid`, `puzzle`, `palette`, `image`. New controls import `ImagePlus`, `Send`, `TriangleAlert`, `Info`, `Search`, `Upload`, `Check` and `X` directly, at 14px for inline notes and 16px for toolbar buttons |
| Font | DM Sans 400/600 (700 only in inherited headings), self-hosted `@fontsource/dm-sans`. **DM Mono 400** (`font-mono`, already self-hosted) for code: the code editor, plain-text preview, diff, hex values and code snippets in info partials |
| Code editor | **CodeMirror 6**, lazily loaded in its own chunk like the TipTap richeditor. Packages and pins are in Registry Safety. It needs a STACK.md "Admin SPA (npm) additions" row and a decision note `.planning/notes/2026-11-xx-admin-code-editor-codemirror.md`, written before the first plan that installs it |
**Why CodeMirror 6.**
- It is MIT and modular: state, view, language and the html/css grammars are separate packages, so the bundle carries only what the field uses.
- It needs no web workers, so the admin CSP is unchanged.
- It has keyboard and screen-reader support (a contenteditable with an ARIA textbox), and it lazy-loads cleanly behind a skeleton.
Rejected alternatives:
- **Monaco:** multi-MB, needs workers and `worker-src`, desktop-only feel.
- **Ace** (Winter's choice): older API, weaker accessibility.
- **A textarea with a highlight overlay:** fragile caret and scroll sync, no bracket matching.
- **TipTap source mode:** it is a rich-text tool, not a code editor.
---
## 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-11-06.
Local components were enumerated by `find admin/src/components -name '*.vue' | wc -l`: 63 components in summercms-admin@unversioned (a private workspace package with no `version` field in `admin/package.json`, pinned by repo commit 928ae00), on 2026-11-06.
Icons were verified on 2026-11-06 by running `node --input-type=module -e "import * as l from '@lucide/vue'; for (const n of ['Mail','Puzzle','Palette','Image','ImagePlus','Send','TriangleAlert','Info','Search','Upload','Check','X','Code']) console.log(n, n in l)"` in `admin/` against @lucide/vue@1.17.0. All are exported. The tile names `mail`, `layout-grid`, `puzzle`, `palette` and `image` are already mapped in `admin/src/app/icons.ts`, so no registry change is needed.
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 |
|-----------|-------------|-------|
| ListView, DataTable, FilterBar | `admin/src/views/ListView.vue`, `admin/src/components/list/*` | The Templates, Layouts and Partials lists. No change except the two row-state labels (C9) |
| RowStateBadges | `admin/src/components/list/RowStateBadges.vue` | Gains the `customized` and `outdated` states (C9) |
| FormView | `admin/src/views/FormView.vue` | The Template, Layout, Partial and Mail images (singleton) forms. Gains footer record actions, `meta.deletable` and the partial reload (C8) |
| SettingsFormView | `admin/src/views/SettingsFormView.vue` | The Branding page. Gains `provide(FORM_VALUES)` and `provide(FORM_LOCALE)` so `mailpreview` reads the draft (C8) |
| SettingsIndexView | `admin/src/views/SettingsIndexView.vue` | Five tiles in the new "Mail" category. No change |
| FormTabs | `admin/src/components/form/FormTabs.vue` | Form tabs, and its class strings for the preview's HTML/Plain text switch |
| FormGrid, FormField, FieldRenderer, `registry.ts` | `admin/src/components/form/*` | Register `codeeditor`, `colorpicker`, `mailimage`, `mailpreview`. `mailpreview` is valueless and group-labelled; `mailimage` is group-labelled |
| MarkdownField | `admin/src/components/form/fields/MarkdownField.vue` | Template body. Gains the "Insert image" button. Its own Preview toggle is hidden when the image option is on (C3) |
| RichEditorField (patterns only) | `admin/src/components/form/fields/RichEditorField.vue` | Lazy-load skeleton/failed pattern, toolbar class strings and the `font-mono text-[13px]` source textarea, copied for `codeeditor` |
| FileuploadField + FileCaptionModal | `admin/src/components/form/fields/FileuploadField.vue` | Mail images library (`mode: image`, attachMany tile grid, caption = alt text). No change |
| PartialField + PartialHost + `.summer-callout` kit | `admin/src/components/form/fields/PartialField.vue`, `main.css` | Info/drift partial and File version partial (server-rendered html/template) |
| RecordActions | `admin/src/components/form/RecordActions.vue` | "Reset to file version", now also in the FormView footer. No change to the component |
| RelationPickerModal (patterns only) | `admin/src/components/relation/RelationPickerModal.vue` | Dialog shell, search input and chosen-state class strings, copied for `MailImagePicker` |
| ConfirmDialog + `useConfirm` | `admin/src/components/ui/ConfirmDialog.vue`, `confirm.ts` | Reset, delete-layout and leave-with-changes confirmations |
| Button | `admin/src/components/ui/Button.vue` | `outline` / `ghost` / `danger` / `primary`, `size: sm` for in-field buttons |
| `showToast` | `admin/src/state/useToasts.ts` | Save, reset, test-send results and delete refusals |
| FormErrorBanner | `admin/src/components/form/FormErrorBanner.vue` | 422 count banner and 403 banner. No change |
| `FORM_VALUES`, `FORM_PATCH`, `FORM_SESSION` | `admin/src/components/form/formContext.ts` | `mailpreview` reads the draft; `mailimage` writes its value through the normal `update:modelValue` |
New `.vue` files:
- `admin/src/components/form/fields/CodeEditorField.vue`;
- `admin/src/components/form/fields/ColorPickerField.vue`;
- `admin/src/components/form/fields/MailImageField.vue`;
- `admin/src/components/form/fields/MailPreviewField.vue`;
- `admin/src/components/mail/MailImagePicker.vue`.
New `.ts` files:
- `admin/src/components/form/codeeditor/editor.ts` (CodeMirror setup, the lazy import target);
- `admin/src/components/mail/insertImage.ts` (the pure snippet builder, C4).
---
## Spacing Scale
Declared values for new markup in this phase (all multiples of 4):
| Token | Value | Tailwind | Usage |
|-------|-------|----------|-------|
| xs | 4px | `gap-1`, `p-1` | Editor and preview toolbar padding and button gap (copied from the richeditor toolbar) |
| sm | 8px | `gap-2` | Icon-to-text gaps, colour swatch to hex input, picker tile grid gap, test-send row gap |
| md | 16px | `px-4`, `gap-4` | Preview subject row horizontal padding, picker dialog stack (copied from RelationPickerModal `gap-4`), `.summer-diff` horizontal padding |
| lg | 24px | `gap-x-6`, `p-6` | Form grid column gap and dialog padding (existing, unchanged) |
| xl | 32px | `px-8` | Sticky footer padding (existing, unchanged) |
| 2xl | 48px | — | not used |
| 3xl | 64px | — | not used |
Fixed component heights (multiples of 16):
- **Preview frame:** 640px (`h-[640px]`).
- **Code editor by `size`:** `tiny` 128px, `small` 192px, `large` 320px (also the default when no size is set), `huge` 480px, `giant` 640px.
- **Plain-text preview and File version blocks:** at most 480px (`max-h-[480px]`), scrolling inside.
Exceptions, all inherited from Direction C. They were approved as locked in 16.3 and re-used in 19.6.1, and none is new:
- label-to-control gap 6px (`gap-1.5` in `FormField`);
- form row gap 22px (`gap-y-[22px]` in `FormGrid`);
- card padding 28px (`p-7`);
- page stack 20px (`gap-5`);
- footer button gap 10px (`gap-2.5`);
- callout padding 14×18 (`.summer-callout`);
- 12px vertical padding (`py-3`), used by the preview subject row and `.summer-diff`, as in the permission rows;
- control height 44px (`h-input`), used by the colour swatch and the picker search;
- toolbar button height 32px (`h-8`, richeditor toolbar);
- tab pill height 34px (`h-[34px]`, FormTabs);
- input content padding 14×10 (`px-3.5 py-2.5`, copied from the inputs), used by the C1 CodeMirror content and the read-only `pre`.
---
## Typography
New markup uses exactly these styles. Everything else is rendered by existing components unchanged.
| Role | Size | Weight | Line Height | Used by |
|------|------|--------|-------------|---------|
| Body | 14px | 400 | 1.5 | Field values, preview subject value, picker alt input, callout text, info partial text, toasts |
| Label | 14px | 600 | 1.5 | Field labels, tab pills, button text, callout titles, info partial `strong` lines, preview "Subject" label |
| Meta | 13px | 400 | 1.5 | Comments, error lines, preview status/notes, test-send hint, picker tile filenames, toolbar text buttons ("Insert image") |
| Code (Meta size, mono) | 13px DM Mono | 400 | 1.5 | **New:** CodeMirror content and gutter, plain-text preview `pre`, `.summer-diff`, hex text in `colorpicker`, `code` in info partials |
| Dialog heading (inherited) | 20px | 700 | 1.2 (`tracking-[-0.01em]`) | `MailImagePicker` title, copied from RelationPickerModal |
| Page heading (inherited) | 24px | 700 | 1.2 (`tracking-[-0.02em]`) | Record title in `FormView`/`SettingsFormView` header, unchanged |
Weights: new content uses 400 and 600 only. 700 appears only in the two inherited headings (the locked system-level exception from 16.3 and 19.6.1). Sizes in new markup are 13 and 14; 20 and 24 are inherited. No 12px text is added; the 12px row-state badge is reused unchanged. The 12px badge, the 20/24px headings and weight 700 are inherited exceptions from 16.3 and 19.6.1: the effective scale for new markup in this phase is 13/14px at 400/600, and the planner must not use 12, 20 or 24 in new markup.
---
## Color
All admin UI colours are existing CSS variables (light / dark from `main.css`). Hard-coded hex values are forbidden in new admin markup, with two exceptions:
1. the chosen-tile border `border-[#e0b020]`, copied verbatim from `RelationPickerModal`;
2. the preview canvas, explained below.
**Mail content colours are not UI tokens.** The Branding defaults below (Winter's `MailBrandSetting` values) are data that the server compiles into mail CSS; they never style the admin.
| Role | Value (light / dark) | Usage |
|------|----------------------|-------|
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page, form card, list table, code editor content area, picker dialog |
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6 | Editor and preview toolbars, the CodeMirror gutter, plain-text preview and `.summer-diff` background, template-action highlight background, the `customized` badge outline, comments, notes and `Info` icons |
| Accent (10%) | `primary` #22304d / #fcd34d; `sel` #fdf3cf / #3a3622; `ring` | See the reserved list |
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | The "Reset to file version" confirm button, the custom-layout Delete (danger outline) and its confirm, field error lines, the preview/test-send error line, `.summer-diff__del` lines, danger toasts |
| Semantic OK | `ok-bg` / `ok-text` | `.summer-diff__add` lines only. Code-editor strings and attribute values use `ok-text` (syntax colour, see below) |
| Preview canvas | `#ffffff` in both themes | The `mailpreview` iframe element background (`bg-white`). Mail clients render on white; without this, dark mode shows mail with a transparent body on the dark surface. It is not an admin UI colour |
**Code editor syntax colours** (CodeMirror `HighlightStyle` and `EditorView.theme`, all through `var(--c-*)`, so light and dark mode swap):
| Token | Variable |
|-------|----------|
| tag names, CSS properties, keywords | `--c-primary` |
| attribute values, strings | `--c-ok-text` |
| attribute names, CSS selectors, everything else | `--c-text` |
| comments, punctuation, brackets | `--c-muted` |
| Go template actions `{{ … }}` (a `MatchDecorator`, class `cm-summer-action`) | `--c-primary` on `--c-subtle`, radius 4px |
| gutter | `--c-subtle` background, `--c-muted` numbers |
| active line | `--c-hover` |
| selection | `--c-sel` |
| cursor | `--c-text` |
Weights stay 400; there is no italic.
Accent reserved for (exhaustive for this phase):
1. `primary` fill on the single primary button per screen:
- footer **Save** on every form and the Branding page (existing);
- **New layout** in the Layouts list toolbar;
- **Insert image** / **Use this image** in the picker dialog.
"Send test to me" and "Reset to file version" are not primary.
2. `primary` text: tag/property/keyword tokens and Go template actions in the code editor (syntax, above).
3. `sel` tint:
- the `outdated` row-state badge ("File changed");
- the drift callout (`.summer-callout--warning`);
- the chosen picker tile (with the copied `#e0b020` border);
- code-editor selection.
4. `ring` (3px): focus-visible on every focusable element, including the editor wrapper (`:focus-within`), colour swatches and picker tiles. Never removed.
Accent is never used for: record action buttons (outline), the test-send button (outline), the `customized` badge (neutral outline), comments, links, settings tiles, or the preview toolbar.
---
## Copywriting Contract
All copy lives in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`backend::lang.*`).
New groups:
- `mail:`, with nested `template:`, `layout:`, `partial:`, `brand:`, `images:`, `preview:`, `picker:` (names follow Winter's `mail_templates.*` / `mail_brand.*` meaning);
- `codeeditor:`, `colorpicker:`, `mailimage:` for the generic field controls.
Additions to existing groups:
- `settings.category_mail`;
- `permissions.manage_mail_templates[_comment]`, `permissions.manage_mail_layouts[_comment]`;
- `list.*` is not touched.
Conventions:
- Placeholders use `:name` / `:count` / `:email` / `:seconds` / `:message` / `:query`.
- Plural texts are CLDR maps (`one`/`other` in en; `one`/`few`/`many`/`other` in pl), never pipe strings.
- Polish is the design language; English values carry the same meaning.
- English spells "colour".
- Existing keys are reused unchanged and are not restated: `form.save`, `form.saving`, `form.save_and_close`, `form.cancel`, `form.delete`, `messages.form.saved`, `messages.form.deleted`, `form.load_failed`, `list.load_failed`, `messages.list.empty_search`, `list.empty_value`, the validation messages (`required`, `max.string`), `fileupload.*` and `form.tab_errors`.
Literal `{{ .Name }}` / `{{ .Content }}` / `{{ .logoUrl }}` in copy are shown as text (the SPA renders phrases as text). In YAML they must be quoted so the phrasebook does not treat them as placeholders.
### Primary CTAs and key states
| Element | Key | EN | PL |
|---------|-----|----|----|
| Templates list title | `mail.template.list_title` | Mail templates | Szablony wiadomości |
| Templates: record name (`config_form.yaml` `name`) / form subtitle | `mail.template.name` | Mail template | Szablon wiadomości |
| Templates: primary CTA | — | none in the list (templates come from plugin files; no create, no delete); the form's primary is the existing **Save** | — |
| Layouts list title | `mail.layout.list_title` | Mail layouts | Układy wiadomości |
| Layouts: record name / form subtitle | `mail.layout.name` | Mail layout | Układ wiadomości |
| Layouts: primary CTA (toolbar create; create-form title) | `mail.layout.new` | New layout | Nowy układ |
| Partials list title | `mail.partial.list_title` | Mail partials | Fragmenty wiadomości |
| Partials: record name / form subtitle | `mail.partial.name` | Mail partial | Fragment wiadomości |
| Partials: primary CTA | — | none in the list (partials come from files); the form's primary is **Save** | — |
| Branding page title (settings entry label) | `mail.brand.menu_label` | Mail branding | Wygląd wiadomości |
| Mail images page title (singleton `name`) | `mail.images.menu_label` | Mail images | Obrazy w wiadomościach |
| Empty state, Templates (`noRecordsMessage`) | `mail.template.list_empty` | No mail templates in this view. Clear the search and filters. | Brak szablonów wiadomości w tym widoku. Wyczyść wyszukiwanie i filtry. |
| Empty state, Layouts | `mail.layout.list_empty` | No layouts in this view. Clear the search, or create a layout. | Brak układów w tym widoku. Wyczyść wyszukiwanie lub utwórz układ. |
| Empty state, Partials | `mail.partial.list_empty` | No partials in this view. Clear the search. | Brak fragmentów w tym widoku. Wyczyść wyszukiwanie. |
| Error state, list or form load failed | existing `list.load_failed`, `form.load_failed` | unchanged | unchanged |
| Saved toast | existing `messages.form.saved` | unchanged | unchanged |
### Settings entries and permission catalog (D-02, D-03)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Settings category | `settings.category_mail` | Mail | Poczta e-mail |
| Templates tile label / description | `mail.template.menu_label` / `mail.template.menu_description` | Mail templates / Edit the subject and content of the mail the application sends. | Szablony wiadomości / Edytuj temat i treść wiadomości wysyłanych przez aplikację. |
| Layouts tile label / description | `mail.layout.menu_label` / `mail.layout.menu_description` | Mail layouts / Edit the layouts that wrap every message, or create your own. | Układy wiadomości / Edytuj układy otaczające każdą wiadomość lub utwórz własne. |
| Partials tile label / description | `mail.partial.menu_label` / `mail.partial.menu_description` | Mail partials / Edit the reusable blocks used in mail, such as buttons and panels. | Fragmenty wiadomości / Edytuj bloki wielokrotnego użytku w wiadomościach, np. przyciski i panele. |
| Branding tile label / description | `mail.brand.menu_label` / `mail.brand.menu_description` | Mail branding / Set the colours and logo used by every message. | Wygląd wiadomości / Ustaw kolory i logo używane we wszystkich wiadomościach. |
| Images tile label / description | `mail.images.menu_label` / `mail.images.menu_description` | Mail images / Upload images to use in mail templates, layouts and partials. | Obrazy w wiadomościach / Prześlij obrazy do użycia w szablonach, układach i fragmentach wiadomości. |
| `system.manage_mail_templates` label | `permissions.manage_mail_templates` | Manage mail templates | Zarządzanie szablonami wiadomości |
| … comment | `permissions.manage_mail_templates_comment` | Edit mail templates, preview them and send test messages to yourself. | Edycja szablonów wiadomości, ich podgląd i wysyłanie wiadomości testowych do siebie. |
| `system.manage_mail_layouts` label | `permissions.manage_mail_layouts` | Manage mail layouts and branding | Zarządzanie układami i wyglądem wiadomości |
| … comment | `permissions.manage_mail_layouts_comment` | Edit mail layouts, partials, branding and the mail image library. These change every message the application sends. | Edycja układów, fragmentów i wyglądu wiadomości oraz biblioteki obrazów. Zmiany dotyczą każdej wiadomości wysyłanej przez aplikację. |
Both codes go in the existing permission tab `permissions.tab_system` ("System"); there is no new tab. This follows Winter, which lists mail permissions under System.
### Lists (C3b, C5, C6, C9)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Column / search: code | `mail.code` | Code | Kod |
| Column: subject | `mail.template.subject` | Subject | Temat |
| Column: description | `mail.template.description` | Description | Opis |
| Column / filter: locale | `mail.template.locale` | Locale | Język |
| Locale value for a file without a suffix | `mail.template.locale_default` | Default | Domyślny |
| Column: layout | `mail.template.layout` | Layout | Układ |
| Column (invisible): customized at | `mail.customized_at` | Customized | Dostosowano |
| Column: name (layouts, partials) | `mail.name_field` | Name | Nazwa |
| Column: source (layouts, partials) | `mail.source` | Source | Źródło |
| Source value of a database-only layout | `mail.source_custom` | Custom | Własny |
| Filter: status (scope) | `mail.filter_status` | Status | Status |
| Filter option | `mail.filter_customized` | Customized | Dostosowane |
| Filter option | `mail.filter_not_customized` | Not customized | Niedostosowane |
| Filter option | `mail.filter_outdated` | File changed | Plik zmieniony |
| Row state `customized` (`messages.rowStateCustomized`) | `mail.state_customized` | Customized | Dostosowany |
| Row state `outdated` (`messages.rowStateOutdated`) | `mail.state_outdated` | File changed | Plik zmieniony |
The source value of a file-backed record is the owning plugin's ID as text (for example `golem15.user`). It is not translated.
### Template form (C3b)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Tab 1 | `mail.template.tab_template` | Template | Szablon |
| Tab 2 (update only) | `mail.tab_file_version` | File version | Wersja z pliku |
| Subject label / comment | `mail.template.subject` / `mail.template.subject_comment` | Subject / One line. Variables such as {{ .Name }} work here too. | Temat / Jeden wiersz. Zmienne, np. {{ .Name }}, działają także tutaj. |
| Layout label / comment | `mail.template.layout` / `mail.template.layout_comment` | Layout / The layout wraps the message. Custom layouts are listed too. | Układ / Układ otacza wiadomość. Na liście są też układy własne. |
| Body label / comment | `mail.template.body` / `mail.template.body_comment` | Message / Markdown with variables such as {{ .Name }}. The plain-text version is made from it automatically. | Treść / Markdown ze zmiennymi, np. {{ .Name }}. Wersja tekstowa powstaje z niej automatycznie. |
| Preview label | `mail.preview.label` | Preview | Podgląd |
### Layout form (C5)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Tabs | `mail.layout.tab_html` / `mail.layout.tab_text` / `mail.layout.tab_css` / `mail.tab_file_version` | HTML / Plain text / CSS / File version | HTML / Zwykły tekst / CSS / Wersja z pliku |
| Name label | `mail.name_field` | Name | Nazwa |
| Code label / comment (create only) | `mail.code` / `mail.layout.code_comment` | Code / Templates refer to the layout by this code. It cannot be changed later. | Kod / Szablony odwołują się do układu przez ten kod. Później nie można go zmienić. |
| HTML wrapper label / comment | `mail.layout.html` / `mail.layout.html_comment` | HTML layout / Wraps the HTML message. Put {{ .Content }} where the message goes. | Układ HTML / Otacza wiadomość HTML. Wstaw {{ .Content }} w miejscu treści. |
| Text wrapper label / comment | `mail.layout.text` / `mail.layout.text_comment` | Plain-text layout / Wraps the plain-text message. Put {{ .Content }} where the message goes. | Układ tekstowy / Otacza wiadomość tekstową. Wstaw {{ .Content }} w miejscu treści. |
| CSS label / comment | `mail.layout.css` / `mail.layout.css_comment` | CSS / Styles for this layout. They are inlined into the HTML message after the branding styles. | CSS / Style tego układu. Są wstawiane do wiadomości HTML po stylach wyglądu. |
| Delete confirm (custom layouts only, danger) | `mail.layout.delete_confirm` | Delete this layout? This cannot be undone. A layout that customized templates still use cannot be deleted. | Usunąć ten układ? Tej operacji nie można cofnąć. Nie można usunąć układu, którego używają dostosowane szablony. |
### Partial form (C6)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Tabs | `mail.partial.tab_html` / `mail.partial.tab_text` / `mail.tab_file_version` | HTML / Plain text / File version | HTML / Zwykły tekst / Wersja z pliku |
| HTML label / comment | `mail.partial.html` / `mail.partial.html_comment` | HTML / Used in the HTML part of messages. | HTML / Używany w części HTML wiadomości. |
| Text label / comment | `mail.partial.text` / `mail.partial.text_comment` | Plain text / Used in the plain-text part of messages. | Zwykły tekst / Używany w części tekstowej wiadomości. |
### Info partial and drift notice (C7, D-06, D-07)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Line: description (templates) | `mail.info_description` | Description: :text | Opis: :text |
| Line: file | `mail.info_file` | File: :path in :plugin | Plik: :path we wtyczce :plugin |
| Status: not customized | `mail.info_not_customized` | Not customized. The form shows the file version; saving creates your version. | Nie dostosowano. Formularz pokazuje wersję z pliku; zapisanie utworzy Twoją wersję. |
| Status: customized | `mail.info_customized` | Customized. Your version is sent instead of the file. | Dostosowano. Zamiast pliku wysyłana jest Twoja wersja. |
| Status: custom layout | `mail.info_custom_layout` | Custom layout. It exists only in the database and can be deleted. | Układ własny. Istnieje tylko w bazie danych i można go usunąć. |
| Variables line (samples declared) | `mail.info_variables` | Variables with sample values: | Zmienne z przykładowymi wartościami: |
| Variables line (no samples) | `mail.info_no_samples` | This file declares no sample values. Variables show as [name] in the preview. | Ten plik nie deklaruje przykładowych wartości. W podglądzie zmienne są pokazane jako [nazwa]. |
| Partial usage line | `mail.info_partial_usage` | Use it in a template or layout with: | Użyj go w szablonie lub układzie tak: |
| Drift callout title | `mail.drift_title` | The file changed after this was customized | Plik zmienił się po dostosowaniu |
| Drift callout text | `mail.drift_text` | A plugin update changed the file. Your version is still sent. Compare both on the File version tab, then save your version again to mark the change as reviewed, or reset to the file version. | Aktualizacja wtyczki zmieniła plik. Nadal wysyłana jest Twoja wersja. Porównaj obie w zakładce Wersja z pliku, a potem zapisz swoją wersję ponownie, aby oznaczyć zmianę jako sprawdzoną, albo przywróć wersję z pliku. |
| File version: changes heading | `mail.file_changes` | Changes from the file | Zmiany względem pliku |
| File version: legend | `mail.file_legend` | Lines starting with + are only in your version. Lines starting with − are only in the file. | Wiersze zaczynające się od + są tylko w Twojej wersji. Wiersze zaczynające się od − są tylko w pliku. |
| File version: content heading | `mail.file_content` | File version | Wersja z pliku |
| File version: identical | `mail.file_identical` | Your version matches the file. | Twoja wersja jest zgodna z plikiem. |
| File version: not customized | `mail.file_not_customized` | Not customized: the form already shows this version. | Nie dostosowano: formularz pokazuje już tę wersję. |
### Record actions and destructive confirmations (D-06, D-08)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Record action: Reset | `mail.reset` | Reset to file version | Przywróć wersję z pliku |
| Reset confirm, template (danger) | `mail.template.reset_confirm` | Reset this template to the file version? Your subject, message and layout choice are deleted, and unsaved edits on this page are discarded. This cannot be undone. | Przywrócić wersję szablonu z pliku? Twój temat, treść i wybór układu zostaną usunięte, a niezapisane zmiany na tej stronie odrzucone. Tej operacji nie można cofnąć. |
| Reset confirm, layout (danger) | `mail.layout.reset_confirm` | Reset this layout to the file version? Your HTML, plain-text and CSS changes are deleted, and unsaved edits on this page are discarded. This cannot be undone. | Przywrócić wersję układu z pliku? Twoje zmiany HTML, tekstu i CSS zostaną usunięte, a niezapisane zmiany na tej stronie odrzucone. Tej operacji nie można cofnąć. |
| Reset confirm, partial (danger) | `mail.partial.reset_confirm` | Reset this partial to the file version? Your HTML and plain-text changes are deleted, and unsaved edits on this page are discarded. This cannot be undone. | Przywrócić wersję fragmentu z pliku? Twoje zmiany HTML i tekstu zostaną usunięte, a niezapisane zmiany na tej stronie odrzucone. Tej operacji nie można cofnąć. |
| Reset success toast | `mail.reset_done` | Reset to the file version. | Przywrócono wersję z pliku. |
| Delete refused: layout in use (403 `Message`, toast) | `mail.layout.delete_in_use` | one: "The layout “:name” is used by :count customized template. Choose another layout for it first." other: "The layout “:name” is used by :count customized templates. Choose another layout for them first." | one: "Układ „:name” jest używany przez :count dostosowany szablon. Najpierw wybierz dla niego inny układ." few: "Układ „:name” jest używany przez :count dostosowane szablony. Najpierw wybierz dla nich inny układ." many: "Układ „:name” jest używany przez :count dostosowanych szablonów. Najpierw wybierz dla nich inny układ." other: "Układ „:name” jest używany przez :count dostosowanego szablonu. Najpierw wybierz dla nich inny układ." |
### Preview and test send (C10, D-15, D-16, D-17)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Format switch | `mail.preview.html` / `mail.preview.text` | HTML / Plain text | HTML / Zwykły tekst |
| Subject row label | `mail.preview.subject` | Subject | Temat |
| iframe title | `mail.preview.frame_title` | Preview of the HTML message | Podgląd wiadomości HTML |
| Rendering status | `mail.preview.rendering` | Rendering preview… | Renderowanie podglądu… |
| Empty draft | `mail.preview.empty` | Nothing to preview yet. Write the message first. | Nie ma jeszcze czego podglądać. Najpierw napisz treść wiadomości. |
| Render error (keeps the last good render) | `mail.preview.failed` | The draft cannot be rendered: :message The preview shows the last version that rendered. | Nie można wyrenderować wersji roboczej: :message Podgląd pokazuje ostatnią wersję, która się wyrenderowała. |
| Render error, nothing rendered yet | `mail.preview.failed_first` | The draft cannot be rendered: :message | Nie można wyrenderować wersji roboczej: :message |
| Preview unavailable (network/5xx) | `mail.preview.unavailable` | Preview unavailable. It updates again with your next edit. | Podgląd niedostępny. Odświeży się przy następnej zmianie. |
| Sample note (templates) | `mail.preview.sample_note` | Variables are filled with sample values. A variable without a sample shows as [name]. | Zmienne są wypełnione przykładowymi wartościami. Zmienna bez przykładu jest pokazana jako [nazwa]. |
| Sample note (layouts, partials, branding) | `mail.preview.sample_content_note` | Previewed with sample content. | Podgląd z przykładową treścią. |
| Test-send button | `mail.preview.test_send` | Send test to me | Wyślij test do mnie |
| Test-send busy | `mail.preview.test_sending` | Sending… | Wysyłanie… |
| Test-send hint | `mail.preview.test_hint` | Sends the current draft, unsaved changes included, to :email. | Wysyła bieżącą wersję roboczą, razem z niezapisanymi zmianami, na adres :email. |
| Test-send success toast | `mail.preview.test_sent` | Test message sent to :email. | Wysłano wiadomość testową na adres :email. |
| Test-send rate limited (429) | `mail.preview.test_limited` | You have sent too many test messages. Try again in :seconds s. | Wysłano zbyt wiele wiadomości testowych. Spróbuj ponownie za :seconds s. |
| Test-send refused (422, draft invalid) | `mail.preview.test_invalid` | Fix the draft before sending a test: :message | Popraw wersję roboczą przed wysłaniem testu: :message |
| Test-send driver failure (5xx) | `mail.preview.test_failed` | The test message could not be sent. Check the mail settings and the application log. | Nie udało się wysłać wiadomości testowej. Sprawdź ustawienia poczty i log aplikacji. |
### Image picker and image fields (C4, C11, D-18, D-20)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Editor toolbar button | `mail.picker.insert_button` | Insert image | Wstaw obraz |
| Dialog title, insert mode | `mail.picker.insert_title` | Insert an image from the library | Wstaw obraz z biblioteki |
| Dialog title, choose mode (`mailimage`) | `mail.picker.choose_title` | Choose an image | Wybierz obraz |
| Search label / placeholder | `mail.picker.search` | Search images | Szukaj obrazów |
| Alt text label / comment | `mail.picker.alt` / `mail.picker.alt_comment` | Alternative text / Describes the image for people who cannot see it. Leave it empty for a decorative image. | Tekst alternatywny / Opisuje obraz osobom, które go nie widzą. Zostaw puste dla obrazu dekoracyjnego. |
| Primary, insert mode | `mail.picker.insert` | Insert image | Wstaw obraz |
| Primary, choose mode | `mail.picker.use` | Use this image | Użyj tego obrazu |
| Upload button | `mail.picker.upload` | Upload image | Prześlij obraz |
| Uploading | `mail.picker.uploading` | Uploading… | Przesyłanie… |
| Empty library heading | `mail.picker.empty_title` | No images yet | Brak obrazów |
| Empty library body (may upload) | `mail.picker.empty_upload` | Upload an image to use it in mail. | Prześlij obraz, aby użyć go w wiadomościach. |
| Empty library body (pick only) | `mail.picker.empty_pick_only` | Images are added in Settings, Mail images, by an administrator who manages mail layouts. | Obrazy dodaje w Ustawieniach, w sekcji Obrazy w wiadomościach, administrator zarządzający układami wiadomości. |
| No search results | `mail.picker.no_results` | No images match “:query”. | Brak obrazów pasujących do „:query”. |
| Load failed | `mail.picker.load_failed` | The image library could not be loaded. Close this window and try again. | Nie udało się wczytać biblioteki obrazów. Zamknij to okno i spróbuj ponownie. |
| Close (icon button aria-label) | existing `form.cancel` | unchanged | unchanged |
| `mailimage`: nothing chosen | `mailimage.empty` | No image chosen | Nie wybrano obrazu |
| `mailimage`: choose / change / remove | `mailimage.choose` / `mailimage.change` / `mailimage.remove` | Choose image / Change image / Remove image | Wybierz obraz / Zmień obraz / Usuń obraz |
| `mailimage`: image gone from library | `mailimage.missing` | This image is no longer in the library. Choose another one. | Tego obrazu nie ma już w bibliotece. Wybierz inny. |
| Mail images field label / comment | `mail.images.field` / `mail.images.field_comment` | Images / Stored on the public disk and linked from mail by their public address. The caption title is the image's alternative text. Removing an image breaks every template, layout or partial that still shows it. | Obrazy / Przechowywane na dysku publicznym i linkowane z wiadomości przez publiczny adres. Tytuł podpisu to tekst alternatywny obrazu. Usunięcie obrazu psuje każdy szablon, układ lub fragment, który nadal go pokazuje. |
### Code editor and colour picker (C1, C2)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Editor failed to load (fallback textarea note) | `codeeditor.load_failed` | The code editor could not be loaded. You can still edit the text here. | Nie udało się wczytać edytora kodu. Nadal możesz edytować tekst tutaj. |
| Toolbar aria-label | `codeeditor.toolbar` | Code editor tools | Narzędzia edytora kodu |
| Colour swatch aria-label | `colorpicker.pick` | Pick a colour for “:field” | Wybierz kolor: „:field” |
| Empty hex placeholder | `colorpicker.not_set` | Not set | Nie ustawiono |
| Invalid colour (422 field line) | `colorpicker.invalid` | Enter a colour as #rrggbb, for example #d66829. | Podaj kolor w formacie #rrggbb, na przykład #d66829. |
### Branding page (C12, D-10, D-11)
| Field | Key | EN | PL | Default (Winter) |
|-------|-----|----|----|------------------|
| `logo` (mailimage) label / comment | `mail.brand.logo` / `mail.brand.logo_comment` | Logo / Layouts show the logo where they use {{ .logoUrl }}. | Logo / Układy pokazują logo tam, gdzie używają {{ .logoUrl }}. | none |
| `body_bg` | `mail.brand.body_bg` | Body background | Główne tło | #f5f8fa |
| `content_bg` | `mail.brand.content_bg` | Content background | Tło treści | #ffffff |
| `header_color` | `mail.brand.header_color` | Header colour | Kolor górnego nagłówka | #bbbfc3 |
| `footer_color` | `mail.brand.footer_color` | Footer colour | Kolor stopki | #aeaeae |
| `heading_color` | `mail.brand.heading_color` | Headings colour | Kolor nagłówków | #2f3133 |
| `text_color` | `mail.brand.text_color` | Text colour | Kolor tekstu | #74787e |
| `link_color` | `mail.brand.link_color` | Link colour | Kolor linków | #2da7c7 |
| `button_text_color` | `mail.brand.button_text_color` | Button text colour | Kolor tekstu przycisków | #ffffff |
| `button_primary_bg` | `mail.brand.button_primary_bg` | Primary button background | Tło głównego przycisku | #d66829 |
| `button_positive_bg` | `mail.brand.button_positive_bg` | Positive button background | Tło pozytywnego przycisku | #52a838 |
| `button_negative_bg` | `mail.brand.button_negative_bg` | Negative button background | Tło negatywnego przycisku | #e01346 |
| `panel_bg` | `mail.brand.panel_bg` | Panel background | Tło panelu | #edeff2 |
| `table_border_color` | `mail.brand.table_border_color` | Table border colour | Kolor ramki tabeli | #edeff2 |
| `_preview` (mailpreview) comment | `mail.brand.preview_comment` | Until this page is saved for the first time, mail uses the brand CSS from the configuration (mail.brandCss). After that, these colours replace it. | Dopóki ta strona nie zostanie zapisana po raz pierwszy, wiadomości używają stylów wyglądu z konfiguracji (mail.brandCss). Potem zastępują je te kolory. | — |
The set is D-10's list plus `panel_bg` and `table_border_color`, the colours of the built-in `panel` and `table` partials (D-12). Winter's promotion and subcopy colours are left out, because no built-in partial uses them. The planner may extend the set; each added colour follows the same row shape.
### Save validation (D-13): server copy for 422 field lines
| Rule | Key | EN | PL | Field |
|------|-----|----|----|-------|
| Parse error | `mail.invalid_parse` | This cannot be read as a template: :message | Nie można odczytać tego jako szablonu: :message | the edited content field (`body`, `content_html`, `content_text`, `content_css`) |
| Test render failed | `mail.invalid_render` | This does not render with the sample values: :message | To się nie renderuje z przykładowymi wartościami: :message | the field whose content failed |
| Unsafe content | `mail.invalid_unsafe` | Mail cannot contain scripts, frames, embedded objects, inline event handlers or javascript:, vbscript: or data: addresses. Found: :message | Wiadomość nie może zawierać skryptów, ramek, osadzonych obiektów, atrybutów zdarzeń ani adresów javascript:, vbscript: lub data:. Znaleziono: :message | the field whose content produced it (`body` for templates) |
| Subject with a line break | `mail.invalid_subject_line` | The subject must be a single line. | Temat musi mieścić się w jednym wierszu. | `subject` |
| Unknown layout | `mail.invalid_layout` | Choose a layout from the list. | Wybierz układ z listy. | `layout` |
| Unknown partial referenced | `mail.invalid_partial` | The partial “:name” does not exist. | Fragment „:name” nie istnieje. | the field that references it |
| Layout without {{ .Content }} | `mail.invalid_no_content` | Put {{ .Content }} where the message goes. | Wstaw {{ .Content }} w miejscu treści. | `content_html` or `content_text` |
| Custom layout code taken | `mail.layout.code_taken` | This code is already used by another layout. | Ten kod jest już używany przez inny układ. | `code` |
| Custom layout code format | `mail.layout.code_format` | Use lowercase letters, digits and dashes only. | Używaj tylko małych liter, cyfr i myślników. | `code` |
`:message` is the parser/renderer/safety-checker text from postcard, passed through as plain text. It names the line when the parser reports one.
---
## Focal Points
- **Lists** (templates, layouts, partials): the code/name column with its row-state badges.
- **Template, layout and partial forms:** the body editor, with the preview directly under it.
- **Branding page:** the live preview.
- **Mail images page:** the image tile grid.
- **Image picker:** the tile grid; the primary button stays disabled until a tile is chosen.
---
## Component Contracts
Field names in the YAML below are recommendations (`body`, `content_html`, …). The planner may rename them, but the rendering, ordering and copy rules are fixed. New YAML keys are recommendations too: `language`, `mailImages`, and `mode` on `mailpreview` (`mode` is an existing key). Each new key goes into `formFieldKeys` (or type-specific options), the swag annotations, `admin/openapi/admin.json`, `schema.d.ts`, the cabana README and the docs.
### C1. `codeeditor` field (D-09) — `CodeEditorField.vue`
**YAML.**
- `type: codeeditor`;
- `language: html | css | text` (required; anything else is a boot error naming controller, field and value);
- optional `size` (`tiny|small|large|huge|giant`, heights in Spacing);
- optional `mailImages: true` (C4).
The value is a string. It is part of the save body and sent unchanged until edited, like `richeditor`.
**Loading.**
- CodeMirror is imported lazily from `codeeditor/editor.ts`.
- While it loads, the control shows `<div data-codeeditor-loading aria-busy="true" class="rounded-control bg-skel" :style="{height}">`, copied from the richeditor loading box, at the size's height.
- If the import fails, it shows the note `codeeditor.load_failed` (13px muted, `Info` 14px, `gap-2`), then a plain `<textarea>` with `font-mono text-[13px]`, `spellcheck="false"`, the same height, `controlClass(invalid)` and `resize-y`. The form keeps working.
**Markup when ready.**
- The wrapper is `rounded-control border border-border-strong bg-surface overflow-hidden`. It gets `focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]`, the same focus look as inputs. When invalid it gets `border-danger`, the same as `controlClass(true)`.
- Inside the wrapper:
1. When `mailImages` is set, a toolbar `flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1` (copied from RichEditorField). It holds one text button with `ImagePlus` 16px and `mail.picker.insert_button`, using the richeditor "source" button classes `inline-flex h-8 items-center gap-1.5 rounded-pager px-2 text-[13px] text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text`. With no toolbar buttons, the toolbar is not rendered.
2. The CodeMirror `EditorView` at the size's height (scrolling inside):
- `font-mono` 13px with 1.5 line height and `px-3.5 py-2.5` content padding (copied from inputs);
- line-number gutter;
- bracket matching;
- the `language` grammar (`html` → `@codemirror/lang-html`, `css` → `@codemirror/lang-css`, `text` → no grammar);
- the Go-template action decorator;
- history (undo/redo);
- the theme from the Color section.
Line wrapping is off for `html`/`css` and on for `text`.
**Accessibility and keys.**
- The content element gets `id = controlId`, `aria-labelledby` set to the field label's id, `aria-describedby` set to describedBy, `aria-invalid` and `aria-multiline="true"`.
- **Tab is not bound to indent.** Tab and Shift+Tab move focus out of the editor (WCAG 2.1.2). Indent and outdent stay on the CodeMirror defaults Mod+] and Mod+[.
- The form hotkeys keep working inside the editor. The form's hotkey filter already ignores plain keys (Esc, /, ?) in `[contenteditable]`, so Esc does not trigger Cancel there. Mod+S and Mod+Enter still save, so CodeMirror must not bind them.
**Read-only rendering** (PreviewField / `readOnly`): `<pre class="max-h-[480px] overflow-auto rounded-control border border-border bg-subtle px-3.5 py-2.5 font-mono text-[13px] whitespace-pre-wrap">` with the text, or `list.empty_value` when empty. It loads no CodeMirror.
**Dirty state:** each document change emits `update:modelValue` with the full string. The existing snapshot comparison marks the form dirty; there is no extra rule.
### C2. `colorpicker` field — `ColorPickerField.vue`
**YAML:** `type: colorpicker`. Winter's `availableColors` is not supported; as an unknown key it is a boot error. The value is a `#rrggbb` string (lower-case on save) or empty. The server validates with `colorpicker.invalid`; the client does no validation beyond the native control.
**Markup:** `flex items-center gap-2`.
1. A native `<input type="color">` swatch:
- `size-input shrink-0 cursor-pointer rounded-control border border-border-strong bg-surface p-1`;
- `aria-label` = `colorpicker.pick` with `:field` = the label;
- value = the field value, or `#ffffff` when empty.
Changing it sets the text value.
2. A text input:
- `controlClass(invalid)` plus `font-mono text-[13px]`, `maxlength="7"`, `spellcheck="false"`, `placeholder` = `colorpicker.not_set`;
- `id = controlId` (the label's `for`), `aria-describedby`, `aria-invalid`.
Typing a valid `#rrggbb` updates the swatch. An invalid or partial value leaves the swatch at its last valid value.
Works on settings forms (it must not be in the settings refusal list in `modules/cabana/settings.go`).
### C3. Template body: MarkdownField with the image button
- `mailImages: true` on a `markdown` field adds, above the textarea, the same text button as C1 (`ImagePlus` 16px, `mail.picker.insert_button`). It sits in a `flex items-center gap-2` row next to where the existing "Preview" toggle sits.
- **With `mailImages` set, MarkdownField hides its own "Preview" toggle.** That toggle renders goldmark without the layout or variables, and on a mail body it would mislead. The mail body is previewed by `mailpreview` (C10).
- Without `mailImages` the field is unchanged.
### C4. Image insertion (D-19, D-20) — `MailImagePicker.vue` + `insertImage.ts`
**Trigger:** the "Insert image" button of C1/C3 opens the picker in **insert mode**. The `mailimage` field (C11) opens it in **choose mode**.
**Dialog shell** (copied from RelationPickerModal):
- Reka `DialogRoot`/`DialogPortal`;
- overlay `fixed inset-0 z-40 bg-overlay`;
- content `fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop`;
- title `text-[20px] font-bold tracking-[-0.01em]`;
- close icon button `flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text` with `X` 16px.
**Body, top to bottom:**
1. **Search:** the RelationPickerModal search input (`h-input w-full rounded-control border border-border-strong bg-surface pr-3.5 pl-10`, `Search` icon). It filters by filename and caption title on the server, with a 300ms debounce.
2. **Upload row** (only when the library answers `canUpload: true`): `Button variant="outline" size="sm"` with `Upload` 16px and `mail.picker.upload`, backed by a hidden `<input type="file" accept="image/jpeg,image/png,image/gif,image/webp">`.
- The upload goes straight into the library; no form Save is needed.
- While it runs, the button shows `mail.picker.uploading` and is disabled.
- On success the new image is first in the grid and selected.
- A 422 shows the server message as a 13px danger line under the row (`role="alert"`).
3. **Grid:** `grid grid-cols-4 gap-2 overflow-y-auto min-h-0`, `role="radiogroup"` labelled by the dialog title.
- Each tile is a `role="radio"` button: `flex flex-col gap-1 rounded-inner border p-1 text-left transition-colors duration-150 ease-out`. Not chosen: `border-border hover:bg-hover`. Chosen: `border-[#e0b020] bg-sel` (copied).
- Inside: an `aspect-square w-full rounded-pager bg-subtle object-cover` thumbnail (`alt` = caption title or filename), then the filename `truncate text-[13px] text-muted`. A chosen tile also shows a `Check` 14px in a `size-5 rounded-full bg-primary text-on-primary` badge at the thumbnail's top-right.
- Keys: arrow keys move within the group, Space/Enter chooses.
- Paging is "load more on scroll", 40 per page.
4. **Alt text** (insert mode only, shown once an image is chosen): a text input labelled `mail.picker.alt` with comment `mail.picker.alt_comment`, prefilled with the image's caption title.
5. **Footer:** `flex justify-end gap-2.5`, with `Button ghost` `form.cancel` and `Button primary` (`mail.picker.insert` or `mail.picker.use`). The primary is disabled until an image is chosen.
**States:**
- Loading: three rows of `aspect-square rounded-inner bg-skel` tiles.
- Empty library: `mail.picker.empty_title` (14/600), then `mail.picker.empty_upload` or `mail.picker.empty_pick_only` (13px muted), centred in the grid area.
- No search results: `mail.picker.no_results`.
- Load failed: `mail.picker.load_failed` (13px danger, `role="alert"`).
**Insertion** (`insertImage.ts`, pure function `(language, url, alt) → string`). `url` is the attachment's **absolute public URL** from the server (D-19); the SPA never builds it.
| Target | Inserted text |
|--------|---------------|
| markdown | `![alt](url)`, with `\`, `[` and `]` in alt escaped by a backslash |
| codeeditor `html` | `<img src="url" alt="alt">`, with `&`, `"`, `<` and `>` in alt and url entity-escaped |
| codeeditor `css` | `url("url")` |
| codeeditor `text` | `url` |
The text replaces the current selection (the textarea `selectionStart/End`, or the CodeMirror main selection). If the editor never had focus, it goes at the end. Focus then returns to the editor with the caret after the inserted text. The form becomes dirty through the normal `update:modelValue`.
**Permission (D-03, planner's call):**
- The library's list endpoint answers holders of either mail permission.
- `canUpload` is true only for holders of `system.manage_mail_layouts` (recommended).
- The UI renders both states from the flag and never infers it.
### C5. Layout form (D-08, D-09)
`config_form.yaml`:
- `name: mail.layout.name`;
- `recordActions: [reset]` (C8);
- create redirects to the new layout's update form; Save and close returns to the list;
- messages `create: mail.layout.new`, `update: mail.layout.name`, `saved: messages.form.saved`, `deleteConfirm: mail.layout.delete_confirm`, `deleted: messages.form.deleted`.
Record title: the layout name. Header subtitle: `mail.layout.name`.
| Field | Type | Span | Tab | Context | Notes |
|-------|------|------|-----|---------|-------|
| `_info` | partial `_info.htm` | full | `mail.layout.tab_html` | update | C7 |
| `name` | text | left | `mail.layout.tab_html` | all | Required. On a file layout it starts from the file header's `name` |
| `code` | text, `preset: { type: slug, field: name }` | right | `mail.layout.tab_html` | create | Required. Comment `mail.layout.code_comment`. Shown read-only in `_info` on update |
| `content_html` | codeeditor `language: html`, `size: giant`, `mailImages: true` | full | `mail.layout.tab_html` | all | Label `mail.layout.html` + comment |
| `_preview_html` | mailpreview `mode: layout` | full | `mail.layout.tab_html` | all | Starts on the HTML view |
| `content_text` | codeeditor `language: text`, `size: huge` | full | `mail.layout.tab_text` | all | Label `mail.layout.text` + comment |
| `_preview_text` | mailpreview `mode: layout` | full | `mail.layout.tab_text` | all | Starts on the Plain text view |
| `content_css` | codeeditor `language: css`, `size: huge`, `mailImages: true` | full | `mail.layout.tab_css` | all | Label `mail.layout.css` + comment |
| `_preview_css` | mailpreview `mode: layout` | full | `mail.layout.tab_css` | all | Starts on the HTML view |
| `_file_version` | partial `_file_version.htm` | full | `mail.tab_file_version` | update | C7. Its tab therefore does not exist on create |
- Tab order is fixed: HTML, Plain text, CSS, File version.
- Each tab ends with its own preview, so the preview is always under the editor being typed in. The three `mailpreview` fields are valueless and render the same draft.
- **Create** (custom layout): the HTML, text and CSS fields are prefilled with postcard's built-in default layout content (server `default` values), so a new layout starts working.
- **Footer:**
- Delete (danger outline) only on custom layouts (`meta.deletable: true`).
- "Reset to file version" (outline, a record action) only on a file layout that has an override.
- A custom layout never offers Reset; a file layout never offers Delete.
Layouts list (`config_list.yaml`):
- `title: mail.layout.list_title`, `recordUrl` → update form, `toolbar.buttons: [create]` (label `mail.layout.new`), no checkboxes, no bulk actions;
- `noRecordsMessage: mail.layout.list_empty`, search prompt existing;
- row states `customized`, `outdated` (C9), with messages `rowStateCustomized: mail.state_customized` and `rowStateOutdated: mail.state_outdated`.
| Column | Type | Visible | Searchable / sortable | Notes |
|--------|------|---------|----------------------|-------|
| `name` | text | yes | searchable, sortable | First column; carries the row-state badges |
| `code` | text | yes | searchable, sortable | Layout alias, for example `golem15.user::mail.layouts.user`, or the custom code |
| `source` | text | yes | sortable | The plugin ID, or `mail.source_custom` |
| `customized_at` | datetime | invisible | sortable | Override `updated_at`; dash when not customized |
Default sort: postcard's default layout first, then `name` ascending.
### C6. Partial form (D-12)
`config_form.yaml`: `name: mail.partial.name`, `recordActions: [reset]`, no create, messages `update: mail.partial.name`, `saved: messages.form.saved`. Record title: the partial code (`<plugin>::mail.partials.<name>`).
| Field | Type | Span | Tab | Notes |
|-------|------|------|-----|-------|
| `_info` | partial `_info.htm` | full | `mail.partial.tab_html` | C7, including the usage snippet |
| `content_html` | codeeditor `language: html`, `size: huge`, `mailImages: true` | full | `mail.partial.tab_html` | Label `mail.partial.html` + comment |
| `_preview_html` | mailpreview `mode: partial` | full | `mail.partial.tab_html` | HTML view |
| `content_text` | codeeditor `language: text`, `size: large` | full | `mail.partial.tab_text` | Label `mail.partial.text` + comment |
| `_preview_text` | mailpreview `mode: partial` | full | `mail.partial.tab_text` | Plain text view |
| `_file_version` | partial `_file_version.htm` | full | `mail.tab_file_version` | C7 |
- Footer: no Delete (`meta.deletable: false`). "Reset to file version" appears when an override exists.
Partials list: `title: mail.partial.list_title`, `toolbar.buttons: []`, no checkboxes, `noRecordsMessage: mail.partial.list_empty`, row states as C5.
| Column | Type | Visible | Searchable / sortable |
|--------|------|---------|----------------------|
| `code` | text | yes | searchable, sortable |
| `source` | text | yes | sortable |
| `customized_at` | datetime | invisible | sortable |
### C3b. Template form (D-04, D-05, D-07)
`config_form.yaml`:
- `name: mail.template.name`;
- `recordActions: [reset]`;
- no create;
- messages `update: mail.template.name`, `saved: messages.form.saved`;
- Save and close returns to the list.
Record title (h1, breadcrumb, `:name`): the template code, for example `golem15.user::mail.activate-en` (truncated). Subtitle: `mail.template.name`.
| Field | Type | Span | Tab | Notes |
|-------|------|------|-----|-------|
| `_info` | partial `_info.htm` | full | `mail.template.tab_template` | C7: description, file, locale, status, variables, drift callout |
| `subject` | text | left | `mail.template.tab_template` | Required. Comment `mail.template.subject_comment` |
| `layout` | dropdown over every layout (file and custom, by name), no empty option | right | `mail.template.tab_template` | Required. Comment `mail.template.layout_comment` |
| `body` | markdown, `size: giant`, `mailImages: true` | full | `mail.template.tab_template` | Label `mail.template.body` + comment. C3 |
| `_preview` | mailpreview `mode: template` | full | `mail.template.tab_template` | Label `mail.preview.label`. C10 |
| `_file_version` | partial `_file_version.htm` | full | `mail.tab_file_version` | C7 |
- **Description is file-owned and never editable.** It shows only as the `_info` line, never as an input (D-04). Cabana has no `readOnly` key, and adding one is out of scope.
- A template without an override opens the same form filled from the file. The first Save creates the override, and the record then carries the `customized` state.
- Footer: Cancel, Save and close, Save (existing), and "Reset to file version" when customized. No Delete (`meta.deletable: false`).
Templates list (`config_list.yaml`):
- `title: mail.template.list_title`, `recordUrl` → update form, `toolbar.buttons: []`, no checkboxes, no bulk actions, `recordsPerPage: 20`;
- `noRecordsMessage: mail.template.list_empty`;
- row states and messages as C5.
| Column | Type | Visible | Searchable / sortable | Notes |
|--------|------|---------|----------------------|-------|
| `code` | text | yes | searchable, sortable | First column; carries the badges. The default sort is `code` ascending, so a base template and its locale variants sit together (D-05: separate rows, no grouping) |
| `subject` | text | yes | searchable | The effective subject (the override's, if any) |
| `description` | text | yes | searchable | From the file |
| `locale` | text | yes | sortable | The suffix (`en`), or `mail.template.locale_default` for a file without a suffix |
| `layout` | text | yes | — | The effective layout's name |
| `customized_at` | datetime | invisible | sortable | |
Filters (existing `scope` type with options, like the 19.6.1 `trashed` filter):
| Scope | Label | Options |
|-------|-------|---------|
| `locale` | `mail.template.locale` | `default` (`mail.template.locale_default`) plus one option per suffix found in the catalog, labelled with the suffix |
| `status` | `mail.filter_status` | `customized`, `not_customized`, `outdated` (labels `mail.filter_customized`, `mail.filter_not_customized`, `mail.filter_outdated`) |
### C7. Info and File version partials (server-rendered, `.summer-partial`)
Partials are html/template files in the cabana pseudo-plugin. They are rendered through the existing allowlist, so they contain no buttons and no forms. Markup uses only allowlisted tags and existing classes.
**`_info.htm`**, top of the first tab. In order:
1. Templates: `<p>` with `mail.info_description` (`:text` = the file description).
2. `<p class="text-muted">` with `mail.info_file`, where `:path` is in `<code class="font-mono">` (for example `views/mail/activate-en.htm`) and `:plugin` is the plugin ID. A custom layout shows `mail.info_custom_layout` instead, with its code in `<code>`.
3. `<p><strong>` with the status line: `mail.info_customized` or `mail.info_not_customized`.
4. Templates: `mail.info_variables` followed by the sample variable names, each `<code class="font-mono">`, separated by ", ". When no samples are declared, `mail.info_no_samples`.
5. Partials: `mail.info_partial_usage` followed by `<code class="font-mono">` with the exact call for this partial, in the syntax the planner picks (D-12).
6. When the record is `outdated` (D-07): `<div class="summer-callout summer-callout--warning" role="status">` with `<p class="summer-callout__title">mail.drift_title</p><p class="summer-callout__text">mail.drift_text</p>`.
Paragraphs are spaced by the existing `.summer-partial` rules.
**`_file_version.htm`** (update only):
- **Customized and different from the file:**
1. `<p><strong>mail.file_changes</strong></p>` and `<p class="text-muted">mail.file_legend</p>`.
2. A unified line diff (3 lines of context), override vs current file, of the serialized format (INI header + sections, the same format the DB stores): `<pre class="summer-diff">`, one `<span>` per line:
- `summer-diff__add` with the text prefixed `+ `;
- `summer-diff__del` prefixed `− `;
- `summer-diff__ctx` prefixed two spaces.
The prefixes are text, so the meaning never depends on colour (WCAG 1.4.1).
3. `<p><strong>mail.file_content</strong></p>` and `<pre class="summer-diff">` with the whole file, every line `summer-diff__ctx`.
- **Customized and identical:** `<p>mail.file_identical</p>`, then the file content block.
- **Not customized:** `<p class="text-muted">mail.file_not_customized</p>`, then the file content block.
- **Fallback:** if the planner ships no diff in this phase, the "Changes" block is omitted and the file content block stays. The rest of this contract is unchanged.
**New CSS kit in `main.css`** (`@layer components`, existing variables only):
```css
.summer-diff { margin: 0; max-height: 480px; overflow: auto; padding: 12px 16px; border: 1px solid var(--c-border); border-radius: 12px; background: var(--c-subtle); font-family: var(--font-mono); font-size: 13px; line-height: 1.5; white-space: pre; color: var(--c-text); }
.summer-diff__add, .summer-diff__del, .summer-diff__ctx { display: block; }
.summer-diff__add { background: var(--c-ok-bg); color: var(--c-ok-text); }
.summer-diff__del { background: var(--c-danger-soft); color: var(--c-danger); }
.summer-diff__ctx { color: var(--c-text); }
```
The partial must reload after a save and after a record action (C8), so the status, drift and diff are never stale.
### C8. Additions to FormView and SettingsFormView
1. **Footer record actions on the update form.** When the record response carries `meta.actions` (the same data PreviewView reads), FormView renders the existing `RecordActions` component in the footer's left group, after Delete (`flex items-center gap-2.5`).
- Buttons are outline. The confirm uses the action's `danger` flag: `reset` is `Danger: true`, so its confirm button is the danger variant with the action label "Reset to file version".
- While an action runs, every footer button is disabled (the existing `busy` emit).
- On success: the `mail.reset_done` toast, then the record reloads in place. Values are replaced by the file version, the form is clean (snapshot reset), and partial fields reload.
- 409: the existing stale toast and a reload. 404: the existing load-failed state. 403: a danger toast with the server message.
- On create there are no actions.
- Generic rule: other controllers get the same footer only if they declare `recordActions` and have no preview screen. Controllers with a preview keep their actions on the preview, unchanged.
2. **`meta.deletable`.** FormView hides Delete when the record response has `meta.deletable === false`. When the key is absent, behaviour is unchanged (`true`), so existing controllers see no difference. Templates, partials and file layouts send `false`; custom layouts send `true`.
3. **Partial fields reload** after every successful save and record action. The partial field's `PartialHost` gets a `reloadKey` bumped by FormView, the same mechanism as PreviewView's `hintKey`.
4. **SettingsFormView** adds `provide(FORM_VALUES, computed(() => values.value))` and `provide(FORM_LOCALE, …)`, so `mailpreview` on the Branding page reads the draft. It provides no `FORM_PATCH` or session. The cabana settings refusal list (`settings.go`) must allow `colorpicker`, `mailimage` and `mailpreview`.
### C9. Row states `customized` and `outdated`
- `pact` gains two **additive** constants: `RowStateCustomized RowState = "customized"` and `RowStateOutdated RowState = "outdated"`. The existing three are unchanged and plugins are not affected. Names are a recommendation; the planner may rename, but the rendering is fixed.
- `RowStateBadges` order becomes `deleted, negative, disabled, customized, outdated`. New styles:
- `customized`: `border border-border-strong text-text`;
- `outdated`: `bg-sel text-text`.
Same pill markup (`h-6 rounded-pill px-2.5 text-[12px] font-semibold`).
- `DataTable`'s `stateTextClass` is unchanged for the new states: the row title stays `text-text` and is not struck through.
- `ListView` reads two more labels: `messages.rowStateCustomized` and `messages.rowStateOutdated`.
- A drifted override carries both badges ("Customized", then "File changed"). A non-customized row carries none.
### C10. `mailpreview` field (D-15, D-16, D-17) — `MailPreviewField.vue`
**YAML:** `type: mailpreview`, `mode: template | layout | partial | branding`. It is valueless (never in the save body) and group-labelled. It renders on create and update.
**Data flow.**
- It reads the whole draft from `FORM_VALUES` and POSTs it with the mode and the record id (0 on create) to one cabana preview route.
- The server renders through postcard with sample vars (D-15). Layouts and partials are rendered around postcard's sample content; Branding uses a built-in sample message with the draft colours and logo.
- The answer is `{ subject, html, text }` or a 422 with `:message`.
- Requests re-run **500ms after the last draft change** and on mount, with a sequence guard so only the latest answer is applied (the MarkdownField pattern). Hidden previews (on an inactive tab) do not request; they render when their tab becomes active.
**Markup.**
- Outer: `flex flex-col gap-2`.
- **Frame:** `rounded-inner border border-border overflow-hidden`.
1. **Toolbar:** `flex flex-wrap items-center gap-2 border-b border-border bg-subtle p-1`.
- The format switch `role="tablist"` uses FormTabs' class strings at the same 34px height: track `inline-flex gap-1 rounded-inner border border-border bg-subtle p-1`, active pill `bg-surface text-text shadow-tab`, inactive `text-muted hover:text-text`. Tabs: `mail.preview.html`, `mail.preview.text`.
- The status `ml-auto px-2 text-[13px] text-muted`, `role="status"`: `mail.preview.rendering` while a request runs, otherwise empty.
2. **Subject row:** `flex gap-2 border-b border-border px-4 py-3` with `<span class="font-semibold">mail.preview.subject</span>` and the rendered subject as text (`min-w-0 break-words`). It is hidden in `branding` mode.
3. **HTML view:** `<iframe sandbox="" referrerpolicy="no-referrer" :srcdoc="html" :title="t('mail.preview.frame_title')" class="block h-[640px] w-full border-0 bg-white">`.
- `sandbox` is empty: no scripts, no same-origin, no forms, no popups, no top navigation.
- `srcdoc` is the server's rendered HTML, already passed through postcard's safety checks. The SPA never builds or modifies it. Admin CSP `frame-src 'none'` does not block a srcdoc frame (see the `boardwalk.go` comment), and no CSP change is needed.
- Images load from their absolute public URLs.
4. **Plain-text view:** `<pre class="m-0 max-h-[640px] overflow-auto bg-surface px-4 py-3 font-mono text-[13px] whitespace-pre-wrap">` with the text, as text.
- **Notes under the frame** (`text-[13px] text-muted`):
- `mail.preview.sample_note` (template mode), or `mail.preview.sample_content_note` (other modes);
- the field `comment` if declared (Branding).
- **Error line** (`text-[13px] text-danger`, `role="alert"`), between the toolbar and the frame content:
- `mail.preview.failed` with `:message`, when a previous render exists (that render stays visible);
- `mail.preview.failed_first` when none exists (the frame shows the empty state);
- `mail.preview.unavailable` on a network error or 5xx.
- **Empty draft:** when the content field is blank, the frame area shows `mail.preview.empty` centred (`px-4 py-3 text-[13px] text-muted`) instead of the iframe, and no request is sent.
- **Test send** (template, layout and partial modes; not branding): a row `flex flex-wrap items-center gap-2`.
- `Button variant="outline" size="sm"` with `Send` 16px and `mail.preview.test_send`; busy label `mail.preview.test_sending`.
- Then the hint `mail.preview.test_hint` (13px muted), with `:email` = the signed-in admin's email from the session.
- It POSTs the same draft payload to the test-send route. The server sends only to the signed-in admin's address (D-17), with no recipient in the request.
- Results:
- 2xx: success toast `mail.preview.test_sent`.
- 429: inline 13px danger line `mail.preview.test_limited`, with `:seconds` from `Retry-After`. The button is disabled until that time passes.
- 422: inline danger line `mail.preview.test_invalid` with `:message`.
- 5xx or driver error: danger toast `mail.preview.test_failed`.
- 403: danger toast with the server message.
- No confirm dialog: the recipient is always yourself, and the hint names the address.
### C11. `mailimage` field (Branding logo) — `MailImageField.vue`
**YAML:** `type: mailimage`. The value is the library image's id (number) or `null`; it is in the save body. It is allowed on settings forms. The server resolves it to `{{ .logoUrl }}` (the absolute public URL) at render time.
**Markup:** `flex items-center gap-4`.
1. A thumbnail box `size-[96px] shrink-0 overflow-hidden rounded-inner border border-border bg-subtle` showing the image (`object-contain`, alt = caption title). When empty it shows the `Image` icon (24px muted, centred).
2. A `flex flex-col gap-2` column:
- the filename (14px), or `mailimage.empty` (13px muted);
- a row `flex gap-2` with:
- `Button outline sm` `mailimage.choose` / `mailimage.change`, which opens C4 in choose mode;
- when set, `Button ghost sm` `mailimage.remove`, which sets `null`. There is no confirm; Save confirms.
3. If the stored id no longer resolves (the image was removed from the library), a `text-[13px] text-danger` line shows `mailimage.missing`, and the thumbnail box shows the empty icon.
The group is labelled by the field label (`groupLabelledTypes`).
### C12. Branding page (D-10, D-11) — settings model
- A cabana settings entry `mail_brand`: category `settings.category_mail`, label `mail.brand.menu_label`, icon `palette`, permission `system.manage_mail_layouts`, order 230.
- Rendered by `SettingsFormView`, with no tabs and one card.
- The first open is prefilled with the Winter defaults from the copy table. Saving stores the record; from then on its compiled CSS replaces `mail.brandCss` (D-11), as the preview comment says.
| Row | Left | Right |
|-----|------|-------|
| 1 | `logo` (mailimage, span full) | — |
| 2 | `body_bg` | `content_bg` |
| 3 | `header_color` | `footer_color` |
| 4 | `heading_color` | `text_color` |
| 5 | `link_color` | `button_text_color` |
| 6 | `button_primary_bg` | `button_positive_bg` |
| 7 | `button_negative_bg` | `panel_bg` |
| 8 | `table_border_color` | — |
| 9 | `_preview` (mailpreview `mode: branding`, span full, label `mail.preview.label`, comment `mail.brand.preview_comment`) | — |
- The colours are `colorpicker` (C2), with no comments; their labels are self-describing.
- The footer is unchanged: Cancel, Save and close, Save.
- The logo picker's upload follows C4 `canUpload`. Every admin who can open this page holds `system.manage_mail_layouts`, so they can upload a logo from here.
### C13. Mail images library (D-18) — singleton form
- A cabana `pact.AdminSingleton` controller, `name: mail.images.menu_label`. It is linked from Settings (category `settings.category_mail`, icon `image`, permission `system.manage_mail_layouts`, order 240) and rendered by `FormView` in singleton mode. There is no list and no Delete.
- One field, `images`: `fileupload`, `mode: image`, attachMany, public disk, `useCaption: true`, `fileTypes: jpg,jpeg,png,gif,webp`, `maxFilesize` set by the planner. Label `mail.images.field`, comment `mail.images.field_comment`.
- It renders the existing tile grid. The caption title is the alt text the picker pre-fills.
- Uploads and removals follow the existing deferred binding: they take effect on Save. The picker's own upload (C4) is immediate and shows up here on the next load.
- Removing an image follows the existing fileupload removal UI. The field comment carries the warning; there is no extra dialog.
### C14. Settings placement (D-02)
Five `pact.SettingsItem` entries in category `backend::lang.settings.category_mail`, rendered by the existing `SettingsIndexView` tiles with no SPA change:
| Code | Label / description | Icon | Controller or settings | Permission | Order |
|------|---------------------|------|------------------------|------------|-------|
| `mail_templates` | `mail.template.menu_label` / `_description` | `mail` | templates controller | `system.manage_mail_templates` | 200 |
| `mail_layouts` | `mail.layout.menu_label` / `_description` | `layout-grid` | layouts controller | `system.manage_mail_layouts` | 210 |
| `mail_partials` | `mail.partial.menu_label` / `_description` | `puzzle` | partials controller | `system.manage_mail_layouts` | 220 |
| `mail_brand` | `mail.brand.menu_label` / `_description` | `palette` | settings model | `system.manage_mail_layouts` | 230 |
| `mail_images` | `mail.images.menu_label` / `_description` | `image` | singleton controller | `system.manage_mail_layouts` | 240 |
- The order within the group is fixed. A tile appears only when the principal may open it (the existing filter), so a holder of only `manage_mail_templates` sees one tile.
- There is no top-level nav item and no flyout entry.
### C15. Where each refusal or failure surfaces
| Rule | Path | Status / error | Renders as | Copy |
|------|------|----------------|-----------|------|
| D-13 parse / render / unsafe / subject line / layout / partial / {{ .Content }} | save (template, layout, partial) | 422, `Details.<field>` | Field line under the named field, count banner, the first invalid field's tab activated with its count badge (existing) | `mail.invalid_*` |
| Custom layout code taken / format | create | 422, `Details.code` | Field line | `mail.layout.code_taken`, `mail.layout.code_format` |
| Invalid colour | Branding save | 422, `Details.<colour>` | Field line | `colorpicker.invalid` |
| D-08 delete a layout still assigned | form delete | 403, `Message` | Danger toast; the record stays | `mail.layout.delete_in_use` with `:name`, `:count` |
| Delete or reset where not offered (stale tab) | delete / record action | 409 | Existing stale toast + reload | existing |
| D-03 missing permission | any route | 403 | Existing forbidden banner (save) or danger toast (actions); tiles hidden beforehand | existing `form.forbidden` |
| Preview render failure | preview route | 422, `Details.message` (or `Message`) | Inline danger line in the preview (C10) | `mail.preview.failed[_first]` |
| Preview unavailable | preview route | network / 5xx | Inline danger line | `mail.preview.unavailable` |
| D-17 rate limit | test send | 429 + `Retry-After` | Inline danger line, button disabled until the time passes | `mail.preview.test_limited` |
| D-17 draft invalid | test send | 422 | Inline danger line | `mail.preview.test_invalid` |
| D-17 driver failure | test send | 5xx | Danger toast | `mail.preview.test_failed` |
| Picker upload refused (type, size) | picker upload | 422 | Danger line under the upload row | server message as is |
| Library load failed | picker list | network / 5xx | Danger line in the dialog | `mail.picker.load_failed` |
Note: the delete refusal must be a `ForbiddenError` with `Message` set. The SPA delete toast reads `error.message`, and a `ValidationError` would show "Validation failed" (the same finding as 19.6.1 C8).
---
## UI Considerations
Applicable state considerations resolved: 48 rows (42 covered, 6 backstop, 0 unresolved; probe-checked 2026-11-06 against 80 applicable element×category pairs, the remaining pairs covered by the shared "All lists, forms" rows). The elements are the three lists, the three forms, the Branding page, the Mail images page, the settings tiles, `codeeditor`, `colorpicker`, `mailimage`, `mailpreview`, the picker, the info/file-version partials, the footer record actions and the refusal surfaces. Copy for empty and error rows lives in the Copywriting Contract.
| Category | Element(s) | Status | Resolution / Reason |
|----------|------------|--------|---------------------|
| empty | Templates list | ✅ covered | A filtered empty view shows `mail.template.list_empty`; an empty search shows the existing `messages.list.empty_search`. Unfiltered it is never empty, because postcard registers built-in mail |
| empty | Layouts list | ✅ covered | `mail.layout.list_empty`; postcard's default layout means the unfiltered list is never empty |
| empty | Partials list | ✅ covered | `mail.partial.list_empty`; the built-in button/panel/table partials mean the unfiltered list is never empty |
| loading | All lists, forms, Branding, Mail images | ✅ covered | Existing skeletons and disabled footers in ListView/FormView/SettingsFormView; no new loading UI |
| error | All lists, forms, Branding, Mail images | ✅ covered | Existing `list.load_failed` / `form.load_failed` alerts |
| populated | Templates list | ✅ covered | Code, subject, description, locale, layout; base and locale variants adjacent by code sort; badges after the code |
| partial | Templates list (mixed states) | ✅ covered | Not-customized rows have no badge; customized rows show "Customized"; drifted rows show "Customized" + "File changed", in fixed order, as text (WCAG 1.4.1) |
| zero-one-many | Locale filter | ✅ covered | The options are `Default` plus one per suffix in the catalog. An app with no locale variants shows only `Default` |
| overflow | All lists | ✅ covered | Existing pagination (20 per page) and horizontal table scroll; long codes truncate in the first column, with the badges kept (`shrink-0`) |
| long-text | Template code in the form header and breadcrumb | ✅ covered | The h1 truncates (existing `truncate`); the full code also appears in `_info` as `code`, which wraps (`overflow-wrap:anywhere` inherited from `.summer-partial`) |
| populated | Template form, not customized | ✅ covered | Filled from the file; `_info` says "Not customized…"; no Reset in the footer; Save creates the override |
| populated | Template form, customized | ✅ covered | `_info` says "Customized…"; footer shows "Reset to file version"; File version tab shows the diff |
| partial | Template form, drifted (D-07) | ✅ covered | The warning callout at the top of the first tab names both ways out (save again or reset). File version tab shows the diff against the new file; saving again clears the badge after the partial reloads |
| empty | Template body blank | ✅ covered | Preview shows `mail.preview.empty` and sends no request; Save returns the `required` / `mail.invalid_render` field line |
| error | Save invalid (D-13) | ✅ covered | 422 field lines under subject/body/editor fields; the count banner; the first invalid field's tab activated (existing). The draft is kept |
| error | Reset refused or stale | ✅ covered | 409 existing stale toast + reload; 403 danger toast |
| populated | Reset success | 🧪 backstop | SPA unit test: FormView with `meta.actions: [reset]` renders the outline button in the footer; confirm is the danger variant; after a 2xx the values are replaced, the form is clean and the partial field's `reloadKey` increments |
| partial | FormView without `meta.deletable` | 🧪 backstop | SPA unit test: absent key → Delete shown (unchanged behaviour); `false` → hidden; `true` → shown |
| populated | Layout form, file layout | ✅ covered | HTML / Plain text / CSS / File version tabs; Reset when customized; no Delete |
| populated | Layout form, custom layout create | ✅ covered | Name + code (slug preset); editors prefilled with the default layout; no File version tab; Save redirects to update |
| error | Delete custom layout in use (D-08) | ✅ covered | 403 danger toast `mail.layout.delete_in_use` with name and count (CLDR plural); the record stays |
| populated | Partial form | ✅ covered | HTML / Plain text / File version; `_info` shows the usage snippet; Reset when customized |
| loading | `codeeditor` lazy load | ✅ covered | A `bg-skel` box at the size's height, `aria-busy="true"` |
| error | `codeeditor` load failure | ✅ covered | `codeeditor.load_failed` note plus a monospace textarea; the value still saves |
| populated | `codeeditor` keyboard | 🧪 backstop | Browser test (`test:browser`): Tab from inside the editor moves focus to the next control (no trap); Mod+S inside the editor triggers the form save; Esc does not trigger Cancel |
| long-text | `codeeditor` long lines / long files | ✅ covered | HTML/CSS scroll horizontally inside the editor (no wrap); text wraps; the editor height is fixed by `size` and scrolls vertically |
| populated | Image insertion | 🧪 backstop | SPA unit test of `insertImage.ts`: markdown alt escaping of `]`; html attribute escaping of `"` and `<`; css `url("…")`; text bare URL; insertion replaces the selection and the caret lands after it |
| empty | Picker, empty library | ✅ covered | `mail.picker.empty_title` + `empty_upload` or `empty_pick_only`, chosen by `canUpload` |
| empty | Picker, no search result | ✅ covered | `mail.picker.no_results` with the query |
| error | Picker load / upload failure | ✅ covered | `mail.picker.load_failed`; the 422 upload message under the upload row |
| zero-one-many | Picker grid | ✅ covered | 1..n tiles in a 4-column grid; more than 40 loads on scroll; exactly one choosable (radiogroup); primary disabled until a choice |
| long-text | Picker filenames | ✅ covered | `truncate`; the full name is in the thumbnail's `alt` when there is no caption |
| populated | `mailpreview` HTML / Plain text | ✅ covered | Sandboxed srcdoc iframe at 640px on a white canvas; text in a mono `pre`; the switch keeps its choice while the draft changes |
| error | `mailpreview` render failure | 🧪 backstop | SPA unit test: after a good render, a 422 answer shows `mail.preview.failed` and keeps the previous `srcdoc`; a 422 before any good render shows `failed_first`; an out-of-order older answer is ignored (sequence guard) |
| populated | `mailpreview` sandbox | 🧪 backstop | SPA unit test: the iframe has `sandbox=""` (empty), `srcdoc` bound only to `data.html` from a 2xx, never to the draft; no `src` attribute |
| error | Test send failures | ✅ covered | 429 inline with seconds and the button disabled until the time passes; 422 inline; 5xx toast; 403 toast |
| populated | Test send success | ✅ covered | Toast `mail.preview.test_sent` with the admin's email |
| populated | Branding page first open | ✅ covered | Winter defaults prefilled; the preview comment explains the `mail.brandCss` precedence (D-11) |
| error | Branding invalid colour | ✅ covered | `colorpicker.invalid` field line; the swatch keeps its last valid value |
| partial | Branding logo removed from library | ✅ covered | `mailimage.missing` danger line and the empty thumbnail; Save with the missing id is refused by the server, or cleared (planner's choice), with the field line |
| populated | Layouts and partials lists | ✅ covered | Code/name column first with badges after it; file and custom layouts mixed, custom ones without a Customized badge (C9) |
| partial | Layouts and partials lists (mixed states) | ✅ covered | Same badge rules as the templates list: no badge, "Customized", or "Customized" + "File changed" (C9) |
| zero-one-many | Settings Mail section by permission | ✅ covered | Tiles are filtered per permission (C14): `manage_mail_templates` only → one tile; `manage_mail_layouts` only → four tiles; neither → no Mail category; superuser → five |
| loading | `mailpreview` request in flight | ✅ covered | `mail.preview.rendering` in the `role="status"` line while a request runs; the previous render stays visible (C10) |
| loading | Picker list | ✅ covered | Three rows of `aspect-square bg-skel` tiles (C4) |
| empty | Mail images page, no images | ✅ covered | The existing fileupload empty state of the singleton form; the field comment explains what the library is for (C13) |
| overflow | `mailpreview` long message | ✅ covered | The 640px iframe scrolls inside itself; the plain-text `pre` is capped at 480px and scrolls (Spacing Scale) |
| populated | Mail images page | ✅ covered | The existing image tile grid with captions; the comment warns about removing images still in use |
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none (shadcn not used) | not applicable |
| third-party registries | none | not applicable |
| npm (new runtime deps, admin SPA) | `@codemirror/state` 6.7.6 (published 2026-09-22), `@codemirror/view` 6.43.15 (2026-10-19), `@codemirror/language` 6.13.1 (2026-10-07), `@codemirror/commands` 6.11.1 (2026-09-15), `@codemirror/lang-html` 6.4.12 (2026-08-04), `@codemirror/lang-css` 6.3.1 (2024-11-26). All MIT. Versions and dates were resolved on 2026-11-06 with `npm view <pkg> version` / `npm view <pkg> time`. All are past the project's 7-day cooldown. Transitive packages come from `@lezer/*`, `@codemirror/autocomplete` and `@codemirror/lang-javascript` (via lang-html), `style-mod`, `w3c-keyname` and `crelt` | Required before install: a STACK.md "Admin SPA (npm) additions" row and a decision note, exact pins in `admin/package.json`, the committed lockfile, `npm ci`. Do **not** use the `codemirror` meta package (`basicSetup` pulls in the search, lint and autocomplete UI). CodeMirror is lazily imported, so the main bundle is unchanged; the dist rebuild gate (`scripts/check-admin-dist.sh`) applies |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
- [x] Dimension 7 Inventory Provenance: PASS
**Approval:** approved 2026-11-06 (msd-ui-checker; flags on dimensions 1, 2, 4 and 5 applied)