Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md

30 KiB
Raw Blame History

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.

  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 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'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:

<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: 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)