docs(10.1): UI design contract
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
---
|
||||
phase: "10.1"
|
||||
slug: "runtime-admin-extension-point"
|
||||
status: draft
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: "2026-09-28"
|
||||
---
|
||||
|
||||
# Phase 10.1 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for frontend phases. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
|
||||
|
||||
**Scope of this contract.** Phase 10.1 adds four new surfaces to the Phase 10 admin SPA. It does not restyle anything that already exists.
|
||||
|
||||
1. A **list header partial host**: a server-rendered strip between the list heading and the list card.
|
||||
2. A **form `type: partial` host**: a server-rendered block inside a form row.
|
||||
3. A **form `type: widget` host**: it mounts a plugin custom element in a form row.
|
||||
4. **Custom toolbar action buttons** in the list toolbar.
|
||||
|
||||
Each surface has a framework contract, which is app-agnostic and lives in `summercms.go`. The application proof lives in `fonoteka.go` (Albums stats strip, Discogs lookup widget, Discogs sync toolbar action). All framework copy below is neutral. Application copy appears only in the "Application proof" rows.
|
||||
|
||||
**Design source of truth:** `.planning/phases/10-admin-vue-spa/design/README.md` (Direction C v2) and the tokens already in `admin/src/styles/main.css`. This contract reuses those tokens and adds none, except the partial style kit classes defined under Color/Spacing below. Those classes read only existing `--c-*` variables.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | In-repo Direction C component set (Vue 3.5.35 SFCs + Tailwind 4.3.0 `@theme` tokens). shadcn is not applicable because this is a Vue project, not React/Next. No preset. |
|
||||
| Preset | not applicable |
|
||||
| Component library | reka-ui 2.9.10 (headless primitives, used by ConfirmDialog, RelationPickerModal and UserMenu). No new primitives in this phase. |
|
||||
| Icon library | `@lucide/vue` 1.17.0, with named imports only. The framework host components use `CircleAlert` (failure box) and nothing else new. Plugin custom elements cannot import lucide and ship **no icons** in v1. |
|
||||
| Font | DM Sans 400/500/600/700 and DM Mono 400/500 (self-hosted `@fontsource`). Base is 14px / 1.5. Plugin elements inherit `font-family` from the host. Buttons inside them must set `font: inherit`. |
|
||||
| npm changes | **None.** T-10-SC exact-pin posture unchanged (source: RESEARCH §Standard Stack) |
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
Enumerated by `find admin/src/components -name '*.vue' | sort` — 29 components — summercms-admin (in-repo, `admin/package.json`, commit e51be35; reka-ui@2.9.10, @lucide/vue@1.17.0 resolved from `admin/node_modules`) — 2026-09-28.
|
||||
|
||||
This is a **non-exhaustive** list of known-good components, not a closed allowlist. It shows the rows this phase reuses or extends. New components this phase adds are listed separately below.
|
||||
|
||||
| Component | Import path | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| Button | `admin/src/components/ui/Button.vue` | Custom toolbar actions use `variant="outline"` at size `md` (42px). The `primary` variant is **not** used by any new surface. |
|
||||
| Toast | `admin/src/components/ui/Toast.vue` (queue: `admin/src/state/useToasts.ts`) | All action feedback. Tones are only `success` (role=status) and `danger` (role=alert). About 4s auto-dismiss. |
|
||||
| ListToolbar | `admin/src/components/list/ListToolbar.vue` | Extended: it renders registered custom action names in declared order after the built-ins. |
|
||||
| FormField | `admin/src/components/form/FormField.vue` | Extended: `widget` and `partial` rows render a visible label as `<span id>` (not `<label for>`), and the host is a `role="group"` labelled by it. |
|
||||
| FieldRenderer / registry | `admin/src/components/form/FieldRenderer.vue`, `registry.ts` | Register `widget` → WidgetField and `partial` → PartialField. They are valueless: never in the save body, and they render on create. |
|
||||
| UnsupportedField | `admin/src/components/form/fields/UnsupportedField.vue` | The geometry source for the new extension failure box (dashed 1.5px, subtle bg, min-h 44, radius 10). |
|
||||
| DataTable / Pagination / FilterBar | `admin/src/components/list/*.vue` | Unchanged. The header partial sits **above** the card that holds them. |
|
||||
| ConfirmDialog | `admin/src/components/ui/ConfirmDialog.vue` | Not used by any new surface. There are no new destructive actions. |
|
||||
|
||||
**New components (this phase, framework):**
|
||||
|
||||
| Component | Path | Purpose |
|
||||
|-----------|------|---------|
|
||||
| PartialHost | `admin/src/components/partial/PartialHost.vue` | Renders the server node tree with Vue `h()` and a client allowlist re-check (D-17). Owns the loading, error and empty states for both partial surfaces. |
|
||||
| PartialField | `admin/src/components/form/fields/PartialField.vue` | Form row wrapper around PartialHost for `type: partial`. |
|
||||
| WidgetField | `admin/src/components/form/fields/WidgetField.vue` | Loads plugin assets, waits for `customElements.whenDefined`, mounts the element imperatively, and bridges `summer-action` to a POST, the fill patch and a toast. |
|
||||
| pluginAssets | `admin/src/app/pluginAssets.ts` | Idempotent `<script type=module>` / `<link rel=stylesheet>` loader, scoped per controller (D-14). |
|
||||
| formContext | `admin/src/components/form/formContext.ts` | `InjectionKey`s for read-only form values and `patch(name, value)`. |
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values (multiples of 4) used by the new surfaces:
|
||||
|
||||
| Token | Value | Usage in this phase |
|
||||
|-------|-------|---------------------|
|
||||
| xs | 4px | Gap between a stat label and its value |
|
||||
| sm | 8px | Row gap when stat items wrap. Gap between the failure-box icon and text is 10px (inherited, see exceptions) |
|
||||
| md | 16px | Stats strip vertical padding. Widget button horizontal padding. Loading skeleton inner spacing |
|
||||
| lg | 24px | Form grid column gap (inherited) |
|
||||
| xl | 32px | Column gap between stat items in the strip |
|
||||
| 2xl | 48px | Not used by new surfaces |
|
||||
| 3xl | 64px | Not used by new surfaces |
|
||||
|
||||
Control heights (inherited Phase 10 tokens, not new): button/search `h-button` 42px; inputs and the failure-box min height `min-h-input` 44px.
|
||||
|
||||
Exceptions (all inherited from Phase 10 design, not introduced here):
|
||||
- **20px**: the vertical gap between page blocks (`gap-5`) that separates the heading, header partial and list card. Also the horizontal padding of the stats strip, which matches the list toolbar's `px-5`.
|
||||
- **10px**: the gap between buttons in the ListToolbar right cluster (`gap-2.5`). Custom action buttons sit inside that existing cluster. The failure-box icon-to-text gap (`gap-2.5`) copies UnsupportedField.
|
||||
- **14px**: failure-box horizontal padding (`px-3.5`), copied verbatim from UnsupportedField so the two boxes are indistinguishable in geometry.
|
||||
- **6px**: the FormField label → control gap (`gap-1.5`, existing row chrome).
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
New surfaces use exactly **3 sizes** and **2 weights**. Pre-existing chrome (page title 26/700, record title 24/700, and so on) is unchanged and outside this contract.
|
||||
|
||||
| Role | Size | Weight | Line Height | Used by |
|
||||
|------|------|--------|-------------|---------|
|
||||
| Body | 14px | 400 | 1.5 | Partial root text (`.summer-partial`) and form partial content |
|
||||
| Label | 14px | 600 | 1.5 | FormField label for widget/partial rows (existing style). Custom toolbar button text. Widget button text |
|
||||
| Meta | 13px | 400 | 1.5 | Stat labels (muted), failure-box text (muted), field comment under a widget |
|
||||
| Stat value | 20px | 600 | 1.2 | `.summer-stat__value` numbers, with `font-variant-numeric: tabular-nums` |
|
||||
|
||||
Weights used: **400** and **600** only. Stat labels are 13/400 muted, not bold: the contrast comes from the colour and the 20px value above it. Numbers are rendered as plain integers with no thousands grouping in v1, so the view model passes ints.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
All values are existing `--c-*` tokens. Light / dark pairs come from `main.css`.
|
||||
|
||||
| Role | Value | Usage |
|
||||
|------|-------|-------|
|
||||
| Dominant (60%) | `--c-bg` `#f4f6f9` / `#111726` | Page background around the stats strip and list card |
|
||||
| Secondary (30%) | `--c-surface` `#ffffff` / `#182033`, border `--c-border` `#e6e9ef` / `#29334b` | Stats strip card, widget button fill, partial host backgrounds (transparent inside the form card) |
|
||||
| Accent (10%) | `--c-ring` `rgba(252,196,40,.55)` / `rgba(252,211,77,.45)` for focus; `--c-primary` `#22304d` / `#fcd34d` for primary buttons | See the reserved list below |
|
||||
| Destructive | `--c-danger` `#c62828` / `#f58a8a` | Failure-box icon, danger toasts, the existing delete button only |
|
||||
| Muted text | `--c-muted` `#566175` / `#a9b3c6` | Stat labels, failure-box text, comments |
|
||||
| Skeleton | `--c-skel` `#eceff4` / `#263049` | Loading placeholders in both partial hosts and the widget host |
|
||||
|
||||
Accent reserved for:
|
||||
- the 3px focus ring on every new interactive element (custom toolbar buttons, the widget's inner button, links inside partials);
|
||||
- the existing heading "create" button and the form "Save" button, which are unchanged.
|
||||
|
||||
No new surface uses `primary` fill or brand yellow. Custom toolbar actions and widget buttons are **outline** (surface fill, `--c-border-strong` border, `--c-text` text, `--c-hover` on hover). Stat values use `--c-text`, not accent.
|
||||
|
||||
**Public CSS variables for plugin authors (contract).** Plugin CSS (`AdminCSS`) and any widget shadow DOM may read only these variables. They inherit into shadow roots and swap automatically in dark mode: `--c-bg`, `--c-surface`, `--c-subtle`, `--c-border`, `--c-border-strong`, `--c-text`, `--c-muted`, `--c-placeholder`, `--c-primary`, `--c-on-primary`, `--c-danger`, `--c-danger-soft`, `--c-hover`, `--c-sel`, `--c-skel`, `--c-ring`. Plugins must not hardcode hex colours and must not rely on Tailwind utility classes, which are purged from the build. The framework README for cabana documents this list.
|
||||
|
||||
### Partial style kit (framework, `admin/src/styles/main.css` `@layer components`)
|
||||
|
||||
Plain CSS classes, stable by contract, which partial templates may use through the allowlisted `class` attribute so server-rendered HTML looks native without plugin CSS:
|
||||
|
||||
| Class | Rules |
|
||||
|-------|-------|
|
||||
| `.summer-partial` | Applied by PartialHost to its root. 14px/1.5, `color: var(--c-text)`. `p`, `ul` and `ol` get `margin: 0 0 8px`, and the last child gets `margin-bottom: 0`. `a` uses `color: var(--c-text)`, underline, and the focus ring. |
|
||||
| `.summer-stats` | Card: `background: var(--c-surface)`, 1px `var(--c-border)`, radius 16px, `box-shadow: var(--c-shadow-card)`, padding 16px 20px. `display: flex; flex-wrap: wrap; column-gap: 32px; row-gap: 8px`. Margin 0 (safe on `<dl>`). |
|
||||
| `.summer-stat` | `display: flex; flex-direction: column-reverse; gap: 4px; min-width: 0`. The value sits above the label visually while `<dt>` stays first in the DOM. |
|
||||
| `.summer-stat__label` | 13px/1.5, weight 400, `color: var(--c-muted)`, `margin: 0`, wraps (no truncation). |
|
||||
| `.summer-stat__value` | 20px/1.2, weight 600, `color: var(--c-text)`, `font-variant-numeric: tabular-nums`, `margin: 0`. |
|
||||
|
||||
Recommended markup (framework docs and the neutral fixture). Semantics come from `dl`/`dt`/`dd`, all allowlisted:
|
||||
|
||||
```html
|
||||
<dl class="summer-stats">
|
||||
<div class="summer-stat"><dt class="summer-stat__label">{{ trans "…" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd></div>
|
||||
</dl>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Surface Contracts
|
||||
|
||||
### S1. List header partial host (`config_list.yaml` `headerPartial`)
|
||||
|
||||
- **Position:** inside the ListView `<section class="flex flex-col gap-5">`, between `<header>` (title + create) and the list card. There is no framework card chrome around it. The partial supplies its own via `.summer-stats` or plugin CSS.
|
||||
- **Loading (first fetch):** one skeleton block, full width, 80px tall, radius 16px, `bg-skel`, `aria-hidden="true"`. The host has `aria-busy="true"`. 80px is the single-row strip height (16 + 20×1.2 + 4 + 13×1.5 + 16 = 79.5, plus 2px of border, rounded to the 4px grid), so a one-row strip causes no layout shift.
|
||||
- **Refetch:** runs after a successful bulk delete and after a successful custom toolbar action. The previous nodes stay visible, with no skeleton flash, and `aria-busy="true"` is set during the fetch. It does **not** refetch on search, filter, sort or page changes, because header partials describe the whole scoped collection, not the filtered view.
|
||||
- **Empty (the server returns zero nodes):** the host renders nothing and takes no gap.
|
||||
- **Error:** the extension failure box (see S5) at full width, with the text `backend::lang.extension.partial_failed`. The list below still renders and works.
|
||||
- **No live region.** Number changes are not announced; the toast from the triggering action is the announcement.
|
||||
|
||||
### S2. Form `type: partial` host
|
||||
|
||||
- Rendered inside the standard FormField row. If the field has `label`, it shows as a `<span>` label (14/600) and the host is `role="group"` + `aria-labelledby`. With no label, there is no label row.
|
||||
- The content root has class `.summer-partial` and no border or background (it sits on the form card surface).
|
||||
- **Loading:** a skeleton bar 44px tall, radius 10px, `bg-skel`.
|
||||
- **Error:** the extension failure box with `backend::lang.extension.partial_failed`. The rest of the form stays usable and saveable.
|
||||
- **Empty:** the row renders its label (if any) and nothing else, with no placeholder text.
|
||||
- It renders on **create and update**. On create the controller view model receives a nil record.
|
||||
|
||||
### S3. Form `type: widget` host
|
||||
|
||||
- **Row chrome:** FormField renders the visible label as `<span id="{base}-label" class="font-semibold">`. The host `<div id="{base}" role="group" aria-labelledby="{base}-label" aria-describedby="{comment/error ids}" class="flex min-h-input items-center">` is a node Vue never renders children into. The comment (13px muted) shows under it as for any field.
|
||||
- **Loading (assets plus `whenDefined`, 5000ms timeout):** a skeleton bar 42px × 160px, radius 10px, `bg-skel`, and the group has `aria-busy="true"`.
|
||||
- **Mounted:** the SPA creates the element imperatively and sets **attributes only**:
|
||||
|
||||
| Attribute | Value | Source |
|
||||
|-----------|-------|--------|
|
||||
| `record-id` | the record id, or `""` on create | D-08 |
|
||||
| `field-name` | the YAML field name | D-08 |
|
||||
| `locale` | `schema.meta.locale` | D-08 |
|
||||
| `fill-values` | JSON of the current values for `field.fill`, kept in sync with `watch` | D-08 |
|
||||
| `label` | the localized `AdminAction.Label` of the widget's `action` | discretion (RESEARCH Open Question 4): keeps all copy in the phrasebook, and the plugin JS carries no strings |
|
||||
| `busy-label` | `backend::lang.extension.busy`, localized | discretion |
|
||||
| `busy` | present while the POST runs, removed after | RESEARCH Pattern 6 |
|
||||
| `state` | `"error"` after a failed POST, removed on the next attempt | RESEARCH Pattern 6. The element may style it, but the toast is the required error surface |
|
||||
|
||||
- **Event → POST:** `summer-action` (bubbles, composed). Repeat events are ignored while `busy` is set. On success the SPA patches **only** the keys in `field.fill` that are present in `result.fill`, marks the form dirty, clears those fields' error messages (the existing "errors clear per field on change" rule), and shows `result.message` as a success toast. **Nothing is saved.** The user saves with the normal Save button, and the existing unsaved-changes guard covers Cancel.
|
||||
- **POST failure:** a danger toast with the server `error.message` if present, else `backend::lang.extension.action_failed`, and `state="error"` on the element. The form values are untouched.
|
||||
- **Load failure or timeout:** the element is not mounted and the host shows the extension failure box with `backend::lang.extension.widget_failed`.
|
||||
- It renders on **create and update**. A widget whose action needs a record must handle `record-id=""` itself (discretion resolved: render on both).
|
||||
|
||||
**Plugin element visual contract** (a recommendation the framework docs repeat; the application proof must follow it). This is the default look for a single-button widget, matching `Button.vue` outline:
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| DOM | Light DOM `<button type="button">` child, styled by the plugin's `AdminCSS` under the tag selector. Shadow DOM is allowed but then needs its own constructed stylesheet. |
|
||||
| Box | height 42px, padding 0 16px, `min-width: 160px`, radius 10px, 1px `var(--c-border-strong)`, `background: var(--c-surface)`, `color: var(--c-text)` |
|
||||
| Text | `font: inherit; font-weight: 600` (14/600), `white-space: nowrap` |
|
||||
| Hover | `background: var(--c-hover)`, 150ms ease-out |
|
||||
| Focus | `:focus-visible { outline: 3px solid var(--c-ring); outline-offset: 2px }` |
|
||||
| Busy | `disabled`, `aria-busy="true"`, text swapped to the `busy-label` attribute, `opacity: .6`, `cursor: not-allowed`. The width stays fixed thanks to `min-width`. |
|
||||
| Icons | none |
|
||||
|
||||
### S4. Custom toolbar action buttons
|
||||
|
||||
- **Placement:** in the ListToolbar right cluster (10px gap), in the **declared order** of `toolbar.buttons` after removing `create` (which stays in the heading). For `[create, delete, discogsSync]` the cluster reads: selection pill (when rows are selected), then Delete, then the custom action.
|
||||
- **Style:** `Button variant="outline" size="md"`, text only (no icon in v1), label = localized `AdminAction.Label`, `data-action="{name}"`.
|
||||
- **Permission:** an action the user may not run is **not rendered** (it is filtered server-side in `schema.toolbarActions`). It is never shown disabled, which follows the Phase 10 navigation rule.
|
||||
- **Selection:** ignored. Custom actions carry no record ids in v1, so they are always enabled, whatever the selection.
|
||||
- **Click:** the button is `disabled` and `aria-busy="true"` while the POST runs, and the label stays unchanged. On 200, a success toast shows `result.message`, then the list reloads and the header partial refetches. On error, a danger toast shows the server message or `backend::lang.extension.action_failed`. There is no confirmation dialog: none of the actions in this phase are destructive.
|
||||
|
||||
### S5. Extension failure box (shared)
|
||||
|
||||
Same geometry as UnsupportedField: `flex min-h-input items-center gap-2.5 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 text-[13px] text-muted`. It has a `CircleAlert` 16px icon in `text-danger` with `aria-hidden`, then the text, and the container has `role="alert"`. It is used by S1 (full width), S2 and S3.
|
||||
|
||||
### S6. Plugin assets (no visual surface, but observable behaviour)
|
||||
|
||||
- The controller's CSS `<link>` elements are `disabled` whenever another controller is active (D-14). The acceptance check is that no CSS from another plugin bleeds into this view.
|
||||
- Assets start loading when the list or form schema arrives. Widgets wait on the shared promise. The list page never waits for plugin JS before rendering its table.
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
Every string is a phrase key with `en` and `pl` entries. There is no hardcoded copy in `admin/src` or in plugin JS.
|
||||
|
||||
### Framework (`modules/phrasebook/backend/lang/{en,pl}/lang.yaml`, new `extension:` group)
|
||||
|
||||
| Key | en | pl |
|
||||
|-----|----|----|
|
||||
| `backend::lang.extension.busy` | Loading… | Wczytywanie… |
|
||||
| `backend::lang.extension.widget_failed` | This control could not be loaded. Refresh the page; if it happens again, check the plugin's assets. | Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki. |
|
||||
| `backend::lang.extension.partial_failed` | This section could not be loaded. Refresh the page to try again. | Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie. |
|
||||
| `backend::lang.extension.action_failed` | The action could not be completed. Please try again. | Nie udało się wykonać akcji. Spróbuj ponownie. |
|
||||
|
||||
### Application proof (`fonoteka.go` plugin `lang/{en,pl}/lang.yaml`)
|
||||
|
||||
| Element | Key | en | pl |
|
||||
|---------|-----|----|----|
|
||||
| Primary CTA (widget button, `discogsLookup` action label) | `golem15.fonoteka::lang.discogs.lookup_button` | Load from Discogs | Wczytaj z Discogs |
|
||||
| Widget field label | `golem15.fonoteka::lang.discogs.lookup_label` | Discogs | Discogs |
|
||||
| Widget field comment | `golem15.fonoteka::lang.discogs.lookup_comment` | Fills in Release year and Format. Click Save to keep them. | Uzupełnia Rok wydania i Format. Kliknij Zapisz, aby je zachować. |
|
||||
| Widget success toast (stub) | `golem15.fonoteka::lang.discogs.stub_filled` | Release year and Format were filled with test data. Save to keep them. | Uzupełniono Rok wydania i Format danymi testowymi. Zapisz, aby je zachować. |
|
||||
| Toolbar action label (`discogsSync`) | `golem15.fonoteka::lang.discogs.sync_button` | Sync with Discogs | Synchronizuj z Discogs |
|
||||
| Toolbar success toast (stub) | `golem15.fonoteka::lang.discogs.stub_not_implemented` | Discogs sync is in test mode. Nothing was changed. | Synchronizacja z Discogs działa w trybie testowym. Nic nie zmieniono. |
|
||||
| New form field `year` | `golem15.fonoteka::lang.item.year` | Release year | Rok wydania |
|
||||
| Stat: total | `golem15.fonoteka::lang.stats.total` | All albums | Wszystkie albumy |
|
||||
| Stat: per format | existing `golem15.fonoteka::lang.album_format.*` | LP, 2LP, CD, 2CD, Cassette (MC), Box, EP 7" | (existing pl values) |
|
||||
| Stat: no shelf | `golem15.fonoteka::lang.stats.no_shelf` | No shelf | Bez półki |
|
||||
|
||||
| Contract element | Copy |
|
||||
|------------------|------|
|
||||
| Primary CTA | **Load from Discogs / Wczytaj z Discogs** (widget button). This phase adds no primary-filled button. |
|
||||
| Empty state heading | None new. The header partial with zero nodes collapses silently. An empty collection still renders the strip with "All albums 0" and no format items. The list's existing empty state ("No records") is unchanged. |
|
||||
| Empty state body | Not applicable, see above. |
|
||||
| Error state | Partial: "This section could not be loaded. Refresh the page to try again." Widget: "This control could not be loaded. Refresh the page; if it happens again, check the plugin's assets." Action: server message, else "The action could not be completed. Please try again." |
|
||||
| Destructive confirmation | **None in this phase.** The widget fill overwrites unsaved form values without asking. That is not destructive: nothing persists until Save, and Cancel's existing unsaved-changes confirm covers reverting. |
|
||||
|
||||
### Application proof: Albums layout decisions (discretion resolved)
|
||||
|
||||
- **Stats strip items**, in order: the total ("All albums"), then one item per stored `format` value with count > 0 in `getFormatOptions` order (LP, 2LP, CD, 2CD, MC, Box, EP 7"), then "No shelf" only when its count > 0. All counts go through `scopeAlbums` (active collection). The template is `controllers/albums/_stats.htm` and uses only the partial style kit classes, with no plugin CSS.
|
||||
- **Widget field** `discogs`: `type: widget`, `widget: golem15-fonoteka-discogs-lookup`, `action: discogsLookup`, `fill: [year, format]`, `span: full`, and the comment key above. It is placed last on the form.
|
||||
- **New field** `year`: `type: number`, `span: right`, placed directly after `shelf` (left), so shelf and year share a row. Stub fill payload: `{year: 1977, format: "LP"}`. `"LP"` is a valid `getFormatOptions` value.
|
||||
- **Plugin CSS** `assets/css/albums.css`: styles only `golem15-fonoteka-discogs-lookup button` per the S3 element visual contract. It uses only public `--c-*` variables. **Plugin JS** `assets/js/discogs-lookup.js` holds no user-facing strings (the text comes from the `label` / `busy-label` attributes) and no `fetch`, `XMLHttpRequest` or `document.cookie`.
|
||||
|
||||
---
|
||||
|
||||
## Interaction & Accessibility Rules
|
||||
|
||||
- Focus: every new interactive element shows the 3px ring. The framework hosts inherit it from `main.css`; plugin elements must declare `:focus-visible` themselves (S3 table).
|
||||
- Keyboard: widget and toolbar buttons are native `<button type="button">`, so Enter and Space work without extra handling. Links inside partials are limited by the allowlist to same-origin `/…` or `#…`.
|
||||
- Motion: no new animation. Hover uses the 150ms ease-out colour change. Skeletons are static, with no shimmer.
|
||||
- Semantics: partial hosts never inject `id` or `style`, and never use raw-HTML sinks (D-17). The header strip is `<dl>`-based, and form partial and widget rows are `role="group"` labelled by their visible label.
|
||||
- Dark mode: every new surface reads `--c-*` variables only. Toggling `.dark` on `<html>` restyles plugin CSS and partials with no reload.
|
||||
|
||||
---
|
||||
|
||||
## UI Considerations
|
||||
|
||||
Applicable state considerations resolved: 17 covered, 3 backstop, 0 unresolved.
|
||||
|
||||
| Category | Element(s) | Status | Resolution / Reason |
|
||||
|----------|------------|--------|---------------------|
|
||||
| loading | S1 header partial | ✅ covered | The first fetch renders one 80px radius-16 `bg-skel` block with `aria-busy="true"` on the host. Refetches keep the prior nodes visible. |
|
||||
| empty | S1 header partial | ✅ covered | Zero server nodes means the host renders nothing and adds no gap. |
|
||||
| error | S1 header partial | ✅ covered | A full-width extension failure box with `backend::lang.extension.partial_failed` (see Copywriting Contract). The list stays functional. |
|
||||
| populated | S1 Albums stats strip | ✅ covered | One `.summer-stats` card: the total, then formats with count > 0 in option order, then "No shelf" when > 0, each as 13px muted label below a 20/600 value. |
|
||||
| zero-one-many | S1 Albums stats strip | ✅ covered | With 0 albums the strip shows "All albums 0" and no format items. Counts render as plain integers and labels are not pluralised, so 0, 1 and many share one layout. |
|
||||
| overflow | S1 stats strip items | ✅ covered | `flex-wrap` with a 32px column gap and 8px row gap. Items wrap to a second row inside the same card and never scroll horizontally. |
|
||||
| long-text | S1 stat labels | 🧪 backstop | Labels wrap (no truncation) inside `min-width: 0` items. A visual check with the longest pl label ("Cassette (MC)" / "Bez półki") at 768px width is required. |
|
||||
| overflow | S1/S2 partial output | ✅ covered | Output over 64 KiB, 2000 nodes or depth 32 is a server 500, so the host shows the partial_failed box and never a truncated render. |
|
||||
| loading | S2 form partial | ✅ covered | A 44px radius-10 `bg-skel` bar in the row until the nodes arrive. |
|
||||
| error | S2 form partial | ✅ covered | An extension failure box with partial_failed in the row. The form stays saveable. |
|
||||
| empty | S2 form partial | ✅ covered | The row shows only its label (if declared). No placeholder copy. |
|
||||
| partial | S2/S3 on create | ✅ covered | Both render on create. The partial view model gets a nil record and the widget gets `record-id=""`. |
|
||||
| loading | S3 widget host | ✅ covered | A 42×160 radius-10 `bg-skel` bar with `aria-busy` on the group until `whenDefined` resolves (5000ms timeout). |
|
||||
| error | S3 widget load | ✅ covered | On a script error or timeout the element is not mounted and the widget_failed failure box is shown. |
|
||||
| error | S3 widget action POST | ✅ covered | A danger toast (server message or action_failed) and `state="error"`. Form values are untouched. |
|
||||
| loading | S3 widget action in flight | ✅ covered | The `busy` attribute makes the element disable its button and show the busy-label text. Repeat `summer-action` events are ignored. |
|
||||
| form | S3 fill write-back | ✅ covered | Only `field.fill` keys present in `result.fill` are patched. The form becomes dirty, those fields' errors clear, and nothing saves until Save. |
|
||||
| long-text | S3 widget button / S4 toolbar button | 🧪 backstop | Labels are `white-space: nowrap`. The toolbar cluster wraps with the existing `flex-wrap`, and the widget button grows past its 160px min-width. Needs a visual check at 768px with the pl labels. |
|
||||
| loading / error | S4 custom toolbar action | ✅ covered | Disabled with `aria-busy` during the POST. Success toast, then list reload and partial refetch. Failure gives a danger toast. |
|
||||
| error | S6 CSS bleed across controllers | 🧪 backstop | Links of inactive controllers get `disabled`. `pluginAssets.test.ts` covers the toggle, and a real-browser UAT (A4) confirms that module scripts load under CSP `script-src 'self'`. |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | none (not applicable: Vue project, no shadcn) | not required |
|
||||
| third-party | none | not applicable. No npm packages added this phase (RESEARCH Package Legitimacy Audit, 2026-09-28) |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
| Source | Decisions used |
|
||||
|--------|----------------|
|
||||
| 10.1-CONTEXT.md | D-01, D-02, D-03, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-14, D-16, D-17, and the discretion list |
|
||||
| 10.1-RESEARCH.md | Pattern 2 (YAML keys), Pattern 4 (node tree host), Pattern 5 (asset layout), Pattern 6 (loader, attributes, `summer-action`, 5s timeout), Open Questions 1, 3 and 4, Pitfalls 4 and 14 |
|
||||
| Phase 10 design/README.md + `admin/src/styles/main.css` | All tokens, control heights, radii, Button/Toast/UnsupportedField geometry, the permission-filter rule |
|
||||
| Codebase scan | FormField/registry label handling, ListToolbar/ListView structure, toast tones, fonoteka `getFormatOptions`, existing `discogs:` and `album_format:` lang groups |
|
||||
| Researcher defaults (no user question needed) | Partial style kit classes, the `extension:` phrase group and copy, the `label`/`busy-label` attributes, stats item selection, `year` field placement, 80px skeleton height |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
- [ ] Dimension 7 Inventory Provenance: PASS
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user