30 KiB
phase, slug, status, reviewed_at, shadcn_initialized, preset, created
| phase | slug | status | reviewed_at | shadcn_initialized | preset | created |
|---|---|---|---|---|---|---|
| 10.1 | runtime-admin-extension-point | approved | 2026-09-28 | false | none | 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.
- A list header partial host: a server-rendered strip between the list heading and the list card.
- A form
type: partialhost: a server-rendered block inside a form row. - A form
type: widgethost: it mounts a plugin custom element in a form row. - 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 |
InjectionKeys 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'spx-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:
<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-statsor plugin CSS. - Loading (first fetch): one skeleton block, full width, 80px tall, radius 16px,
bg-skel,aria-hidden="true". The host hasaria-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 isrole="group"+aria-labelledby. With no label, there is no label row. - The content root has class
.summer-partialand 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 hasaria-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 whilebusyis set. On success the SPA patches only the keys infield.fillthat are present inresult.fill, marks the form dirty, clears those fields' error messages (the existing "errors clear per field on change" rule), and showsresult.messageas 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.messageif present, elsebackend::lang.extension.action_failed, andstate="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.buttonsafter removingcreate(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 = localizedAdminAction.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
disabledandaria-busy="true"while the POST runs, and the label stays unchanged. On 200, a success toast showsresult.message, then the list reloads and the header partial refetches. On error, a danger toast shows the server message orbackend::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 aredisabledwhenever 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
formatvalue with count > 0 ingetFormatOptionsorder (LP, 2LP, CD, 2CD, MC, Box, EP 7"), then "No shelf" only when its count > 0. All counts go throughscopeAlbums(active collection). The template iscontrollers/albums/_stats.htmand 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 aftershelf(left), so shelf and year share a row. Stub fill payload:{year: 1977, format: "LP"}."LP"is a validgetFormatOptionsvalue. - Plugin CSS
assets/css/albums.css: styles onlygolem15-fonoteka-discogs-lookup buttonper the S3 element visual contract. It uses only public--c-*variables. Plugin JSassets/js/discogs-lookup.jsholds no user-facing strings (the text comes from thelabel/busy-labelattributes) and nofetch,XMLHttpRequestordocument.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-visiblethemselves (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
idorstyle, and never use raw-HTML sinks (D-17). The header strip is<dl>-based, and form partial and widget rows arerole="group"labelled by their visible label. - Dark mode: every new surface reads
--c-*variables only. Toggling.darkon<html>restyles plugin CSS and partials with no reload.
UI Considerations
Applicable state considerations resolved: 20 covered, 3 backstop, 1 dismissed, 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. |
| long-text | S2 form partial output | ✅ covered | .summer-partial sets overflow-wrap: anywhere. Long words and URLs wrap inside the row and are never truncated or scrolled horizontally. |
| empty | S3 widget with no fill values | ✅ covered | The element still mounts when fill-values is {} and record-id is "", and its button stays enabled. The plugin decides what to do with an empty context; the framework never hides or disables the widget. |
| long-text / overflow | S5 extension failure box | ✅ covered | The text wraps and the box grows past min-h-input, switching to items-start with 10px vertical padding once it is multi-line. It never clips, so the pl widget_failed copy stays fully readable. |
| partial / zero-one-many | S1 header partial host | ➖ dismissed | The host renders one opaque server node tree all-or-nothing (a failure gives the S5 box). Item counts belong to the partial's own content, which the stats strip rows above cover. |
| 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: FLAG (non-blocking)
- Dimension 3 Color: PASS
- Dimension 4 Typography: FLAG (non-blocking)
- Dimension 5 Spacing: FLAG (non-blocking)
- Dimension 6 Registry Safety: PASS
- Dimension 7 Inventory Provenance: PASS
Approval: approved 2026-09-28 (FLAGs on Visuals, Typography and Spacing are non-blocking recommendations)