docs(17): UI design contract
This commit is contained in:
815
.planning/phases/17-blog-companions/17-UI-SPEC.md
Normal file
815
.planning/phases/17-blog-companions/17-UI-SPEC.md
Normal file
@@ -0,0 +1,815 @@
|
||||
---
|
||||
phase: "17"
|
||||
slug: "blog-companions"
|
||||
status: approved
|
||||
reviewed_at: "2026-10-11"
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: "2026-10-11"
|
||||
---
|
||||
|
||||
# Phase 17 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
|
||||
|
||||
This phase has three visual surfaces:
|
||||
|
||||
1. **Admin SPA (`admin/`, framework).** Four generic cabana features (D-06 to D-10) and one view variant:
|
||||
- the `richeditor` field (HTML WYSIWYG);
|
||||
- the `repeater` field;
|
||||
- the dependent dropdown (`dependsOn`);
|
||||
- the tree reorder page, reached from a new list heading button;
|
||||
- the singleton form, which opens the one sitemap definition directly (D-11).
|
||||
2. **Plugin admin screens** of `sm-faq-plugin`, `sm-menu-plugin` and `sm-sitemap-plugin`. They are YAML only (`fields.yaml`, `columns.yaml`, settings fields) and have no plugin Vue code. They render through the existing list, form and settings views plus the features above.
|
||||
3. **Two Nuxt layers, `vue-menu-layer` and `vue-faq-layer` (D-18).** They are neutral, token-driven render components in the `vue-journal-layer` style. The site app overrides their tokens or components.
|
||||
|
||||
`sitemap.xml` and `sitemap.xsl` have **no new visual design**. The XSL is embedded byte-identical to the PHP `assets/sitemap.xsl`, so the browser view of the sitemap stays the PHP presentation (D-14, RESEARCH Pattern 4).
|
||||
|
||||
The admin SPA already 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 admin tokens, colours, fonts, radii or shadows.** Every admin value below reuses an existing token or copies a class string from an existing component: `FormView.vue`, `ListView.vue`, `FormField.vue`, `FormGrid.vue`, `DropdownField.vue`, `MarkdownField.vue`, `FileuploadField.vue`, `Button.vue`, `control.ts`, and the 12.2 and 16.3 UI-SPECs.
|
||||
|
||||
Sources:
|
||||
- `17-CONTEXT.md`: D-05, D-07 to D-11, D-13, D-16 to D-18 (locked).
|
||||
- `17-RESEARCH.md`: Patterns 5 to 10, Pitfalls 9, 10 and 15, Assumptions A1, A4 and A6 to A8.
|
||||
- Phase 12.2 and 16.3 UI-SPECs (approved house conventions).
|
||||
- The codebase scan listed above.
|
||||
- The PHP port sources: `faq/models/faq/fields.yaml`; `menu/models/{menu,menuentry}/fields.yaml`, `menuentries/_list_toolbar.php` and `reorder_config.yaml`; `sitemap/classes/definitionitem/fields.yaml`, `models/settings/fields.yaml` and `lang/{en,pl}`; `themes/arsha/partials/g15_menu`, `g15_faq`.
|
||||
- The current site markup the layers replace: `vue-grzybyfunkcjonalne-app/app/components/{SiteHeader,FaqSection}.vue`.
|
||||
|
||||
**Out of this contract:**
|
||||
- Journal's category `content` field stays `type: blogeditor` (the Phase 16.2 Blog Editor). RESEARCH A1 recommends dropping D-10's "switch to richeditor" clause, and this contract specifies no change to it; the user confirms during planning.
|
||||
- The production cutover.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none. There is no shadcn: this is a Vue 3 SPA with its own Direction C system. The shadcn gate does not apply to a Vue codebase with an established system, the same ruling as Phases 10.1, 12.1, 12.2 and 16.3. The Nuxt layers use plain scoped CSS on their own tokens, like `vue-journal-layer`. |
|
||||
| Preset | not applicable |
|
||||
| Component library | Admin: Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/`. Layers: none (Vue SFCs with native `<details>`, `<nav>` and `<button>`). |
|
||||
| Icon library | Admin: `@lucide/vue` 1.17.0, named imports only. Sizes: 16px in buttons and toolbar buttons, 14px in error and note lines, 20px in the empty-state circle. Navigation icons use the registered names in `admin/src/app/icons.ts`. Layers: no icon library; chevrons are CSS borders, as in the current site header. |
|
||||
| Font | Admin: DM Sans 400/600 for new content; 700 only on the inherited page `h1`, copied verbatim from `FormView` (self-hosted). 13px meta text is always `text-muted`, except error lines (`text-danger`). DM Mono 400 (`font-mono`) only in the richeditor HTML source view. `tabular-nums` on row indexes and counters. Layers: `--menu-font` and `--faq-font` tokens with a system-ui stack (no web font, so the layers stay module-free per RESEARCH Pattern 10). |
|
||||
| WYSIWYG engine (admin) | TipTap 3.31.4: `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, pinned exact. These are new admin SPA dependencies, named by RESEARCH (Standard Stack) and behind a `checkpoint:human-verify` before `npm install`. `@tiptap/vue-3` is not added. |
|
||||
|
||||
---
|
||||
|
||||
## 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-11.
|
||||
Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 53 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 3156baa) — 2026-10-11.
|
||||
|
||||
**Primitive presence.** Verified on 2026-10-11 with `node --input-type=module -e "import * as r from 'reka-ui'; ..."` in `admin/`: `ToolbarRoot`, `ToolbarButton`, `ToolbarSeparator`, `PopoverRoot`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`, `CollapsibleRoot`, `CollapsibleTrigger`, `CollapsibleContent` and the `Dialog*` parts are all exported.
|
||||
|
||||
**Icon presence.** Verified the same day with `node --input-type=module -e "import * as l from '@lucide/vue'; ..."` against @lucide/vue@1.17.0. These are all exported: `Bold`, `Italic`, `Underline`, `Heading3`, `List`, `ListOrdered`, `Quote`, `Link`, `Unlink`, `Undo2`, `Redo2`, `Code`, `GripVertical`, `ChevronUp`, `ChevronDown`, `IndentIncrease`, `IndentDecrease`, `ArrowUpDown`, `Plus`, `Trash2`, `CircleAlert`, `Info`, `LoaderCircle`, `RotateCcw`, `ExternalLink`, `X`.
|
||||
|
||||
**Navigation icon names.** These are already registered in `icons.ts`: `list`, `list-ordered`, `circle-question-mark` (also reachable as `icon-question-circle`), `network` (as `icon-sitemap`) and `settings` (as `icon-cog`).
|
||||
|
||||
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 |
|
||||
|-----------|-------------|-------|
|
||||
| ToolbarRoot, ToolbarButton, ToolbarSeparator | `reka-ui` | The richeditor formatting toolbar: roving tabindex and `aria-orientation`. Verified exported. |
|
||||
| PopoverRoot, PopoverTrigger, PopoverContent | `reka-ui` | The richeditor link popover. Verified exported. |
|
||||
| CollapsibleRoot, CollapsibleTrigger, CollapsibleContent | `reka-ui` | Expanding and collapsing a repeater row body. Verified exported. |
|
||||
| AlertDialog* via ConfirmDialog | `admin/src/components/ui/ConfirmDialog.vue` + `useConfirm` | Confirms removing a non-empty repeater row. FormView already mounts one ConfirmDialog; reuse that instance. Do not build a second confirm. |
|
||||
| Button | `admin/src/components/ui/Button.vue` | Every text button. Variants `primary`/`outline`/`ghost`/`danger`, sizes `md` (42px) and `sm` (38px). |
|
||||
| FormField, FormGrid | `admin/src/components/form/` | Repeater row bodies render a nested `FormGrid` with its own `idPrefix`. Notices and errors use the existing FormField lines and callouts. |
|
||||
| DropdownField | `admin/src/components/form/fields/DropdownField.vue` | Extended, not forked, for `dependsOn` (same native `<select>`, same `ChevronDown`). |
|
||||
| `controlClass`, `controlAttributes` | `admin/src/components/form/control.ts` | Border, background and padding of the richeditor container, the source textarea and the link URL input. |
|
||||
| `.summer-markdown` | `admin/src/styles/main.css` | Content typography inside the richeditor editing area and in the read-only HTML view, reused unchanged. |
|
||||
| `fieldErrors`, `focusField` | `admin/src/components/form/formState.ts` | Map path keys such as `content.3.answer` to nested fields, and focus the first invalid one. |
|
||||
| `showToast` | `admin/src/state/useToasts.ts` | Reorder failure and stale toasts. |
|
||||
| `t`, `tc`, `message` | `admin/src/app/i18n.ts` | All copy. Components contain no literal UI strings. |
|
||||
| `api` (openapi-fetch client) | `admin/src/api/client.ts` | The dependent-options, reorder and singleton routes, called after `npm run gen:api`. |
|
||||
| Native HTML5 drag and drop | the pattern in `FileuploadField.vue` (`draggable="true"`, `@dragstart`) | Pointer reordering of repeater rows and tree nodes. No drag-and-drop library is added (RESEARCH, Alternatives Considered). |
|
||||
|
||||
**New code this phase creates** (the planner names the files):
|
||||
- `admin/src/components/form/fields/RichEditorField.vue`, plus the TipTap glue (suggested `admin/src/components/form/richeditor/`);
|
||||
- `admin/src/components/form/fields/RepeaterField.vue`;
|
||||
- `dependsOn` support inside `DropdownField.vue`;
|
||||
- `admin/src/views/ReorderView.vue` and its route;
|
||||
- a `reorder` heading button in `ListView.vue`;
|
||||
- singleton handling in `FormView.vue`, or a thin `SingletonFormView.vue` wrapper.
|
||||
|
||||
The registry changes:
|
||||
- `richeditor` and `repeater` join `renderers`;
|
||||
- `repeater` joins `groupLabelledTypes`;
|
||||
- neither type is `valueless`.
|
||||
|
||||
Layers (new repos, no component library):
|
||||
- `vue-menu-layer`: `app/components/Menu/Nav.vue` (auto-registers as `MenuNav`), `app/components/Menu/NavItem.vue` (`MenuNavItem`, recursive), `app/composables/useMenu.ts`, `app/composables/useMenuApi.ts`, `app/utils/menuLinks.ts`, `app/assets/css/menu-tokens.css` and `menu-base.css`;
|
||||
- `vue-faq-layer`: `app/components/Faq/Block.vue` (`FaqBlock`), `app/components/Faq/List.vue` (`FaqList`), `app/components/Faq/Item.vue` (`FaqItem`), `app/composables/useFaq.ts`, `app/composables/useFaqApi.ts`, `app/assets/css/faq-tokens.css` and `faq-base.css`.
|
||||
|
||||
The layers deliberately ship no component named `FaqSection`, because the site app already has a `FaqSection.vue`, and a same-name file is the override mechanism. A layer component of that name would be silently replaced.
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values for new admin layout in this phase (multiples of 4):
|
||||
|
||||
| Token | Value | Tailwind | Usage |
|
||||
|-------|-------|----------|-------|
|
||||
| xs | 4px | `gap-1`, `p-1` | Toolbar padding and button gap; gap between the icon buttons of a row header or tree row |
|
||||
| sm | 8px | `gap-2`, `px-2` | Repeater row header gap and horizontal padding; tree row gap; link popover stack gap; empty-state stack gap |
|
||||
| md | 16px | `gap-4`, `p-4`, `px-4` | Gap between repeater rows; repeater row body padding; gap from the last row to the add button; tree row base horizontal padding; link popover padding |
|
||||
| lg | 24px | `px-6`, indent step | Tree indent per depth level (`padding-inline-start: calc(16px + depth * 24px)`); empty-state horizontal padding |
|
||||
| xl | 32px | `py-8` | Empty-state vertical padding (repeater, reorder page) |
|
||||
| 2xl | 48px | `min-h-12` | Repeater row header minimum height |
|
||||
| 3xl | 64px | — | Not used by new components |
|
||||
|
||||
Exceptions (inherited from Direction C and binding, not new; the same set accepted in 12.2 and 16.3):
|
||||
- form card padding 28px (`p-7`) and page stack gap 20px (`gap-5`), from `FormView`;
|
||||
- form-grid row gap 22px (`gap-y-[22px]`) and column gap 24px, from `FormGrid`, reused unchanged inside repeater rows;
|
||||
- label-to-control gap 6px (`gap-1.5`), from `FormField`;
|
||||
- control horizontal padding 14px (`px-3.5`) and textarea vertical padding 10px (`py-2.5`), from `MarkdownField`; the richeditor editing area and source textarea copy them;
|
||||
- footer button gap 10px (`gap-2.5`), from `FormView`;
|
||||
- alert block padding 14×18 (`px-[18px] py-3.5`), from the 12.2 alert block;
|
||||
- 2px nudges (`mt-0.5`, `gap-0.5`) in error lines, copied from the existing error-line markup.
|
||||
|
||||
No other off-scale spacing is introduced.
|
||||
|
||||
Fixed component dimensions. These are sizes, not spacing, and are listed so the checker treats them as deliberate:
|
||||
- 32px square icon buttons (`size-8`, `rounded-pager`) in the toolbar, row headers and tree rows;
|
||||
- 44px inputs (`h-input`), and 42px / 38px buttons;
|
||||
- 54px tree row minimum height (`min-h-row`, the existing `--spacing-row` token);
|
||||
- 48px repeater row header minimum height;
|
||||
- richeditor editing area `min-h-[160px]` and `max-h-[480px]`;
|
||||
- link popover width 320px;
|
||||
- 40px empty-state icon circle;
|
||||
- 2px drop-line indicator (`h-0.5`);
|
||||
- 20px toolbar separator height;
|
||||
- 12px skeleton bars with `rounded-[6px]` (the existing list skeleton bar).
|
||||
|
||||
**Layers.** Spacing comes only from tokens: `--menu-space-1..4` and `--faq-space-1..4` = 0.25rem, 0.5rem, 1rem and 1.5rem (4, 8, 16 and 24px at a 16px root). The toggle button is 44×44 (`--menu-toggle-size: 2.75rem`), the touch-target minimum.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
New admin components use exactly three sizes. All inherit DM Sans and the 14px/1.5 base from `html` in `main.css`.
|
||||
|
||||
| Role | Size | Weight | Line Height | Used by |
|
||||
|------|------|--------|-------------|---------|
|
||||
| Body | 14px | 400 | 1.5 | Richeditor text, dropdown text, tree row names, link popover input |
|
||||
| Label | 14px | 600 | 1.5 | Field labels (existing `FormField`), repeater row titles, empty-state headings, button text (existing `Button`), link popover label |
|
||||
| Meta | 13px | 400 | 1.5 | Repeater row index, repeater counter line, field comments, error and notice lines, dependent-dropdown state hints, reorder save status, richeditor source textarea (DM Mono), richeditor lossy note, empty-state body |
|
||||
| Page heading | 24px | 700 | 1.5 (inherited), `tracking-[-0.02em]` | Reorder page and singleton form `h1`: the existing `FormView` header class `text-[24px] font-bold tracking-[-0.02em]`, copied verbatim |
|
||||
|
||||
**Weights.** New content uses 400 and 600 only. **700 is the inherited, heading-only weight.** It appears only on the page `h1` (copied verbatim) and never in new components. No 12px, 17px, 18px, 20px or 26px text is introduced.
|
||||
|
||||
**Editor content.** The richeditor editing area and the read-only HTML view render the user's content with the existing `.summer-markdown` content styles. This is content typography, not interface typography, and it is reused unchanged. The toolbar offers only Heading 3, which renders at that class's 16px/600. No new content style is added.
|
||||
|
||||
**Layers.** Their typography is token-driven and limited to three sizes and two weights:
|
||||
|
||||
| Role | Token default | Weight | Line height | Used by |
|
||||
|------|---------------|--------|-------------|---------|
|
||||
| Body | `--menu-font-size` / `--faq-font-size-base` = 1rem (16px) | 400 | `--menu-line-height` 1.5 / `--faq-line-height` 1.7 | Menu links, submenu links, FAQ answers, FAQ description |
|
||||
| Question | `--faq-question-size` = 1.125rem (18px) | 600 | 1.4 | FAQ `summary` |
|
||||
| Title | `--faq-title-size` = 1.5rem (24px) | 600 | 1.25 | `FaqBlock` heading (the FAQ `name`) |
|
||||
|
||||
The active menu link does **not** change weight, so the layout does not shift. Active state is colour plus an underline.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
### Admin
|
||||
|
||||
All colours are existing CSS variables, with light and dark values from `main.css`. Hard-coded hex values are forbidden in new admin components.
|
||||
|
||||
| Role | Value (light / dark) | Usage |
|
||||
|------|----------------------|-------|
|
||||
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; form card; repeater row bodies; richeditor editing area; tree card and rows; popover |
|
||||
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6, `skel` #eceff4 / #263049, `hover` #f1f3f7 / #212b42 | Richeditor toolbar strip; repeater row headers; empty-state circle; row and tree dividers; dashed empty-state borders; meta text; skeleton bars; icon-button hover |
|
||||
| Accent (10%) | `primary` #22304d / #fcd34d (action colour); `sel` #fdf3cf / #3a3622; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45) | See the reserved list below |
|
||||
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | Repeater row remove button hover; the remove confirm button; invalid borders; error lines; alert blocks; a repeater row with errors |
|
||||
|
||||
Accent reserved for (exhaustive for this phase):
|
||||
1. **`primary` fill:** the page's existing footer Save (unchanged) and the link popover's "Apply link" button (the only primary on that surface). The reorder page has **no** primary fill, because it saves each move.
|
||||
2. **`primary` lines:**
|
||||
- the 2px drop line that marks a "before" or "after" target while a tree node or repeater row is dragged;
|
||||
- the dashed outline of an "inside" drop target;
|
||||
- focused input borders (the existing base style).
|
||||
3. **`sel` tint:**
|
||||
- pressed richeditor toolbar buttons (`aria-pressed="true"`: the active bold, italic, underline, heading, list, quote or link state, and the HTML view toggle);
|
||||
- the "inside" drop-target row;
|
||||
- the drop slot of a dragged repeater row.
|
||||
4. **`ring` (3px):** focus-visible on every new focusable element (the base style), and `:focus-within` on the richeditor container. It is never removed.
|
||||
|
||||
Accent is never used for: the repeater "Add" button (outline), the "Reorder" heading button (outline), row titles, icons at rest, toolbar hover (uses `hover`), tree row hover (uses `hover`), links in editor content, or the dependent dropdown.
|
||||
|
||||
### Layers
|
||||
|
||||
Each layer declares its own tokens. A site without Journal still gets a complete look (D-18). Default values match the `vue-journal-layer` neutral palette, so stacked layers look coherent, but there are no cross-layer variable references. An app overrides any token on `:root:root`, as the Journal README documents.
|
||||
|
||||
| Role | `vue-menu-layer` token (default) | `vue-faq-layer` token (default) | Usage |
|
||||
|------|----------------------------------|---------------------------------|-------|
|
||||
| Dominant (60%) | `--menu-color-bg` #ffffff | `--faq-color-bg` #ffffff | Submenu panel and mobile panel / FAQ item background |
|
||||
| Secondary (30%) | `--menu-color-surface` #f6f8fa, `--menu-color-border` #d1d9e0, `--menu-color-muted` #59636e, `--menu-color-text` #1f2328 | `--faq-color-surface` #f6f8fa, `--faq-color-border` #d1d9e0, `--faq-color-muted` #59636e, `--faq-color-text` #1f2328 | Link hover background, panel and item borders, divider lines, body text |
|
||||
| Accent (10%) | `--menu-color-accent` #0969da | `--faq-color-accent` #0969da | See the reserved list below |
|
||||
| Destructive | none | none | The layers have no destructive action |
|
||||
|
||||
Layer accent reserved for:
|
||||
1. **Menu:** the active link (`aria-current="page"`): text colour plus a 2px underline (`text-decoration-thickness: 2px; text-underline-offset: 0.35em`). Active-trail ancestors get the text colour only.
|
||||
2. **FAQ:** links inside answers and the description (underlined).
|
||||
3. **Both:** the 2px focus-visible outline (`outline: 2px solid var(--…-color-accent); outline-offset: 2px`).
|
||||
|
||||
Accent is never used for menu hover (uses `--menu-color-surface`), open dropdown toggles, FAQ `summary` text, or the FAQ chevron (uses `--faq-color-text`).
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
**Admin framework copy** is keyed in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`, in the new blocks `repeater:`, `richeditor:` and `reorder:`, plus new keys in `form:` and `messages.list:`.
|
||||
|
||||
**Plugin copy** lives in each plugin's `lang/{en,pl}/lang.yaml` and keeps the PHP key names. Three rules apply:
|
||||
- Values are corrected where PHP shipped placeholders or typos: "Menuses" becomes "Menus", "Is_published" becomes "Published", and "No description provided yet..." becomes a real description.
|
||||
- A `pl` file is added where PHP had only `en` (FAQ).
|
||||
- Copy never names a consuming application (CLAUDE.md).
|
||||
|
||||
Placeholders use `:name` syntax; plurals are CLDR maps (`one`, `few`, `many`, `other` for Polish). Polish is the design language; English values follow.
|
||||
|
||||
**Layer copy.** The layers ship no site copy. Their only strings are accessibility labels, passed as props with the English defaults listed below; the site app passes `$t(...)` values. Menu titles and FAQ text come from the API.
|
||||
|
||||
### Primary CTAs and key states (admin, framework)
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Primary CTA, repeater add (default) | `repeater.add` "Add item" / "Dodaj pozycję" (`Plus` icon). A field's `prompt:` replaces it. FAQ: `golem15.faq::lang.labels.add_question` "Add question" / "Dodaj pytanie". Sitemap: `golem15.sitemap::lang.item.add_item` "Add item" / "Dodaj pozycję"; the PHP `<u>…</u>` access-key markup is dropped from the value. |
|
||||
| Primary CTA, link popover | `richeditor.link_apply` "Apply link" / "Zastosuj link" |
|
||||
| List heading button | `messages.list.reorder` "Reorder records" / "Zmień kolejność rekordów" (`ArrowUpDown` icon), overridable per controller. Menu entries override it with "Reorder entries" / "Zmień kolejność pozycji". |
|
||||
| Repeater empty heading | `repeater.empty` "No items yet" / "Brak pozycji" |
|
||||
| Repeater empty body | `repeater.empty_hint` "Use “:prompt” below to add the first one." / "Użyj przycisku „:prompt” poniżej, aby dodać pierwszą." `:prompt` is the add button's label. |
|
||||
| Repeater untitled row | `repeater.untitled` "Item :n" / "Pozycja :n" (when the `titleFrom` value is empty) |
|
||||
| Repeater counter (only with `maxItems`) | `repeater.count` "Items: :current of :max" / "Pozycje: :current z :max" |
|
||||
| Repeater row error chip | existing `form.tab_errors` (":count error" / ":count errors"; PL from the existing CLDR map) |
|
||||
| Reorder empty heading | `reorder.empty` "There is nothing to reorder yet" / "Nie ma jeszcze czego porządkować" |
|
||||
| Reorder empty body | `reorder.empty_hint` "Add records first, then come back to set their order." / "Najpierw dodaj rekordy, a potem wróć tutaj, aby ustalić ich kolejność." The header back button is the next step. |
|
||||
| Reorder untitled node | `reorder.untitled` "Untitled (#:id)" / "Bez tytułu (#:id)". Divider menu entries usually have no title. |
|
||||
| Reorder save status | busy: existing `form.saving` "Saving…" / "Zapisywanie…"; done: `reorder.saved` "Order saved" / "Zapisano kolejność" (shown for 2 s) |
|
||||
| Dependent dropdown, sibling empty | `form.dependent_waiting` "Choose “:field” first" / "Najpierw wybierz pole „:field”". `:field` is the sibling's label. |
|
||||
| Dependent dropdown, type takes no value | `form.dependent_not_used` "Not used for this choice" / "Nie dotyczy tego wyboru" |
|
||||
| Dependent dropdown, loading | existing `form.loading_options` "Loading…" / "Wczytywanie…" |
|
||||
| Dependent dropdown, type has no records yet | `form.dependent_empty` "No “:type” records yet. Add one first, or choose another type." / "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ." `:type` is the selected sibling option's label. The existing `form.no_options` stays unchanged for the plain DropdownField. |
|
||||
| Dependent dropdown, value cleared (polite live region) | `form.dependent_cleared` "“:field” was cleared because its options changed." / "Wyczyszczono pole „:field”, bo zmieniły się jego opcje." |
|
||||
| Richeditor HTML view toggle | `richeditor.source` "Edit HTML" / "Edytuj HTML" (`Code` icon, `aria-pressed`) |
|
||||
| Richeditor toolbar label | `richeditor.toolbar` "Formatting" / "Formatowanie" |
|
||||
| Richeditor lossy note | `richeditor.lossy_note` "Some formatting in this text cannot be shown in the editor. Use “Edit HTML” to change it without losing it." / "Części formatowania tego tekstu nie da się pokazać w edytorze. Użyj „Edytuj HTML”, aby zmienić tekst bez jej utraty." |
|
||||
|
||||
### Error states (admin, framework)
|
||||
|
||||
| Element | Copy (EN / PL) |
|
||||
|---------|----------------|
|
||||
| Dependent options failed to load | `form.dependent_failed` "The options could not be loaded." / "Nie udało się wczytać opcji." plus a `form.dependent_retry` "Retry loading options" / "Ponów wczytywanie opcji" button (`RotateCcw`) |
|
||||
| Dependent value rejected on save (422) | `form.dependent_invalid` "Choose one of the listed options." / "Wybierz jedną z dostępnych opcji." (server message, rendered verbatim) |
|
||||
| Repeater required nested field (422) | the server's message under the nested field, verbatim (path key `content.3.answer`) |
|
||||
| Repeater too many or too few rows (422) | `repeater.max_items` "Remove items until there are at most :max." / "Usuń pozycje, aby zostało ich najwyżej :max."; `repeater.min_items` "Add items until there are at least :min." / "Dodaj pozycje, aby było ich co najmniej :min." (server messages under the field label) |
|
||||
| Richeditor editor failed to load | `richeditor.load_failed` "The editor could not be loaded. You can edit the HTML directly." / "Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio." |
|
||||
| Richeditor invalid link URL | `richeditor.link_invalid` "Links must use http, https, mailto, tel or a relative URL." / "Link musi używać http, https, mailto, tel albo adresu względnego." |
|
||||
| Richeditor sanitize notices (after save, warning callout under the field) | `richeditor.removed_elements` one: ":count element was removed: :names" other: ":count elements were removed: :names" / one: "Usunięto :count element: :names" few: "Usunięto :count elementy: :names" many: "Usunięto :count elementów: :names" other: "Usunięto :count elementu: :names". `richeditor.removed_attributes` and `richeditor.removed_comments` follow the same pattern. `richeditor.markup_changed` "The HTML filter changed some markup." / "Filtr HTML zmienił część znaczników." The copy is taken from Journal's `editor.*` keys so both editors speak the same way. |
|
||||
| Reorder load failed | `reorder.load_failed` "The records could not be loaded. Refresh the page to try again." / "Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie." |
|
||||
| Reorder save failed | `reorder.failed` "The new order could not be saved. The previous order is back." / "Nie udało się zapisać nowej kolejności. Przywrócono poprzednią." (danger toast) |
|
||||
| Reorder stale set (server refuses: a record was added or removed meanwhile) | `reorder.stale` "The records changed in the meantime. The list has been reloaded." / "Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie." (danger toast, tree reloads) |
|
||||
| Singleton form load failed | existing `form.load_failed` (unchanged FormView alert) |
|
||||
|
||||
### Destructive actions (admin)
|
||||
|
||||
| Action | Confirmation approach | Copy (EN / PL) |
|
||||
|--------|----------------------|----------------|
|
||||
| Remove a repeater row that has any non-empty value | ConfirmDialog, `danger`. The removal is deferred until the record's Save, so Cancel on the form undoes it and the form becomes dirty. | Button aria-label: `repeater.remove_named` "Remove: :title" / "Usuń: :title". Confirm: `repeater.remove_confirm` "Remove “:title”? The change is saved together with the record." / "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem." Confirm button: `repeater.remove` "Remove item" / "Usuń pozycję". |
|
||||
| Remove an empty repeater row | No dialog: there is nothing to lose. | the same aria-label |
|
||||
| Move or nest a tree node | No dialog. Each move saves at once, and the move buttons reverse any move. | — |
|
||||
| Leave a form with unsaved repeater or richeditor changes | existing unsaved-changes ConfirmDialog (unchanged) | existing `form.unsaved_confirm` / `form.discard` |
|
||||
| Empty the sitemap definition (the PHP `empty_confirm` toolbar action) | **Not ported.** The repeater removes rows one by one, and an accidental "empty all" is costly. Documented as an adaptation in the sitemap README. | — |
|
||||
|
||||
### Accessibility strings (admin, framework)
|
||||
|
||||
These are new keys:
|
||||
- `repeater.move` "Move: :title" / "Przenieś: :title" (drag handle);
|
||||
- `repeater.move_up` "Move up: :title" / "Przenieś wyżej: :title";
|
||||
- `repeater.move_down` "Move down: :title" / "Przenieś niżej: :title";
|
||||
- `repeater.collapse` "Collapse: :title" / "Zwiń: :title" and `repeater.expand` "Expand: :title" / "Rozwiń: :title";
|
||||
- `repeater.collapse_all` "Collapse all" / "Zwiń wszystkie" and `repeater.expand_all` "Expand all" / "Rozwiń wszystkie";
|
||||
- `repeater.moved` ":title moved to position :position of :total." / "Przeniesiono :title na pozycję :position z :total.";
|
||||
- `reorder.move` "Move: :name" / "Przenieś: :name";
|
||||
- `reorder.move_up` "Move up: :name" / "Przenieś wyżej: :name";
|
||||
- `reorder.move_down` "Move down: :name" / "Przenieś niżej: :name";
|
||||
- `reorder.indent` "Move inside the previous item: :name" / "Przenieś do poprzedniej pozycji: :name";
|
||||
- `reorder.outdent` "Move out one level: :name" / "Przenieś poziom wyżej: :name";
|
||||
- `reorder.moved` ":name moved to position :position of :total." / "Przeniesiono :name na pozycję :position z :total.";
|
||||
- `reorder.nested` ":name is now inside :parent." / ":name jest teraz wewnątrz :parent.";
|
||||
- `reorder.unnested` ":name moved out to level :level." / "Przeniesiono :name na poziom :level.";
|
||||
- `reorder.tree` "Order of records" / "Kolejność rekordów" (the list's accessible name).
|
||||
|
||||
Richeditor button labels:
|
||||
- `richeditor.bold` "Bold" / "Pogrubienie";
|
||||
- `richeditor.italic` "Italic" / "Kursywa";
|
||||
- `richeditor.underline` "Underline" / "Podkreślenie";
|
||||
- `richeditor.heading` "Heading" / "Nagłówek";
|
||||
- `richeditor.bullet_list` "Bullet list" / "Lista punktowana";
|
||||
- `richeditor.ordered_list` "Numbered list" / "Lista numerowana";
|
||||
- `richeditor.quote` "Quote" / "Cytat";
|
||||
- `richeditor.link` "Link" / "Link";
|
||||
- `richeditor.unlink` "Remove link" / "Usuń link";
|
||||
- `richeditor.undo` "Undo" / "Cofnij";
|
||||
- `richeditor.redo` "Redo" / "Ponów";
|
||||
- `richeditor.link_url` "Link URL" / "Adres linku".
|
||||
|
||||
Other keys:
|
||||
- the existing `form.close` is reused;
|
||||
- `settings.back` (existing) is the back-button label on the singleton form.
|
||||
|
||||
### Plugin admin copy (keys keep the PHP names)
|
||||
|
||||
| Plugin | Element | Copy (EN / PL) |
|
||||
|--------|---------|----------------|
|
||||
| faq | Navigation | `plugin.name` "FAQ" / "FAQ"; side menu `models.faq.label_plural` "FAQs" / "FAQ"; `plugin.description` "Questions and answers shown on the site." / "Pytania i odpowiedzi wyświetlane na stronie."; permission `permissions.faqs` "Manage FAQs" / "Zarządzanie FAQ" |
|
||||
| faq | Field labels | `labels.name` "Name" / "Nazwa"; `labels.slug` "Slug" / "Slug"; `labels.locale` "Language" / "Język"; `labels.description` "Description" / "Opis"; `labels.is_active` "Active" / "Aktywne"; `labels.content` "Questions" / "Pytania"; `labels.question` "Question" / "Pytanie"; `labels.answer` "Answer" / "Odpowiedź"; `labels.add_question` "Add question" / "Dodaj pytanie" |
|
||||
| faq | Field comment | `labels.is_active_comment` "Only active FAQs are served to the site." / "Strona otrzymuje tylko aktywne FAQ." `labels.locale_comment` "One FAQ per language. Use the same slug in each language." / "Jedno FAQ na język. W każdym języku użyj tego samego sluga." |
|
||||
| menu | Navigation | `plugin.name` "Menus" / "Menu"; side items `models.menu.label_plural` "Menus" / "Menu" and `models.menuentry.label_plural` "Menu entries" / "Pozycje menu"; permission "Manage site menus" / "Zarządzanie menu strony" |
|
||||
| menu | Menu fields | "Name" / "Nazwa"; "Code" / "Kod"; "CSS class" / "Klasa CSS"; "Active" / "Aktywne" |
|
||||
| menu | Entry fields | "Menu" / "Menu"; "Parent" / "Pozycja nadrzędna" (empty option: existing `form.none`); "Type" / "Typ"; "Title" / "Tytuł"; "URL" / "URL"; "Reference" / "Odnośnik"; "Code" / "Kod"; "Icon" / "Ikona"; "CSS class" / "Klasa CSS"; "Opens in" / "Otwieraj w"; "Published" / "Opublikowane" |
|
||||
| menu | Type options (built-ins, D-05) | `url` "Link" / "Link"; `divider` "Divider" / "Separator". Registry types follow in registry order with the owning plugin's labels (below). |
|
||||
| menu | Target options (new field, RESEARCH Open Q 7) | `_self` "Same tab" / "Tej samej karcie"; `_blank` "New tab" / "Nowej karcie" |
|
||||
| menu | URL comment and error | comment `models.menuentry.url_comment` "Used by Link entries. Start with http://, https://, mailto:, tel:, / or #." / "Dla pozycji typu Link. Zacznij od http://, https://, mailto:, tel:, / lub #."; 422 `errors.url_scheme` "Use a link that starts with http://, https://, mailto:, tel:, / or #." / "Podaj link zaczynający się od http://, https://, mailto:, tel:, / lub #." (Pitfall 15) |
|
||||
| menu | Reference comment | `models.menuentry.reference_comment` "The record this entry points to, for types that need one." / "Rekord, do którego prowadzi pozycja, dla typów, które go wymagają." |
|
||||
| journal | Registry type labels (D-04, new Go keys `menuitem.*`) | `journal-category` "Journal category" / "Kategoria (Journal)"; `all-journal-categories` "All journal categories" / "Wszystkie kategorie (Journal)"; `journal-post` "Journal post" / "Wpis (Journal)"; `all-journal-posts` "All journal posts" / "Wszystkie wpisy (Journal)"; `category-journal-posts` "Journal category posts" / "Wpisy kategorii (Journal)". PL values replace the PHP ones, which were wrong ("Kategorie" for one category). |
|
||||
| sitemap | Settings entries (category CMS) | definitions link: `plugin.name` "Sitemap" / "Mapa strony", `plugin.description` "Generate a sitemap.xml file for your website." / "Generuje plik sitemap.xml dla Twojej strony." (PHP values kept); settings form: `settings.label` "Sitemap settings" / "Ustawienia mapy strony", `settings.description` "Frontend URL, locale and priority options." / "Adres frontendu, język i priorytety." |
|
||||
| sitemap | Singleton form title and subtitle | `definition.title` "Sitemap definition" / "Definicja mapy strony"; subtitle link `definition.view` "View sitemap.xml" / "Zobacz sitemap.xml" (`ExternalLink` 14px, new tab) |
|
||||
| sitemap | Item row fields | PHP keys and values: `item.type`, `item.url`, `item.reference`, `item.url_pattern` with `item.url_pattern_comment` ("Use :placeholder syntax, e.g. /blog/:slug" / "Użyj składni :placeholder, np. /blog/:slug"; the PL value is new), `item.allow_nested_items` with comment, `item.priority`, `item.changefreq` and its seven frequency values. `cmsPage` is dropped (D-12). |
|
||||
| sitemap | Settings fields | New keys `settings.frontend_url`, `settings.frontend_default_locale`, `settings.updates_manifest_path`, `settings.enable_priority_tiers`, `settings.priority_*` with the PHP labels. The comments are generic: the PHP comments named one site's paths, so the tier comments become "Used for URLs that match the :tier tier patterns in the host config. Default: :value" / "Dla adresów pasujących do wzorców poziomu „:tier” z konfiguracji hosta. Domyślnie: :value". The `_nginx_hint` and `_priority_section` blocks become field comments and README text (Pitfall 9). |
|
||||
|
||||
### Layer accessibility strings (props with English defaults)
|
||||
|
||||
| Layer | Prop | Default (EN) | Site passes (PL example) |
|
||||
|-------|------|--------------|--------------------------|
|
||||
| menu | `label` (the `<nav>` `aria-label`) | the menu `code` | "Menu główne" |
|
||||
| menu | `openLabel` / `closeLabel` (mobile toggle) | "Open menu" / "Close menu" | "Otwórz menu" / "Zamknij menu" |
|
||||
| menu | `submenuLabel` (toggle of an entry that also has a link) | "Show submenu: :title" | "Pokaż podmenu: :title" |
|
||||
| faq | none: the heading and description come from the API | — | — |
|
||||
|
||||
---
|
||||
|
||||
## Component Contracts
|
||||
|
||||
### 1. RichEditorField (`type: richeditor`, D-10)
|
||||
|
||||
**Structure.** One container: `controlClass(invalid)` minus the `px-3.5` (the parts pad themselves), plus `overflow-hidden`. It shows the input focus treatment through `focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]`, matching every other input. `data-richeditor`. Top to bottom:
|
||||
|
||||
1. **Toolbar.** Reka `ToolbarRoot` with `aria-label` `richeditor.toolbar` and `aria-controls` pointing at the editing area: `flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1`.
|
||||
- Buttons are `ToolbarButton`, 32×32 `size-8 inline-flex items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text disabled:opacity-40 disabled:cursor-not-allowed`. Each holds a 16px icon (`aria-hidden`) and has its label as both `aria-label` and `title`. `mousedown.prevent` keeps the editor selection.
|
||||
- Groups are separated by `ToolbarSeparator` (`mx-1 h-5 w-px bg-border`). In order:
|
||||
- Bold (`Bold`), Italic (`Italic`), Underline (`Underline`);
|
||||
- Heading (`Heading3`, toggles H3 ↔ paragraph), Bullet list (`List`), Numbered list (`ListOrdered`), Quote (`Quote`);
|
||||
- Link (`Link`, opens the popover), Remove link (`Unlink`, enabled only inside a link);
|
||||
- Undo (`Undo2`), Redo (`Redo2`).
|
||||
- Then, pushed right with `ml-auto`, the HTML toggle: `Code` icon plus the text `richeditor.source` at 13px. It is a 32px-high button with `px-2` and `aria-pressed`.
|
||||
- Formatting buttons carry `aria-pressed` from the editor state. Undo and Redo are disabled when they cannot run. Link is disabled when the selection is empty and the cursor is not inside a link.
|
||||
2. **Editing area.** The TipTap root: `summer-markdown min-h-[160px] max-h-[480px] overflow-y-auto px-3.5 py-2.5 outline-none`, with `role="textbox"`, `aria-multiline="true"`, `aria-labelledby` set to the field label and `aria-describedby` forwarded. Content scrolls inside it, so the toolbar stays visible.
|
||||
3. **HTML view** (when the toggle is pressed). It replaces the editing area with a `<textarea>`: `block w-full min-h-[160px] resize-y bg-surface px-3.5 py-2.5 font-mono text-[13px] text-text outline-none`, `spellcheck="false"`, holding the current HTML. Every formatting button is disabled in this view. Switching back parses the textarea into the editor.
|
||||
|
||||
**Extensions.** StarterKit 3.31.4 is configured with headings limited to `[3]`. Its Link has `openOnClick: false`, `autolink: false`, and a protocol allowlist of http, https, mailto and tel. Code, CodeBlock and HorizontalRule are off. TipTap's injected CSS is off (`injectCSS: false`), as in Journal.
|
||||
|
||||
**Link popover.** Reka Popover anchored on the Link button: `w-[320px] flex flex-col gap-2 rounded-inner border border-border bg-surface p-4 shadow-menu`.
|
||||
- It holds a `label` (14/600) `richeditor.link_url` and an `input type="url"`, `h-input` with `controlClass(invalid)`, prefilled with the current link's href.
|
||||
- Under it sits the error line (13px `text-danger`, `CircleAlert` 14px).
|
||||
- The button row is `flex justify-end gap-2`: `Button variant="ghost" size="sm"` "Remove link" (only when inside a link) and `Button variant="primary" size="sm"` `richeditor.link_apply`.
|
||||
- Enter applies; Esc closes and returns focus to the Link button.
|
||||
- Client validation accepts `http:`, `https:`, `mailto:`, `tel:`, a path starting with `/`, `#`, `?` or `./`, or a bare relative path. Anything else shows `richeditor.link_invalid` and is not applied. The server sanitizer is still the gate.
|
||||
|
||||
**Value contract.**
|
||||
- The value is an HTML string.
|
||||
- **Untouched rule (binding):** the field emits nothing until the user edits. A loaded value is submitted byte-identical, never re-serialized by TipTap, so imported PHP HTML is never rewritten by merely opening and saving the form (RESEARCH test map, "untouched value byte-identical").
|
||||
- After the first edit the value is `editor.getHTML()`. An editor holding only an empty paragraph emits `""`. The HTML view emits the textarea text as typed.
|
||||
|
||||
**Lossy note.** On load, if the stored HTML contains an element outside this set, a note shows between the toolbar and the editing area: `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `s`, `h3`, `ul`, `ol`, `li`, `blockquote`, `a`, `div`, `span`. The check is a case-insensitive comparison of tag names.
|
||||
- Markup: `flex items-start gap-2 border-b border-border bg-surface px-3.5 py-2.5 text-[13px] text-muted`, `role="note"`, `Info` 14px (`mt-0.5 shrink-0`) and `richeditor.lossy_note`.
|
||||
- The editor still opens. The untouched rule keeps the stored HTML until the user edits, and the HTML view edits it without loss.
|
||||
|
||||
**Save notices.** The server sanitizes on save (D-10) and returns removal notices in `meta.notices` under the field path. They render through the existing `FormField` warning callout (`summer-callout summer-callout--warning`, `role="status"`) under the field, with no new markup. The editor then shows the sanitized value returned by the save.
|
||||
|
||||
**Loading.** The TipTap code is a lazy chunk. While it loads, the container shows a `h-[200px] rounded-control bg-skel` block with `aria-busy="true"`.
|
||||
- If the chunk fails, the container becomes the HTML view, already toggled on with the toggle hidden, and a 13px muted note `richeditor.load_failed` sits above it. Saving still works.
|
||||
|
||||
**Read-only** (`attributes: readonly: true`, or preview). The sanitized HTML renders in the existing read-only box (`rounded-control border border-border bg-subtle px-3.5 py-2.5`) with `summer-markdown`. An empty value shows a muted "—".
|
||||
|
||||
**Invalid.** A 422 renders through the existing FormField error line. The container border becomes `danger` and the editing area gets `aria-invalid="true"`.
|
||||
|
||||
**Keyboard.**
|
||||
- TipTap defaults: Ctrl/Cmd+B, I and U, Ctrl/Cmd+Z, and Ctrl/Cmd+Shift+Z.
|
||||
- Ctrl/Cmd+K opens the link popover.
|
||||
- Tab leaves the editing area: TipTap does not capture Tab, and lists indent through the toolbar only.
|
||||
- The toolbar is one Tab stop; arrow keys move between its buttons (Reka roving tabindex).
|
||||
|
||||
### 2. RepeaterField (`type: repeater`, D-07)
|
||||
|
||||
**Label.** `repeater` joins `groupLabelledTypes`. The label is a `<span>` naming the field's `role="group"` container through `aria-labelledby`. The field is always full width: the server compiles `span: full`.
|
||||
|
||||
**Visual anchor.** The rows are read first. The only filled element is the page footer's Save, so the outline "Add" button under the rows reads as a secondary step.
|
||||
|
||||
**Layout.** `flex flex-col gap-4`, `data-repeater="<name>"`. Rows come first, then a footer line `flex flex-wrap items-center gap-4` holding the add button and the counter.
|
||||
|
||||
**Row.** `rounded-inner border border-border bg-surface` (`border-danger` when any nested field in the row has an error), `data-repeater-row="<index>"`. A Reka `CollapsibleRoot` holds it, with `open` by default.
|
||||
- **Header** (`CollapsibleTrigger` is *not* the whole header: only the expand button toggles): `flex min-h-12 items-center gap-2 bg-subtle px-2 rounded-t-inner`. When the row is open, the header adds `border-b border-border`; when collapsed, it becomes `rounded-inner`. Left to right:
|
||||
- the drag handle: a 32×32 ghost icon button with `GripVertical` and aria `repeater.move` (hidden when read-only);
|
||||
- the row index `:n.` at 13px muted `tabular-nums`;
|
||||
- the title, `min-w-0 flex-1 truncate`, 14/600, with the full text in `title`. It is the `titleFrom` nested field's value: the option label for a dropdown, text content without tags for a richeditor. When empty it is `repeater.untitled`.
|
||||
- the error chip (only when the row has errors): `inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold text-danger`, with `form.tab_errors`;
|
||||
- an icon-button cluster `flex gap-1`: Move up (`ChevronUp`, aria `repeater.move_up`, disabled on the first row), Move down (`ChevronDown`, aria `repeater.move_down`, disabled on the last row), Expand/Collapse (`ChevronDown` rotated 180° when open, `transition-transform duration-150`, aria `repeater.collapse` or `repeater.expand`, `aria-expanded`), and Remove (`Trash2`, aria `repeater.remove_named`, `hover:bg-danger-soft hover:text-danger`, disabled when the row count equals `minItems`).
|
||||
- All of these are 32×32 `rounded-pager text-muted hover:bg-hover hover:text-text` buttons, always visible, never hover-only.
|
||||
- **Body** (`CollapsibleContent`): `p-4`, holding a nested `FormGrid` built from the repeater's `form.fields`.
|
||||
- The `idPrefix` is `<form idPrefix>-<field>-<rowKey>`. Field ids, label `for`, `aria-describedby`, errors (path `field.<index>.<nested>`) and notices all resolve per row.
|
||||
- The nested grid keeps the 2-column layout and `span` rules of the top-level grid.
|
||||
- Collapsed rows unmount nothing: values stay in the form state. A collapsed row's richeditor is not mounted until it is expanded, so N rows do not start N editors.
|
||||
- **Load state.** All rows are expanded on load, as in Winter. When the repeater has more than one row, a `Button variant="ghost" size="sm"` "Collapse all" / "Expand all" (`repeater.collapse_all` / `repeater.expand_all`) sits at the right of the footer line.
|
||||
|
||||
**Add.**
|
||||
- `Button variant="outline" size="sm" :icon="Plus"` with the field's `prompt` or `repeater.add`, `data-action="repeater-add"`.
|
||||
- It appends one row with nested defaults, expanded, and focuses its first focusable nested control.
|
||||
- It is disabled when the count equals `maxItems`.
|
||||
- The counter `repeater.count` (13px muted `tabular-nums`) shows only when `maxItems` is set.
|
||||
|
||||
**Empty.** With zero rows, the rows area is replaced by `flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center`, `data-repeater-empty`. It holds `repeater.empty` (14/600) and `repeater.empty_hint` (13px muted, `max-w-[420px]`). The add button stays below it.
|
||||
|
||||
**Remove.** A row with any non-empty nested value asks first: `useConfirm` with `repeater.remove_confirm`, `danger`, confirm label `repeater.remove`. An empty row is removed at once. After removal, focus moves to the next row's handle, else the previous row's handle, else the add button.
|
||||
|
||||
**Reorder.**
|
||||
- **Pointer:** drag by the handle. The dragged row is `opacity-50`. The target slot shows a `rounded-inner border-2 border-dashed border-primary bg-sel` placeholder of the row's header height.
|
||||
- **Keyboard:** with the handle focused, ArrowUp moves the row one place earlier and ArrowDown one place later.
|
||||
- **Buttons:** the visible Move up and Move down buttons do the same (WCAG 2.5.7 single-pointer alternative).
|
||||
- Focus stays on the moved row's handle, and a polite `aria-live` region announces `repeater.moved`.
|
||||
- Reordering only changes form state; it is saved with the record.
|
||||
|
||||
**Errors.** A 422 with path keys:
|
||||
- expands every row that has an error;
|
||||
- adds the row error chip and `border-danger`;
|
||||
- renders each message under its nested field;
|
||||
- makes the existing `focusField` focus the first invalid nested control.
|
||||
|
||||
`repeater.max_items` and `repeater.min_items` messages render under the field label as a normal field error.
|
||||
|
||||
**Dirty.** Any add, remove, reorder or nested edit marks the form dirty.
|
||||
|
||||
**Read-only.** There is no handle, move, remove or add, and there are no counters. Rows render their nested fields read-only, all expanded.
|
||||
|
||||
**Nesting limits (server-enforced, D-07).** These nested types are allowed: `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or `dependsOn`) and `richeditor`. A repeater in a repeater fails boot, so there is no nested visual case.
|
||||
|
||||
### 3. Dependent dropdown (`type: dropdown` + `dependsOn`, D-08)
|
||||
|
||||
It is the same control as `DropdownField`: the native `<select>` with `h-input appearance-none pr-10`, `controlClass(invalid)` and the `ChevronDown` 16px. Its options come from the dependent-options route keyed on the sibling's current value. The sibling is in the same form, or in the same repeater row.
|
||||
|
||||
| State | Rendering |
|
||||
|-------|-----------|
|
||||
| Sibling empty | `disabled`; a single option with `form.dependent_waiting` in `text-muted`. Value `null`. |
|
||||
| Loading | `disabled`, `aria-busy="true"`; a single option with `form.loading_options`. |
|
||||
| Type takes no value (the registry type has no references, e.g. `all-journal-categories`, `url`, `divider`) | `disabled`; a single option with `form.dependent_not_used`. Value sent as `null`. |
|
||||
| Loaded, zero options | `disabled`; a single option with `form.dependent_empty` (`:type` = the sibling's selected option label). The same text repeats as a 13px muted line under the control (`aria-describedby`), because a disabled `<select>` hides its option text from some screen readers. |
|
||||
| Loaded | Enabled. The `emptyOption` shows if declared, then the options. Nested references are indented by prefixing the label with four U+00A0 per `depth`, the same flattening Winter used. |
|
||||
| Stored value not among the options | The existing DropdownField rule: the disabled `form.select_placeholder` option is selected, never a wrong choice. The server 422 `form.dependent_invalid` then guides the user. |
|
||||
| Load failed | `disabled`. Under the control, a 13px `text-danger` line (`flex items-center gap-1.5`, `CircleAlert` 14px, `role="alert"`) shows `form.dependent_failed`, followed by `Button variant="ghost" size="sm" :icon="RotateCcw"` `form.dependent_retry`, which refetches. |
|
||||
|
||||
**Behaviour.**
|
||||
- When the sibling's value changes, the options for the new value load. Responses are cached per sibling value for the life of the form view, so switching back does not refetch.
|
||||
- If the current value is not in the new options, it is set to `null`, the form becomes dirty, and the form's single polite live region announces `form.dependent_cleared`.
|
||||
- An in-flight request for a previous sibling value is discarded when it resolves late (last request wins).
|
||||
- **One admin binding:** the menu entry form and the sitemap repeater rows both declare `reference` with `dependsOn: type`. The visuals are identical in both.
|
||||
|
||||
### 4. Reorder page (D-09) and the list "Reorder" button
|
||||
|
||||
**Entry point.**
|
||||
- When the list config declares `reorder` among its buttons, `ListView` renders `Button variant="outline" :icon="ArrowUpDown"` with `messages.list.reorder`, `data-action="reorder"`, in the header button group **before** the primary "New record" button.
|
||||
- It links to the controller's reorder route. When the list is filtered on the reorder partition column, the filter value goes along as `?partition=<id>`.
|
||||
|
||||
**Visual anchor.** The first tree row's name is the anchor. The page has no primary fill, and the save status is muted, so the eye goes from the 24/700 `h1` straight to the tree.
|
||||
|
||||
**Page (`ReorderView.vue`).** It is a `section.flex.w-full.flex-col.gap-5`.
|
||||
- **Header:** the `FormView` header markup copied:
|
||||
- the 40px outline back button (`ArrowLeft` 18px, aria `form.return_to_list`), which returns to the list with its query kept;
|
||||
- the `h1` with the reorder config `title` (24/700, class copied). Menu entries use "Reorder menu entries" / "Zmiana kolejności pozycji menu".
|
||||
- At the right, `flex items-center gap-4`:
|
||||
- the save status (13px muted, `flex items-center gap-1.5`, `aria-live="polite"`): `LoaderCircle` 14px `animate-spin` plus `form.saving` while a save is in flight, `reorder.saved` for 2 s after success, otherwise empty;
|
||||
- when the config declares a partition, the partition `<select>`. It is styled as `DropdownField` (`h-input w-[240px]`) with a visible `label` (14/600, `flex items-center gap-2`) taken from the partition field's label ("Menu" for menu entries), and lists the partition records by name.
|
||||
- Changing the partition updates `?partition=` (replace, not push) and reloads the tree. The initial partition is `?partition`, else the first option.
|
||||
- **Card:** `overflow-hidden rounded-card border border-border bg-surface shadow-card`, holding the tree.
|
||||
|
||||
**Tree.**
|
||||
- A nested `ul` (`role="list"`, `aria-label` `reorder.tree`, `data-reorder-tree`), one `li` per node with its children's `ul` inside it. Nodes are ordered by `sort_order` within each parent.
|
||||
- A row is `flex min-h-row items-center gap-2 border-t border-border pr-4 first:border-t-0 hover:bg-hover`, with `padding-inline-start: calc(16px + depth * 24px)` and `data-reorder-node="<id>"`. Left to right:
|
||||
- the drag handle: a 32×32 ghost icon button with `GripVertical` and aria `reorder.move`;
|
||||
- the name: the `nameFrom` value, 14/400, `min-w-0 flex-1 [overflow-wrap:anywhere]`, or `reorder.untitled` in `text-muted`;
|
||||
- a cluster `flex gap-1` of four 32×32 ghost icon buttons: Move up (`ChevronUp`), Move down (`ChevronDown`), Outdent (`IndentDecrease`) and Indent (`IndentIncrease`), with the `reorder.*` aria-labels. Each is disabled when the move is impossible: first or last among its siblings, already at the root, or no previous sibling to nest under.
|
||||
- Children always move with their parent. There is no collapse: menus and similar trees are short, and the page scrolls.
|
||||
|
||||
**Moves.**
|
||||
- **Pointer drag** by the handle; the dragged row is `opacity-50`. Each hovered row has three zones:
|
||||
- top third: "before" (a 2px `bg-primary` line `h-0.5` at the row's top edge, starting at the target depth's indent);
|
||||
- bottom third: "after" (the same line at the bottom edge);
|
||||
- middle third: "inside, as last child" (the row gets `bg-sel outline-2 outline-dashed outline-primary -outline-offset-2`).
|
||||
- A node cannot drop into its own subtree: no indicator, `cursor-not-allowed`.
|
||||
- **Keyboard on the handle:**
|
||||
- ArrowUp and ArrowDown swap with the previous or next sibling;
|
||||
- ArrowRight nests the node as the last child of its previous sibling;
|
||||
- ArrowLeft moves it out to become the next sibling of its parent.
|
||||
- **Buttons:** the four visible buttons do the same as the keys (WCAG 2.5.7).
|
||||
- After any move, focus stays on the moved node's handle, and a polite live region (separate from the save status) announces `reorder.moved`, `reorder.nested` or `reorder.unnested`.
|
||||
|
||||
**Saving.**
|
||||
- Every move saves without a Save button: one `POST` with the whole scoped node set (`{nodes:[{id,parent_id,sort_order}]}`), debounced 400 ms after the last move, the same as the 12.2 file reorder.
|
||||
- Leaving the page flushes a pending debounce at once and never asks for confirmation.
|
||||
- On failure, the tree reverts to the last saved state and the `reorder.failed` danger toast shows.
|
||||
- When the server refuses a stale set (a node was added, removed or moved out of the partition meanwhile), the `reorder.stale` danger toast shows and the tree reloads.
|
||||
|
||||
**States.**
|
||||
- **Loading:** 5 skeleton rows (`min-h-row`, a 20px square `rounded-[6px] bg-skel` and a `h-3 w-48 rounded-[6px] bg-skel` bar), with `aria-busy="true"` on the card.
|
||||
- **Load failure:** the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`, `CircleAlert` 16px) with `reorder.load_failed`, in place of the card.
|
||||
- **Empty** (the partition has no nodes, or no partition exists): inside the card, `flex flex-col items-center gap-2 px-6 py-8 text-center`. It holds a 40px `bg-subtle` circle with `ArrowUpDown` 20px muted, `reorder.empty` (14/600) and `reorder.empty_hint` (13px muted, `max-w-[420px]`).
|
||||
- **One node:** every move button is disabled; the handle stays focusable and its arrow keys do nothing.
|
||||
|
||||
**Permissions.** The route inherits the controller's permission. A 403 shows the existing `form.forbidden` alert block in place of the card.
|
||||
|
||||
### 5. Singleton form (sitemap definition, D-11)
|
||||
|
||||
- The sitemap's settings entry links to the definitions controller. That controller declares the singleton capability, so the SPA asks the server for the one record (the server does `firstOrCreate` with the fixed theme key) and renders `FormView` in update mode for it. There is no list screen and no create screen.
|
||||
- **Header:**
|
||||
- the back button aria is `settings.back` and returns to the settings index;
|
||||
- the `h1` is `definition.title`;
|
||||
- the subtitle line is 13px muted (`flex items-center gap-1.5`) with a plain `<a>` `definition.view` to `/sitemap.xml` (`target="_blank" rel="noopener noreferrer"`, underlined, `ExternalLink` 14px `aria-hidden`).
|
||||
- **Footer:**
|
||||
- no Delete button (a singleton cannot be deleted);
|
||||
- Cancel (ghost) and Save and close (outline) both return to the settings index;
|
||||
- Save (primary) stays.
|
||||
- **Body:** one full-width `repeater` field `data` with these row fields (`titleFrom: url`; a registry row with no URL shows `repeater.untitled`):
|
||||
|
||||
| Row field | Type | Span |
|
||||
|-----------|------|------|
|
||||
| type | `dropdown`: `url` "URL" plus the registry types | left |
|
||||
| reference | `dropdown` with `dependsOn: type` | right |
|
||||
| url | `text` | left |
|
||||
| urlPattern | `text`, with comment | right |
|
||||
| priority | `dropdown`, options `0.1` to `1.0` | left |
|
||||
| changefreq | `dropdown`, the 7 frequencies | right |
|
||||
| nesting | `checkbox`, with comment, default on | full |
|
||||
|
||||
- The save toast is the existing `messages.form.saved`. The unsaved-changes guard is unchanged.
|
||||
- **Visual anchor:** the repeater's first row header (14/600 title) is read first after the `h1`. The footer Save is the page's only primary fill, and the "View sitemap.xml" link stays muted.
|
||||
|
||||
### 6. Plugin admin screens (YAML only)
|
||||
|
||||
Winter keys that cabana lacks are adapted as RESEARCH Pitfall 9 lists:
|
||||
- tabs are flattened;
|
||||
- `span: none` and `cssClass` become `left`/`right`;
|
||||
- `select:` on a relation becomes `nameFrom`;
|
||||
- `hint` and `section` become `comment`;
|
||||
- there are no `trigger` conditions, so fields always show and their comments say when they apply.
|
||||
|
||||
Every adaptation is listed in each plugin README.
|
||||
|
||||
**FAQ (`sm-faq-plugin`).**
|
||||
- **Navigation:** top-level "FAQ", icon `circle-question-mark`, order 500, side menu "FAQs" (PHP parity).
|
||||
- **List:** the PHP columns `id`, `name`, `slug`, `is_active` (switch), `created_at` (invisible) and `updated_at`, plus a **`locale` column** (relation, `nameFrom: name`). One slug exists per language, and without the column two rows look identical. This is a recommended adaptation.
|
||||
- **Form:**
|
||||
|
||||
| Field | Type | Span |
|
||||
|-------|------|------|
|
||||
| `name` | text | left |
|
||||
| `slug` | text, `preset: name` | right |
|
||||
| `locale` | relation, `nameFrom: name`, with comment | left |
|
||||
| `is_active` | switch, with comment | right |
|
||||
| `description` | richeditor | full |
|
||||
| `content` | repeater, `prompt` = add_question, `titleFrom: question`; nested `question` (text, required) and `answer` (richeditor) | full |
|
||||
|
||||
**Menu (`sm-menu-plugin`).**
|
||||
- **Navigation:** PHP hung both screens on SiteManager's side menu, and SiteManager is out of scope. The port registers its own top-level "Menus" (icon `list`), with side items "Menus" (`list`) and "Menu entries" (`list-ordered`), permission `golem15.menu.manage_menu`. This is documented as an adaptation.
|
||||
- **Menus list:** the PHP columns. **Menu form:** `name` (left), `code` (right, `preset: name`), `css_class` (left), `is_active` (switch, right).
|
||||
- **Menu entries list:**
|
||||
- the PHP columns;
|
||||
- a `menu` list filter in place of the PHP toolbar's menu `<select>`;
|
||||
- the heading buttons `reorder` (label overridden to "Reorder entries") and `create`;
|
||||
- the PHP toolbar's "Delete selected" stays the existing bulk delete.
|
||||
- **Menu entry form:** flat, with the "Children" tab and partial removed because the reorder page replaces them (D-09):
|
||||
|
||||
| Field | Type | Span |
|
||||
|-------|------|------|
|
||||
| `menu` | dropdown | full |
|
||||
| `parent` | relation, `nameFrom: title`, `emptyOption` = `form.none` | left |
|
||||
| `type` | dropdown, D-05 options | right |
|
||||
| `title` | text | left |
|
||||
| `url` | text, with comment | right |
|
||||
| `reference` | dropdown, `dependsOn: type`, with comment | left |
|
||||
| `code` | text | right |
|
||||
| `icon` | text | left |
|
||||
| `css_class` | text | right |
|
||||
| `target` | dropdown `_self`/`_blank`, default `_self` (new) | left |
|
||||
| `is_published` | switch, default on, `context: update` (PHP parity) | right |
|
||||
|
||||
If the planner adopts RESEARCH A4 (a per-item `url_pattern` column), add `url_pattern` as text with the sitemap's `url_pattern_comment` copy, at span left, after `reference`. The remaining spans then shift to keep left/right pairs.
|
||||
|
||||
**Sitemap (`sm-sitemap-plugin`).**
|
||||
- **Settings index** (category CMS): "Sitemap" (icon `network`, links to the singleton form, §5) and "Sitemap settings" (icon `settings`, the existing `SettingsFormView`), both with permission `golem15.sitemap.access_definitions` (PHP parity).
|
||||
- **Settings form:**
|
||||
|
||||
| Field | Type | Span |
|
||||
|-------|------|------|
|
||||
| `frontend_url` | text | left |
|
||||
| `frontend_default_locale` | text | right |
|
||||
| `updates_manifest_path` | text | full |
|
||||
| `enable_priority_tiers` | checkbox, default on | full |
|
||||
| `priority_homepage`, `priority_services`, `priority_blog`, `priority_categories`, `priority_legal` | text, defaults `1.0` / `0.8` / `0.6` / `0.4` / `0.2` | auto: the 2-column grid places them in pairs |
|
||||
|
||||
Each tier comment ends with "Applies only when priority tiers are enabled." / "Działa tylko przy włączonych poziomach priorytetów." No validation is added beyond PHP, so the output stays 1:1.
|
||||
|
||||
### 7. `vue-menu-layer`
|
||||
|
||||
**Tokens** (`menu-tokens.css`, loaded before `menu-base.css`):
|
||||
- colour: `--menu-color-text`, `--menu-color-muted`, `--menu-color-bg`, `--menu-color-surface`, `--menu-color-border`, `--menu-color-accent` (defaults in §Color);
|
||||
- type: `--menu-font` (the system-ui stack of the Journal layer), `--menu-font-size: 1rem`, `--menu-line-height: 1.5`;
|
||||
- spacing: `--menu-space-1..4`;
|
||||
- radii: `--menu-radius: 4px`, `--menu-radius-panel: 8px`;
|
||||
- layout: `--menu-submenu-min-width: 14rem`, `--menu-toggle-size: 2.75rem`;
|
||||
- shadow: `--menu-shadow: 0 8px 24px rgba(31, 35, 40, 0.12)`.
|
||||
|
||||
**`MenuNav`.**
|
||||
- **Visual anchor:** the active link (`--menu-color-accent` with a 2px underline) is the only accented element. With no active link, the first top-level link leads, and the nav carries no other emphasis.
|
||||
- **Props:** `code` (required), `locale?`, `label?`, `openLabel?`, `closeLabel?`, `submenuLabel?`.
|
||||
- **Data:** `useMenu(code, { locale })`, which calls `useAsyncData` with the key `menu:<code>:<locale>` and is fetched during SSR, so the first paint has the menu.
|
||||
- **Markup:** `<nav class="menu-nav {menu.css_class}" :aria-label>`. Inside it:
|
||||
- the mobile toggle `<button class="menu-nav__toggle" aria-controls aria-expanded>`, 44×44 with three CSS bars and a visually hidden `openLabel`/`closeLabel`;
|
||||
- `<ul class="menu-nav__list">` with one `MenuNavItem` per top-level entry.
|
||||
- **Scoped slot `#item="{ entry, depth, active }"`:** lets an app render an entry its own way, for example with the API's `icon`, which the layer does not render (PHP did not either).
|
||||
- **Slot `#fallback`:** renders when the menu is missing (404), failed, or has no entries. By default nothing renders: no empty `<nav>`.
|
||||
|
||||
**`MenuNavItem` (recursive)**, one `<li class="menu-nav__item">` per entry:
|
||||
|
||||
| Entry | Rendering |
|
||||
|-------|-----------|
|
||||
| Link with a URL on the allowlist (http, https, mailto, tel, `/`, `#`), no children | `NuxtLink` for relative paths; a plain `<a>` for absolute, mailto and tel. `class="menu-nav__link {entry.css_class}"`. With `target="_blank"`, also `rel="noopener noreferrer"`. |
|
||||
| Entry with children and a URL (also list types such as `all-journal-categories`, whose stored URL and title the API keeps) | The link as above, plus a sibling `<button class="menu-nav__subtoggle" aria-expanded aria-controls>` with a CSS chevron and the visually hidden `submenuLabel` (":title" filled), then `<ul class="menu-nav__submenu" :id>` with the children. This is the WAI disclosure navigation pattern: the link still navigates. |
|
||||
| Entry with children and no URL | One `<button class="menu-nav__link menu-nav__subtoggle" aria-expanded aria-controls>` with the title and chevron, then the submenu. |
|
||||
| Leaf with no URL, or a URL outside the allowlist (defence in depth, Pitfall 15) | `<span class="menu-nav__text">` with the title; never an `href`. |
|
||||
| `divider` | `<li class="menu-nav__divider" aria-hidden="true">`: a 1px `--menu-color-border` line, horizontal in panels and a 1px × 1.25rem vertical line at the top level on desktop. |
|
||||
|
||||
**Active state.**
|
||||
- **Matching:** compare `useRoute().path` with each entry URL's path. Both are normalized: decoded, trailing slash removed except on `/`, query and hash ignored. An absolute URL counts only when its host equals `useRequestURL().host`.
|
||||
- **Exact match:** `aria-current="page"` plus `menu-nav__link--active`.
|
||||
- **Ancestors of an active entry:** `menu-nav__item--trail` (text colour only, no `aria-current`).
|
||||
|
||||
**Desktop (≥768px).**
|
||||
- The list is `display:flex; flex-wrap:wrap; align-items:center; gap: var(--menu-space-2)`. Links are `padding: var(--menu-space-2) var(--menu-space-3)`, `border-radius: var(--menu-radius)`, with `--menu-color-surface` background on hover.
|
||||
- **Submenus at depth 1:** an absolutely positioned panel below the item (`inset-inline-start: 0; top: calc(100% + var(--menu-space-2))`), `min-width: var(--menu-submenu-min-width)`, `padding: var(--menu-space-2)`, `--menu-color-bg` background, 1px `--menu-color-border` border, `--menu-radius-panel`, `--menu-shadow`.
|
||||
- **Depth 2 and deeper:** flyouts to the inline end of their parent item (`inset-inline-start: 100%; top: 0`).
|
||||
- **Opening:** a submenu opens on toggle click, and on hover for `(hover: hover)` pointers (PHP parity).
|
||||
- **Closing:** Esc (focus returns to the toggle), focus leaving the item, a pointer-down outside, and every route change.
|
||||
- **Siblings:** opening one submenu closes its open siblings.
|
||||
|
||||
**Mobile (≤767px).** The 767px breakpoint is fixed in `menu-base.css` because custom properties cannot drive media queries; an app changes it by overriding the component or the CSS.
|
||||
- The toggle shows. The list becomes a vertical panel under the bar (`position:absolute; inset-inline:0`, `--menu-color-bg`, bottom border, `--menu-shadow`), hidden until the toggle opens it.
|
||||
- Submenus expand inline as indented accordions (`padding-inline-start: var(--menu-space-3)`); there is no hover opening.
|
||||
- Links are full width with the chevron at the end.
|
||||
- The panel closes on route change and on Esc (focus returns to the toggle).
|
||||
|
||||
**Overrides.** Every visual value is a token. Every component can be replaced by a same-name file in the app. The site app's `SiteHeader.vue` keeps its bar, logo and skip link, and swaps its `MAIN_MENU` loop for `<MenuNav code="main" :label="$t('menu.label')" … />`. It may override the tokens with its `--site-*` palette.
|
||||
|
||||
### 8. `vue-faq-layer`
|
||||
|
||||
**Tokens** (`faq-tokens.css`):
|
||||
- colour: `--faq-color-text`, `--faq-color-muted`, `--faq-color-bg`, `--faq-color-surface`, `--faq-color-border`, `--faq-color-accent`;
|
||||
- type: `--faq-font`, `--faq-font-size-base: 1rem`, `--faq-line-height: 1.7`, `--faq-question-size: 1.125rem`, `--faq-title-size: 1.5rem`;
|
||||
- spacing: `--faq-space-1..4`;
|
||||
- radius: `--faq-radius: 8px`;
|
||||
- shadow: `--faq-shadow: 0 8px 24px rgba(31, 35, 40, 0.08)`.
|
||||
|
||||
**`FaqBlock`.**
|
||||
- **Visual anchor:** the 24px/600 title is read first, then the first question `summary`. When the app hides the heading (`:show-heading="false"`), the first question leads.
|
||||
- **Props:** `slug` (required), `locale?`, `headingLevel` (2 to 4, default 2), `showHeading` (default true), `showDescription` (default true), `exclusive` (default false).
|
||||
- **Data:** `useFaq(slug, { locale })`, keyed `faq:<slug>:<locale>` and fetched during SSR.
|
||||
- **Markup:** `<section class="faq-block" :aria-labelledby>`, containing:
|
||||
- the heading `<h{level} class="faq-block__title">` with the FAQ `name`, at `--faq-title-size`/600/1.25 with `margin-bottom: var(--faq-space-2)`;
|
||||
- `<div class="faq-block__description faq-html" v-html>` with the server-sanitized description, at `margin-bottom: var(--faq-space-4)`, omitted when empty;
|
||||
- `FaqList`.
|
||||
- **When it renders nothing:** on a 404, an error, or zero content items, it renders nothing (no empty heading), unless a `#fallback` slot is given.
|
||||
|
||||
**`FaqList`.**
|
||||
- **Props:** `items: {question, answer}[]`, `exclusive?`, `openFirst?` (default false).
|
||||
- **Markup:** `<div class="faq-list">` (`display:grid; gap: var(--faq-space-3)`) holding one `FaqItem` per item, in API order.
|
||||
|
||||
**`FaqItem`.**
|
||||
- **Container:** `<details class="faq-item">` with a 1px `--faq-color-border` border, `--faq-radius`, `--faq-color-bg` background, and `--faq-shadow` when `[open]`. With `exclusive`, every item in the list shares a `name` attribute, so the native exclusive accordion opens only one at a time; otherwise any number open.
|
||||
- **`<summary class="faq-item__question">`:** `display:flex; align-items:center; justify-content:space-between; gap: var(--faq-space-3)`, `padding: var(--faq-space-3) var(--faq-space-4)`, `--faq-question-size`/600/1.4, `list-style:none` (the WebKit marker hidden too).
|
||||
- A CSS chevron `::after` (`flex-shrink:0`, 0.55rem square, 2px `--faq-color-text` borders, rotated 45° and turned −135° when open, `transition: transform 150ms ease`, off under `prefers-reduced-motion`).
|
||||
- Long questions wrap (`overflow-wrap:anywhere`) and the chevron stays at the end.
|
||||
- The focus ring follows `--faq-color-accent`.
|
||||
- **`<div class="faq-item__answer faq-html" v-html>`:** the server-sanitized answer (D-17; the server sanitizes on output, RESEARCH A8, so there is no client sanitizer), with `padding: 0 var(--faq-space-4) var(--faq-space-4)`.
|
||||
|
||||
**`.faq-html` content styles** (the PHP partial's answer rules, ported):
|
||||
- `p`, `ul` and `ol` have `margin: 0 0 var(--faq-space-3)`, with the last child at margin 0;
|
||||
- `ul` is `list-style: disc` and `ol` is `list-style: decimal`, both with `padding-inline-start: 1.25rem`;
|
||||
- each `li + li` has `margin-top: var(--faq-space-2)`;
|
||||
- `a` is `--faq-color-accent`, underlined;
|
||||
- `h3` is `--faq-question-size`/600;
|
||||
- `blockquote` has a 3px `--faq-color-border` inline-start border, `padding-inline-start: var(--faq-space-3)` and `--faq-color-muted` text.
|
||||
|
||||
**Overrides.** The site app's `FaqSection.vue` keeps its section wrapper and replaces the static JSON with `<FaqBlock slug="what-mushrooms-can-give-you" />`. It may hide the block heading and keep its own (`:show-heading="false"`), and may map tokens to its `--site-*` palette.
|
||||
|
||||
---
|
||||
|
||||
## UI Considerations
|
||||
|
||||
Applicable state considerations were resolved with the ui-consideration-probe on 2026-10-11. The probe covered 11 elements:
|
||||
|
||||
| Element | Kinds |
|
||||
|---------|-------|
|
||||
| richeditor | form, interactive-control |
|
||||
| link popover | form |
|
||||
| repeater | form, list-collection |
|
||||
| dependent dropdown | form, interactive-control |
|
||||
| reorder tree | list-collection |
|
||||
| reorder heading button | interactive-control |
|
||||
| singleton form | form |
|
||||
| plugin list and form screens | list-collection, form |
|
||||
| menu nav | nav, list-collection |
|
||||
| FAQ block | list-collection, static-content |
|
||||
| sitemap.xml/xsl | static-content |
|
||||
|
||||
Every applicable pair is covered, backstopped or dismissed with a reason, and 0 are unresolved. Rows group pairs that share a resolution. The copy for empty and error rows lives in the Copywriting Contract.
|
||||
|
||||
| Category | Element(s) | Status | Resolution / Reason |
|
||||
|----------|------------|--------|---------------------|
|
||||
| empty | Richeditor | ✅ covered | An empty editor shows the bare 160px editing area under the toolbar and emits `""`; read-only shows a muted "—" |
|
||||
| loading | Richeditor | ✅ covered | While the TipTap chunk loads, a 200px `bg-skel` block with `aria-busy="true"` shows |
|
||||
| error | Richeditor (editor load) | ✅ covered | A failed chunk falls back to the HTML textarea with the `richeditor.load_failed` note, and saving still works |
|
||||
| error | Richeditor (save) | ✅ covered | A 422 uses the FormField error line and the `danger` border; sanitize removals show as the existing warning callout under the field, with the `richeditor.removed_*` copy |
|
||||
| partial | Richeditor (imported HTML outside the editor schema) | ✅ covered | The `richeditor.lossy_note` shows; the stored HTML is kept by the untouched rule and is editable in the HTML view |
|
||||
| long-text | Richeditor | ✅ covered | The editing area scrolls inside `max-h-[480px]` and the toolbar stays visible; the HTML textarea is `resize-y` |
|
||||
| populated | Richeditor untouched value | 🧪 backstop | A vitest asserts that loading HTML with `<b>`, `<div>` and attributes and saving without an edit submits the stored string byte-identical |
|
||||
| error | Link popover | ✅ covered | A URL outside the allowlist shows `richeditor.link_invalid` and is not applied |
|
||||
| empty / long-text | Link popover | ➖ dismissed | A single URL input: Apply with an empty URL is the same as Remove link, and long URLs scroll in the 44px input |
|
||||
| empty | Repeater | ✅ covered | The dashed empty block with `repeater.empty` and `repeater.empty_hint`; the add button stays below |
|
||||
| populated | Repeater | ✅ covered | Expanded rows with header (handle, index, title, move/collapse/remove) and a nested FormGrid body |
|
||||
| zero-one-many | Repeater | ✅ covered | Zero: the empty block. One: both move buttons are disabled and "Collapse all" is hidden. Many: `:n.` indexes and the "Collapse all/Expand all" toggle. `maxItems` disables add and shows the counter; `minItems` disables remove |
|
||||
| partial | Repeater rows with missing required values | ✅ covered | The server returns per-row path errors; the affected rows expand with the error chip and `border-danger`, and focus moves to the first invalid nested control |
|
||||
| error | Repeater (count limits) | ✅ covered | `repeater.max_items` and `repeater.min_items` render under the field label |
|
||||
| overflow | Repeater (many rows) | ✅ covered | Rows stack and the page scrolls; "Collapse all" reduces each row to its 48px header; collapsed rows do not mount their richeditors |
|
||||
| long-text | Repeater row title | ✅ covered | One line with `truncate`; the full text is in `title`; an empty title shows `repeater.untitled` |
|
||||
| loading | Repeater | ➖ dismissed | Rows arrive with the record in the form's existing load (FormView skeleton); the repeater has no load of its own |
|
||||
| populated | Repeater remove and reorder semantics | 🧪 backstop | A vitest asserts that removing a non-empty row asks first and an empty one does not, that the arrow keys and buttons reorder and announce `repeater.moved`, and that nothing is sent before Save |
|
||||
| empty | Dependent dropdown (sibling empty) | ✅ covered | Disabled, showing `form.dependent_waiting` |
|
||||
| loading | Dependent dropdown | ✅ covered | Disabled with `aria-busy` and `form.loading_options`; a late response for an old sibling value is discarded |
|
||||
| error | Dependent dropdown | ✅ covered | The `form.dependent_failed` alert line with a Retry button; a 422 on save shows `form.dependent_invalid` |
|
||||
| populated | Dependent dropdown | ✅ covered | Enabled options; nested references are indented by depth |
|
||||
| partial | Dependent dropdown (value no longer offered) | ✅ covered | On a sibling change the value clears and is announced; a stored stale value shows the placeholder, never a wrong option |
|
||||
| empty | Dependent dropdown (type without references, or zero records) | ✅ covered | A type without references: disabled with `form.dependent_not_used`. Zero records: disabled with `form.dependent_empty`, which names the type and gives the next step (add a record or choose another type). In both cases the value is sent as `null` |
|
||||
| overflow / long-text | Dependent dropdown (many or long references) | ✅ covered | A native `<select>`: the browser list scrolls and type-ahead jumps to an option; long labels are clipped by the native control and shown in full in the open list |
|
||||
| loading | Reorder tree | ✅ covered | 5 skeleton rows with `aria-busy` on the card |
|
||||
| error | Reorder tree (load, save, stale, 403) | ✅ covered | The load-failure alert block; a save failure reverts and shows the `reorder.failed` toast; a stale set shows `reorder.stale` and reloads; a 403 shows the `form.forbidden` alert |
|
||||
| empty | Reorder tree | ✅ covered | The empty block with `reorder.empty` and `reorder.empty_hint` inside the card |
|
||||
| populated | Reorder tree | ✅ covered | Indented rows with handle, name and four move buttons, ordered by `sort_order` per parent |
|
||||
| zero-one-many | Reorder tree | ✅ covered | Zero: the empty block. One: all move buttons are disabled. Many: rows separated by `border-t`, with no pagination |
|
||||
| overflow | Reorder tree (deep or long) | ✅ covered | The page scrolls; the indent grows by 24px per level and the name column (`min-w-0 flex-1`) wraps, so deep levels stay readable |
|
||||
| long-text | Reorder node names | ✅ covered | Names wrap with `[overflow-wrap:anywhere]` inside the `min-h-row` row; an empty name shows `reorder.untitled` |
|
||||
| partial | Reorder (divider entries without a title) | ✅ covered | They show `reorder.untitled` with the id in `text-muted` |
|
||||
| populated | Reorder save contract | 🧪 backstop | A vitest asserts that three quick moves send one debounced POST with the full node set, that a failed POST restores the previous tree, and that each keyboard move announces in the live region |
|
||||
| loading / error | Reorder heading button | ➖ dismissed | It is a plain router link with no async state; it renders only when the list config declares `reorder` |
|
||||
| loading / error | Singleton form | ✅ covered | The existing FormView skeleton and `form.load_failed` alert; the server creates the record on first open, so there is no "not found" state in the admin |
|
||||
| empty | Singleton form (new definition) | ✅ covered | A first-time definition shows the repeater empty block with `golem15.sitemap::lang.item.add_item` as the next step |
|
||||
| empty / loading / error / populated / overflow | Plugin list and form screens | ➖ dismissed | They render through the unchanged ListView, FormView and SettingsFormView, whose states are covered by earlier phases; only the new field types above add states |
|
||||
| loading | Menu nav | ✅ covered | Fetched during SSR, so the first paint has the menu; client navigation reuses the keyed `useAsyncData` result, so there is no loading flash |
|
||||
| error / empty | Menu nav | ✅ covered | 404, an error or zero entries render nothing, or the app's `#fallback` slot; there is never an empty `<nav>` |
|
||||
| populated | Menu nav | ✅ covered | Desktop horizontal list with dropdown panels and flyouts; mobile toggle panel with inline accordions; active link and trail per the active-state rule |
|
||||
| zero-one-many | Menu nav (children per entry) | ✅ covered | No children: a plain link. Children: a disclosure toggle plus a submenu. Any depth: recursive, with depth ≥2 as flyouts on desktop and indented on mobile |
|
||||
| overflow | Menu nav (many top-level entries) | ✅ covered | The desktop list uses `flex-wrap` onto a second line; panels use `min-width` and grow with content; the mobile panel scrolls with the page |
|
||||
| long-text | Menu titles | ✅ covered | Top-level links wrap within their padding; submenu links wrap inside the panel (`overflow-wrap:anywhere`) |
|
||||
| error | Menu entry URL scheme | 🧪 backstop | A layer node test asserts that `javascript:` and `data:` URLs render as `<span class="menu-nav__text">` with no `href`, and that `_blank` links carry `rel="noopener noreferrer"` |
|
||||
| loading | FAQ block | ✅ covered | Fetched during SSR, keyed per slug and locale |
|
||||
| error / empty | FAQ block | ✅ covered | 404, an error or zero items render nothing, or the `#fallback` slot; there is never an empty heading |
|
||||
| populated | FAQ block | ✅ covered | Heading, sanitized description, then native `<details>` items, all closed by default (`openFirst` opt-in) |
|
||||
| zero-one-many | FAQ items | ✅ covered | One item renders as a single `<details>`; many stack with `--faq-space-3` gaps; `exclusive` makes them one-at-a-time |
|
||||
| long-text | FAQ questions and answers | ✅ covered | Questions wrap with the chevron pinned at the end; answers flow with the `.faq-html` list and paragraph rules |
|
||||
| populated | FAQ HTML safety | 🧪 backstop | A Go API test (sm-faq-plugin) asserts that `description` and every `answer` are sanitized on output even for rows copied straight into the DB (Pitfall 10) |
|
||||
| empty / populated | sitemap.xml / sitemap.xsl | ➖ dismissed | No new visual design: the XSL is byte-identical to PHP, and the XML output is pinned by PHP golden tests (RESEARCH Pattern 4); an empty definition answers an empty `application/xml` body as in PHP |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | none (shadcn not used) | not applicable |
|
||||
| third-party registries | none | not applicable |
|
||||
| npm dependencies, admin SPA | `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, all 3.31.4 exact | RESEARCH Package Legitimacy Audit (2026-10-11): `npm view` shows no `postinstall` script on any of them, MIT licence, and source at github.com/ueberdosis/tiptap. They are flagged [SUS] only for "too-new version", on a long-established package; this exact version was already approved by a human package check for Journal in Phase 16.2. The planner adds a `checkpoint:human-verify` before `npm --prefix admin install`. |
|
||||
| npm dependencies, layers | none beyond `nuxt` 4.4.8 / `vue` 3.5.35 / `typescript` 5.9.3 (dev, the same pins as `vue-journal-layer`) | not applicable. No `sanitize-html` and no UI library (RESEARCH A8, Pattern 10). |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [x] Dimension 1 Copywriting: PASS
|
||||
- [x] Dimension 2 Visuals: PASS
|
||||
- [x] Dimension 3 Color: PASS
|
||||
- [x] Dimension 4 Typography: FLAG (700 limited to the inherited h1; accepted)
|
||||
- [x] Dimension 5 Spacing: PASS
|
||||
- [x] Dimension 6 Registry Safety: PASS
|
||||
- [x] Dimension 7 Inventory Provenance: PASS
|
||||
|
||||
**Approval:** approved 2026-10-11
|
||||
Reference in New Issue
Block a user