Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-02-PLAN.md
Jakub Zych ccdc014078 docs(10.1): create phase plans for the runtime admin extension point
Four plans: framework Go contracts and routes, framework SPA hosts,
Albums proof in fonoteka.go, and unit tests with the phase gate.
Adds ADMIN-07 to REQUIREMENTS.md and fills the Phase 10.1 roadmap goal,
success criteria and plan list.
2026-09-28 22:25:50 +02:00

40 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
10.1-runtime-admin-extension-point 02 execute 2
10.1-01
admin/src/app/pluginAssets.ts
admin/src/components/form/formContext.ts
admin/src/components/form/fields/WidgetField.vue
admin/src/components/form/fields/PartialField.vue
admin/src/components/partial/PartialHost.vue
admin/src/components/partial/partialNodes.ts
admin/src/components/form/registry.ts
admin/src/components/form/FormField.vue
admin/src/components/list/ListToolbar.vue
admin/src/views/FormView.vue
admin/src/views/ListView.vue
admin/src/api/types.ts
admin/src/styles/main.css
admin/vite.config.ts
admin/tests/fixtures/extension.form-schema.json
admin/tests/fixtures/extension.list-schema.json
admin/tests/fixtures/extension.partial.json
admin/tests/fixtures/typed.ts
admin/tests/smoke/extension.smoke.test.ts
modules/phrasebook/backend/lang/en/lang.yaml
modules/phrasebook/backend/lang/pl/lang.yaml
modules/cabana/README.md
modules/boardwalk/dist/**
true
ADMIN-07
tokens raw_tokens tasks confidence
120000 120000 3 low
truths artifacts key_links prohibitions
Per D-04, D-05 and D-08, a `type: widget` field loads its controller's scripts, waits for customElements.whenDefined with a 5000 ms timeout, creates the element imperatively and sets attributes only (record-id, empty on create; field-name; locale; fill-values as JSON of the current fill values kept in sync; label from the action label; busy-label); the element receives no token, cookie, Vue instance or function.
Per D-05 and D-07, a bubbling `summer-action` event from the element makes the SPA POST {record_id, values} to .../widgets/{field} through the typed API client with the admin cookie and X-Requested-With; repeat events are ignored while `busy` is set; on success only keys in field.fill that are present in the response fill are patched onto the form, the form becomes dirty, those fields' errors clear, nothing is saved, and result.message is a success toast; on failure a danger toast shows the server message or backend::lang.extension.action_failed and the element gets state="error".
Per D-09, widget and partial fields are registered renderers that are never part of the save body and render on create and update; relation-manager keeps its record-only rule.
Per D-17, PartialHost fetches .../partials/{name} (with ?id= on an existing record for form partials) and builds the node tree with Vue h() under the same tag, attribute and URL allowlist as the server; unknown tags are unwrapped, unknown or event attributes are dropped, text stays text, and admin/src contains no raw-HTML sink.
Per D-03 and D-11, when the list schema names a headerPartial the ListView renders it between the page header and the list card; it refetches after a successful bulk delete or custom toolbar action and not on search, filter, sort or page changes.
Per D-12, custom toolbar names present in schema.toolbarActions render in the ListToolbar after the built-ins in declared order as outline buttons labelled from toolbarActions, enabled regardless of the selection; a click disables the button with aria-busy, POSTs {} to .../toolbar/{action}, toasts result.message, reloads the list and refetches the header partial; a failure is a danger toast; a name the admin may not run is never rendered; create and delete behave as in Phase 10.
Per D-14 and D-16, plugin scripts and styles load only when their controller's list or form opens, only from URLs under {runtime.base}/assets/, once per URL (a failed script can retry on a later navigation), and stylesheet links of other controllers are disabled; the list table never waits for plugin JS.
UI consideration (loading S1): the first header-partial fetch shows one full-width 80px radius-16 bg-skel block (aria-hidden) with aria-busy on the host; refetches keep the previous nodes visible with aria-busy and no skeleton flash.
UI consideration (empty S1): a header partial with zero nodes renders nothing and takes no gap.
UI consideration (error S1): a header partial failure renders the full-width extension failure box with backend::lang.extension.partial_failed and the list stays usable.
UI consideration (overflow S1 stats items): the .summer-stats kit wraps items with flex-wrap, a 32px column gap and an 8px row gap inside one card and never scrolls horizontally.
UI consideration (overflow S1/S2 partial output): a server 500 for an oversized partial shows the partial_failed box, never a truncated render.
UI consideration (loading S2): a form partial shows a 44px radius-10 bg-skel bar until its nodes arrive.
UI consideration (error S2): a form partial failure shows the extension failure box with partial_failed in its row and the form stays saveable.
UI consideration (empty S2): a form partial with zero nodes shows only its label, if declared, and no placeholder text; with no label there is no label row.
UI consideration (partial S2/S3 on create): form partials and widgets render on create; the partial is fetched without ?id= and the widget gets record-id="".
UI consideration (loading S3): a widget shows a 42px by 160px radius-10 bg-skel bar with aria-busy on its group until whenDefined resolves or 5000 ms pass.
UI consideration (error S3 load): on a script error or timeout the element is not mounted and the widget_failed failure box is shown.
UI consideration (error S3 POST): a failed action POST shows a danger toast (server message or action_failed), sets state="error" and leaves the form values untouched.
UI consideration (loading S3 in flight): while the POST runs the element carries the busy attribute and repeat summer-action events are ignored.
UI consideration (form S3 fill write-back): only field.fill keys present in result.fill are patched; the form becomes dirty, their errors clear, and nothing saves until Save.
UI consideration (empty S3 with no fill values): the element mounts with fill-values `{}` and record-id "" and the framework never hides or disables it.
UI consideration (loading/error S4): a custom toolbar button is disabled with aria-busy during its POST; success toasts then reloads the list and refetches the partial; failure toasts danger.
UI consideration (long-text S2): .summer-partial sets overflow-wrap: anywhere so long words and URLs wrap inside the row.
UI consideration (long-text/overflow S5): the extension failure box text wraps and the box grows past min-h-input, switching to items-start with 10px vertical padding when multi-line, so the pl widget_failed copy is fully readable.
statement verification
UI consideration (error S6, CSS bleed across controllers): stylesheet links of inactive controllers are disabled; the pluginAssets toggle is covered here and in 10.1-04, and a real-browser check (RESEARCH A4) confirms module scripts load under CSP script-src 'self'. backstop
path provides exports
admin/src/app/pluginAssets.ts Idempotent per-controller script and stylesheet loader with the same-origin prefix check
loadScript
loadStyles
activateStyles
loadControllerAssets
path provides
admin/src/components/form/fields/WidgetField.vue Custom-element host bridging summer-action to the typed POST, fill patch and toast
path provides
admin/src/components/partial/PartialHost.vue Header and form partial host with loading, empty and error states
path provides
admin/src/components/partial/partialNodes.ts Client allowlist and h() renderer for PartialNode trees
path provides
admin/src/components/form/formContext.ts InjectionKeys for form values, patch and locale
path provides
modules/boardwalk/dist/index.html Rebuilt embedded SPA containing the new hosts
from to via pattern
admin/src/components/form/fields/WidgetField.vue POST /{vendor}/{plugin}/{controller}/widgets/{field} typed openapi-fetch api.POST widgets/{field}
from to via pattern
admin/src/components/partial/PartialHost.vue GET /{vendor}/{plugin}/{controller}/partials/{name} typed openapi-fetch api.GET partials/{name}
from to via pattern
admin/src/views/ListView.vue POST /{vendor}/{plugin}/{controller}/toolbar/{action} onAction handler toolbar/{action}
from to via pattern
admin/src/components/form/registry.ts WidgetField.vue and PartialField.vue widget and partial renderer registration plus the valueless set '(widget|partial)'
No raw-HTML sink and no HTML-string parser anywhere in admin/src, including comments (the Phase 10 hygiene list).
No direct network call outside admin/src/api/client.ts; plugin assets load only through script and link elements.
No npm package is added or re-pinned.

Phase Goal

A plugin extends the compiled admin SPA without a Node rebuild: controller JS/CSS served same-origin from embedded files, type: widget custom elements whose actions the SPA posts, type: partial and list headerPartial rendered server-side without a raw-HTML sink, and registered toolbar actions (ADMIN-07).

Build the SPA half of the extension point in summercms.go/admin: the plugin asset loader, the widget host, the partial host for form and list-header partials, custom toolbar buttons, the partial style kit and the new framework strings, then rebuild and commit the embedded dist.

Purpose: This is the only Node-built part of the phase; after it, application plugins extend the admin with Go, YAML, templates and plain JS alone (D-04). Decisions implemented: D-04, D-05, D-07 (client patch), D-08, D-09, D-12 (SPA), D-14, D-16 (loader prefix check), D-17 (client half); UI-SPEC surfaces S1 to S6. Output: new SPA modules and components, updated form and list views, the backend::lang.extension.* strings, smoke tests importing every new module, rebuilt modules/boardwalk/dist.

Repo: summercms.go only. Code and planning docs in separate commits; never add co-author tags.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md @.planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md @.planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md @.planning/phases/10.1-runtime-admin-extension-point/10.1-01-SUMMARY.md @.planning/phases/10-admin-vue-spa/design/README.md @admin/src/api/schema.d.ts @admin/src/components/form/registry.ts @admin/src/views/FormView.vue @admin/src/views/ListView.vue From 10.1-01 (typed in admin/src/api/schema.d.ts after its regeneration): - `POST /{vendor}/{plugin}/{controller}/widgets/{field}` body `cabana.AdminActionRequest` {record_id?: number; values?: Record} → `cabana.Envelope-cabana_AdminActionResult` {data: {message: string; fill: Record}}; 403, 404, 422 are `cabana.ErrorEnvelope`. - `POST /{vendor}/{plugin}/{controller}/toolbar/{action}` body `{}` → same envelope, fill always `{}`. - `GET /{vendor}/{plugin}/{controller}/partials/{name}` query `id?` → `cabana.Envelope-cabana_PartialView` {data: {nodes: cabana.PartialNode[]}}; PartialNode {tag?: string; attrs?: Record; text?: string; children?: PartialNode[]}. - `cabana.FormField` gains widget?, action?, actionLabel? (localized), fill?, path?; `cabana.FormView.assets` and `cabana.ListSchema.assets` are `cabana.ControllerAssets` {scripts: string[]; styles: string[]} with absolute URLs `{base}/assets/...?v=`; `cabana.ListSchema.headerPartial?`; `cabana.ListSchema.toolbarActions` is `cabana.ToolbarAction[]` {name; label} already filtered to what the admin may run. Existing SPA seams: `admin/src/components/form/control.ts` FieldControlProps {field, modelValue, controlId, invalid?, describedBy?, labels?, source?: ControllerParams | null, recordId?: number | null}; `registry.ts` renderers map, `isRegistered`, `needsRecord`, `ownsLabel` (field components import ../control, never ../registry); `formState.ts` editablePayload skips `!isRegistered(type)`; `app/runtime.ts` `runtime.base`; `app/i18n.ts` `t`, `message`, `currentLocale`; `state/useToasts.ts` `showToast(text, tone)`; `api/client.ts` `api` (the only allowed network call site); `components/ui/Button.vue` variants primary/outline/ghost/danger, size md/sm; `components/form/fields/UnsupportedField.vue` failure-box class string; tests use `tests/helpers.ts` (`mountApp`, `requestsTo`, API base `/admin-test/api/v1`) and typed fixtures in `tests/fixtures/typed.ts`. Hygiene rules that must stay green (`scripts/check-phase10.sh --hygiene`): no raw-HTML directive words in admin/src even in comments; no direct network call outside api/client.ts; admin/src/api holds only schema.d.ts, client.ts and types.ts, and types.ts only aliases `Schemas['cabana.X']`; every .ts/.vue file in admin/src is imported by some test; no application names in admin/src, admin/tests or modules/cabana.

Planning notes

  • Spec-less probe fallback skipped: no requirement IDs were mapped for Phase 10.1 before this planning run; ADMIN-07 is introduced by it. Truths come from CONTEXT D-01..D-17 and UI-SPEC surfaces S1-S6; every resolved UI-SPEC "UI Considerations" row assigned to the SPA is a truth above (the S1 host "partial / zero-one-many" row was dismissed in the UI-SPEC and needs no must-have; Albums-specific rows live in 10.1-03).
  • Discretion resolved: the widget also receives label and busy-label attributes (RESEARCH Open Question 4; additive, no credential) so plugin JS carries no strings; widgets render on create and update; the partial client allowlist lives in partialNodes.ts so 10.1-04 can unit-test it directly.
  • Tests here are smoke tests (CLAUDE.md rule 3); branch-level Vitest suites are 10.1-04.

Artifacts this phase produces

  • admin/src/app/pluginAssets.ts: loadScript(url), loadStyles(controllerId, urls), activateStyles(controllerId), loadControllerAssets(controllerId, assets)
  • admin/src/components/form/formContext.ts: FORM_VALUES, FORM_PATCH, FORM_LOCALE InjectionKeys
  • admin/src/components/form/fields/WidgetField.vue, admin/src/components/form/fields/PartialField.vue
  • admin/src/components/partial/PartialHost.vue (props source, name, recordId, variant: 'header' | 'field', reloadKey)
  • admin/src/components/partial/partialNodes.ts: PARTIAL_TAGS, partialAttrAllowed(tag, name, value), renderPartialNodes(nodes)
  • registry.ts: widget and partial renderers, valueless set, exported groupLabelled(type)
  • ListToolbar props actions: ToolbarAction[], busyAction: string | null, event action: [name]
  • types.ts aliases AdminActionRequest, AdminActionResult, ControllerAssets, ToolbarAction, PartialNode, PartialView
  • CSS kit classes .summer-partial, .summer-stats, .summer-stat, .summer-stat__label, .summer-stat__value
  • Phrase keys backend::lang.extension.busy, .widget_failed, .partial_failed, .action_failed (en, pl)
  • Custom-element contract: event name summer-action; attributes record-id, field-name, locale, fill-values, label, busy-label, busy, state
  • Vite dev proxy entry ${devPrefix}/assets
  • Fixtures tests/fixtures/extension.form-schema.json, extension.list-schema.json, extension.partial.json; smoke test tests/smoke/extension.smoke.test.ts
Task 1: An admin clicks a plugin widget on a form, and only its fill fields change before a toast confirms D-05 and D-08 fix the element contract (attributes only, the summer-action event, the SPA owning HTTP) that every plugin widget is written against; user-locked, so flagged without a checkpoint. 10.1-01 is executed: `grep -q 'widgets/{field}' admin/src/api/schema.d.ts && grep -q 'cabana.ControllerAssets' admin/src/api/schema.d.ts` succeeds. admin/src/app/pluginAssets.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormField.vue, admin/src/views/FormView.vue, admin/src/api/types.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/tests/fixtures/extension.form-schema.json, admin/tests/fixtures/typed.ts, admin/tests/smoke/extension.smoke.test.ts, modules/boardwalk/dist/** admin/src/components/form/registry.ts, admin/src/components/form/control.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/FormField.vue, admin/src/components/form/FormGrid.vue, admin/src/components/form/formState.ts, admin/src/components/form/fields/UnsupportedField.vue, admin/src/views/FormView.vue, admin/src/views/ListView.vue (onDelete POST-toast idiom), admin/src/app/runtime.ts, admin/src/app/i18n.ts, admin/src/api/types.ts, admin/src/api/schema.d.ts, admin/tests/helpers.ts, admin/tests/fixtures/typed.ts, admin/tests/smoke/form.smoke.test.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/phase10_test.go (TestPhase10SPAKeysResolve), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S3, S5, Copywriting Contract) (1) types.ts: add aliases `AdminActionRequest`, `AdminActionResult` and `ControllerAssets` onto `Schemas['cabana.X']` only (the hygiene regex accepts nothing else).

(2) New admin/src/app/pluginAssets.ts (D-14, D-16): a module-level Map<string, Promise<void>> of scripts. loadScript(url) rejects any URL that does not start with ${runtime.base}/assets/ (T-10.1-13), otherwise appends one <script type="module"> with that src to document.head, resolves on load, and on error deletes the map entry and rejects, so a later navigation can retry; a URL already in the map returns the same promise. loadStyles(controllerId, urls) creates one <link rel="stylesheet" data-summer-controller="{controllerId}"> per new URL (same prefix check). activateStyles(controllerId) sets disabled on every plugin link owned by another controller and clears it on this controller's links. loadControllerAssets(controllerId, assets) runs activateStyles and loadStyles, then resolves when every script promise settles. Plugin files load only through script and link elements; no other network call.

(3) New admin/src/components/form/formContext.ts with typed InjectionKeys FORM_VALUES (read-only Ref of AdminRecord), FORM_PATCH ((name: string, value: unknown) => void) and FORM_LOCALE (Ref of string). FormView provides the values read-only, patch as its existing update(name, value) (so the form turns dirty and that field's errors clear) and the locale from schema.meta.locale; when the schema arrives it starts loadControllerAssets(controllerId, schema.assets) without awaiting it before rendering.

(4) registry.ts: register widget → WidgetField; add const valueless = new Set([RELATION_MANAGER, 'widget']) and make isRegistered use it, so editablePayload never sends a widget, while needsRecord stays relation-manager only (widgets render on create, UI-SPEC S3); export groupLabelled(type), true for widget. FormField.vue: for a groupLabelled type render the visible label as <span id="{base}-label" class="font-semibold"> (same asterisk rule) instead of <label for>.

(5) New admin/src/components/form/fields/WidgetField.vue (imports ../control, never ../registry; D-04, D-05, D-07, D-08): a host <div :id="controlId" role="group" :aria-labelledby="${controlId}-label" :aria-describedby class="flex min-h-input items-center"> containing a ref div that Vue never renders children into. While loading, show a 42px by 160px radius-10 bg-skel bar and set aria-busy="true" on the group. Wait for loadControllerAssets of the form (idempotent) and Promise.race([customElements.whenDefined(field.widget), a 5000 ms timeout]); then create the element with document.createElement(field.widget) inside try/catch and append it to the ref div. Set attributes only: record-id (the record id, or "" on create), field-name, locale (FORM_LOCALE, falling back to currentLocale), fill-values (JSON of the current values of field.fill, kept current with a watch on FORM_VALUES; {} when empty), label (field.actionLabel, falling back to field.label) and busy-label (t('backend::lang.extension.busy')). Listen for summer-action: ignore it while busy; set the busy attribute and remove state; POST /{vendor}/{plugin}/{controller}/widgets/{field} through api with path {...source, field: field.name} and body {record_id: recordId ?? undefined, values}; on success call FORM_PATCH for each key of field.fill present in the response fill (never any other key, T-10.1-17) and showToast(result.data.data.message); on failure showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger') and set state="error"; always remove busy. On a load failure or timeout, mount nothing and render the S5 box: UnsupportedField's class string with role="alert", CircleAlert 16px text-danger aria-hidden and t('backend::lang.extension.widget_failed'); when the text wraps the box switches to items-start with 10px vertical padding. Remove the listener on unmount. Never hand the element a token, cookie, Vue instance or function (T-10.1-18).

(6) Add the extension: group to modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml with keys busy, widget_failed, partial_failed and action_failed, using the exact en and pl strings from the UI-SPEC Copywriting Contract (TestPhase10SPAKeysResolve then proves every key the SPA uses resolves).

(7) Smoke: admin/tests/fixtures/extension.form-schema.json is an acme.demo.widgets form whose lookup field has widget acme-demo-lookup, action lookup, actionLabel "Look up" and fill [name], with assets.scripts holding one /admin-test/assets/acme/demo/js/lookup.js?v=abc URL; type it in typed.ts against cabana.Envelope-cabana_FormView. admin/tests/smoke/extension.smoke.test.ts defines an acme-demo-lookup element in the test before mount, stubs the script load, mounts the form route and proves: the attributes above are set; a summer-action POSTs {record_id, values} with X-Requested-With; a response fill containing name and a non-fill color patches only name and shows the toast; a second event while busy sends nothing; the save body omits lookup; the widget renders on create with record-id "". The test imports pluginAssets, formContext and WidgetField directly (hygiene rule: every src module imported by a test).

(8) Run npm --prefix admin run build and commit modules/boardwalk/dist with the code. npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && scripts/check-phase10.sh --hygiene <fails_when>Any command exits non-zero; vitest prints "No test files found" or a "FAIL" line; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or prints "no tests to run"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when> <acceptance_criteria> - grep -c "\['widget', WidgetField\]" admin/src/components/form/registry.ts prints 1. - grep -c "runtime.base" admin/src/app/pluginAssets.ts prints at least 1. - grep -c 'widgets/{field}' admin/src/components/form/fields/WidgetField.vue prints at least 1 and grep -c 'customElements.whenDefined' admin/src/components/form/fields/WidgetField.vue prints at least 1. - grep -c 'extension:' modules/phrasebook/backend/lang/en/lang.yaml and grep -c 'extension:' modules/phrasebook/backend/lang/pl/lang.yaml each print 1. - The extension smoke test covers every behaviour listed in item (7) and passes. </acceptance_criteria> On a form with a widget field an admin sees the plugin element, clicks it, and gets only the declared fill fields updated (unsaved) with a toast, through the SPA's own authenticated POST.

Task 2: Header partials and form partials render as native admin markup through an allowlisted node renderer admin/src/components/partial/PartialHost.vue, admin/src/components/partial/partialNodes.ts, admin/src/components/form/fields/PartialField.vue, admin/src/components/form/registry.ts, admin/src/views/ListView.vue, admin/src/styles/main.css, admin/src/api/types.ts, admin/tests/fixtures/extension.list-schema.json, admin/tests/fixtures/extension.partial.json, admin/tests/fixtures/typed.ts, admin/tests/smoke/extension.smoke.test.ts, modules/cabana/README.md, modules/boardwalk/dist/** admin/src/components/form/registry.ts and admin/src/components/form/FormField.vue (Task 1), admin/src/views/ListView.vue, admin/src/styles/main.css (tokens and the components layer), admin/src/components/form/control.ts (allowedAttributes idiom), admin/src/api/schema.d.ts (cabana.PartialNode, cabana.PartialView), modules/cabana/partial_render.go (10.1-01 allowlist to mirror), modules/cabana/README.md, admin/tests/smoke/extension.smoke.test.ts (Task 1), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (Partial style kit, S1, S2, S5, Public CSS variables) (1) types.ts: aliases `PartialNode` and `PartialView` onto `Schemas['cabana.X']`.

(2) New admin/src/components/partial/partialNodes.ts (D-17, T-10.1-14): PARTIAL_TAGS (the same tag list as modules/cabana/partial_render.go), partialAttrAllowed(tag, name, value) (global class, title, lang, dir, role, aria-* and data-; a[href] only when it starts with exactly one "/" or with "#"; img[src] only a same-origin "/" path, plus alt, width, height; td/th colspan, rowspan, scope; time datetime; data value; meter value, min, max, low, high, optimum; progress value, max; never id, style or on) and renderPartialNodes(nodes) building VNodes with Vue h(): an allowed tag becomes h(tag, allowedAttrs, children), any other tag contributes only its children, text nodes become plain text children, and recursion stops at depth 32. It never parses a string and never uses a raw-HTML sink (the Phase 10 hygiene list also matches comments, so keep those words out of comments).

(3) New admin/src/components/partial/PartialHost.vue with props source (ControllerParams), name, recordId (optional), variant ('header' | 'field') and reloadKey (number): GET /{vendor}/{plugin}/{controller}/partials/{name} through api, with query id only when recordId is set; the root element has class summer-partial and renders renderPartialNodes. States (UI-SPEC S1, S2): the first load shows, for header, one full-width 80px radius-16 bg-skel block (aria-hidden) and, for field, a 44px radius-10 bg-skel bar, with aria-busy="true" on the host; a reloadKey change refetches while keeping the previous nodes visible with aria-busy and no skeleton; zero nodes render nothing (the header variant then occupies no gap); an error renders the S5 box (UnsupportedField geometry, role=alert, CircleAlert, t('backend::lang.extension.partial_failed')) at full width for header. No live region.

(4) New admin/src/components/form/fields/PartialField.vue wrapping PartialHost variant field with name = field.path, source and recordId from the props (on create it fetches without id). registry.ts registers partial → PartialField and adds partial to the valueless set and to groupLabelled; FormField.vue renders no label row for a groupLabelled field without a label; the host is role="group" labelled by the label span when there is one.

(5) ListView.vue (D-03, D-11): when schema.headerPartial is set, render <PartialHost variant="header"> between the page <header> and the list card; keep a partialReload counter passed as reloadKey and increment it after a successful bulk delete (after loadList in onDelete). Do not refetch on search, filter, sort or page changes.

(6) admin/src/styles/main.css @layer components: .summer-partial (14px/1.5, color: var(--c-text), overflow-wrap: anywhere; p, ul and ol margin: 0 0 8px with the last child margin 0; links in var(--c-text), underlined, with the 3px focus ring), .summer-stats (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), .summer-stat (flex column-reverse, gap 4px, min-width 0), .summer-stat__label (13px/1.5, weight 400, var(--c-muted), margin 0, wraps) and .summer-stat__value (20px/1.2, weight 600, var(--c-text), font-variant-numeric: tabular-nums, margin 0), reading only existing --c-* variables.

(7) modules/cabana/README.md: a "Partial style kit and plugin CSS variables" subsection listing the kit classes with the recommended <dl class="summer-stats"> markup and the public variables plugin CSS may read (--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), stating that plugins must not hardcode hex colours or rely on Tailwind utilities.

(8) Smoke: extension.list-schema.json (acme.demo.widgets list with headerPartial stats, assets, toolbarActions []) and extension.partial.json (a PartialView whose nodes include a dl.summer-stats strip, a script element node, an a with an onclick attribute and a javascript: href, and a text node holding angle brackets), both typed in typed.ts. Extend extension.smoke.test.ts: the strip renders with its classes; the script node, onclick and javascript: href never reach the DOM; the angle-bracket text renders as text; zero nodes render nothing; a 500 shows the partial_failed box while the table still renders; a bulk delete refetches the partial with prior nodes kept; a form partial renders on create without id and on update with id. Import partialNodes, PartialHost and PartialField.

(9) Rebuild with npm --prefix admin run build and commit modules/boardwalk/dist. npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && scripts/check-phase10.sh --hygiene <fails_when>Any command exits non-zero; vitest prints "No test files found" or a "FAIL" line; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when> <acceptance_criteria> - grep -c "\['partial', PartialField\]" admin/src/components/form/registry.ts prints 1. - grep -c 'partials/{name}' admin/src/components/partial/PartialHost.vue prints at least 1 and grep -c 'headerPartial' admin/src/views/ListView.vue prints at least 1. - grep -c '\.summer-stats' admin/src/styles/main.css prints at least 1 and grep -c 'overflow-wrap: anywhere' admin/src/styles/main.css prints at least 1. - grep -c 'summer-stats' modules/cabana/README.md prints at least 1. - scripts/check-phase10.sh --hygiene exits 0 (no raw-HTML sink, every new module imported by a test). </acceptance_criteria> A header partial above a list and a form partial in a row render the server's node tree as native, themed markup with loading, empty and error states, and hostile nodes never reach the DOM.

Task 3: Custom toolbar actions run from the list, and plugin CSS applies only to its own controller D-12's rendering rule (declared order after the built-ins, server-filtered names only, no disabled placeholders) is what every registered toolbar action relies on; user-locked, flagged without a checkpoint. admin/src/components/list/ListToolbar.vue, admin/src/views/ListView.vue, admin/src/app/pluginAssets.ts, admin/src/api/types.ts, admin/vite.config.ts, admin/tests/fixtures/extension.list-schema.json, admin/tests/smoke/extension.smoke.test.ts, modules/boardwalk/dist/** admin/src/components/list/ListToolbar.vue, admin/src/views/ListView.vue (buttons split, onDelete), admin/src/app/pluginAssets.ts (Task 1), admin/vite.config.ts, admin/tests/list/ListToolbar.test.ts, admin/tests/smoke/list.smoke.test.ts, .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S4, S6), .planning/phases/10.1-runtime-admin-extension-point/10.1-RESEARCH.md (Pitfall 6) (1) types.ts: alias `ToolbarAction` onto `Schemas['cabana.ToolbarAction']`.

(2) ListView.vue (D-12): toolbarButtons keeps the declared order minus create, holding delete and every name present in schema.toolbarActions (the server already filtered them by permission; a name missing there is not rendered, never shown disabled). Pass actions (schema.toolbarActions) and busyAction to ListToolbar. onAction(name): ignore when busyAction is set; set busyAction; POST /{vendor}/{plugin}/{controller}/toolbar/{action} through api with body {}; on success showToast(result.data.data.message), then await loadList() and increment partialReload; on failure showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger'); finally clear busyAction. No confirmation dialog. When the list schema arrives start loadControllerAssets(controllerId, schema.assets) (D-14); the table renders without waiting for it.

(3) ListToolbar.vue: props gain actions: ToolbarAction[] and busyAction: string | null; emits gain action: [name: string]. In the existing button loop, a name other than delete that has an entry in actions renders <Button variant="outline" size="md" :data-action="name"> with the entry's label as text (no icon), disabled and aria-busy="true" while busyAction equals it, independent of the selection count, emitting action. Delete keeps its Phase 10 behaviour.

(4) pluginAssets.ts: make sure activateStyles runs on every list and form mount (both views call loadControllerAssets), so the stylesheets of other controllers are disabled (UI-SPEC S6, T-10.1-16).

(5) admin/vite.config.ts: add a ${devPrefix}/assets proxy entry to devTarget with changeOrigin: false beside the existing /api entry, so plugin assets load under npm run dev (Pitfall 6).

(6) Smoke: extension.list-schema.json gains buttons [create, delete, recount] with toolbarActions [{name: recount, label: Recount}], plus a second list fixture variant where toolbarActions is empty. Extend extension.smoke.test.ts: the Recount button renders after Delete, is enabled with no selection, POSTs {} to .../toolbar/recount with X-Requested-With, toasts the message, reloads the list and refetches the header partial; with empty toolbarActions no Recount button renders; a 500 shows a danger toast; opening a second controller disables the first controller's stylesheet link.

(7) Rebuild with npm --prefix admin run build and commit modules/boardwalk/dist. npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-dist.sh && scripts/check-phase10.sh --hygiene && go test ./modules/phrasebook ./modules/boardwalk -count=1 <fails_when>Any command exits non-zero; vitest prints "No test files found", a "FAIL" line or "Unhandled Rejection"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when> Run the Vite dev server against a running summer serve (SUMMER_ADMIN_DEV_TARGET) and open a list whose controller declares assets; open the browser network tab. The plugin script and stylesheet load through the /assets proxy with 200 and a JavaScript/CSS content type; switching to another controller disables the first stylesheet link. <why_human>The Vite dev proxy and real module loading run only in a browser; happy-dom does not load module scripts.</why_human> <acceptance_criteria> - grep -c 'toolbar/{action}' admin/src/views/ListView.vue prints at least 1. - grep -c 'aria-busy' admin/src/components/list/ListToolbar.vue prints at least 1. - grep -c '/assets' admin/vite.config.ts prints at least 1. - The full Vitest suite passes, including the Phase 10 ListToolbar and ListView tests unchanged in behaviour for create and delete. </acceptance_criteria> A registered toolbar action appears in the list toolbar for admins who may run it, runs with busy state, toast, list reload and partial refetch, and plugin stylesheets stay scoped to their controller.

Source coverage (this plan)

Source Item Task
CONTEXT D-04 plain JS custom elements, no Vue in plugins 1
CONTEXT D-05 SPA owns HTTP (widget POST) 1
CONTEXT D-07 patch only fill keys (client) 1
CONTEXT D-08 element attributes, no token or cookie 1
CONTEXT D-09 widget and partial registered, valueless 1, 2
CONTEXT D-03 / D-11 list-header slot above the table 2
CONTEXT D-12 custom toolbar buttons, POST, toast 3
CONTEXT D-14 assets load per controller 1, 3
CONTEXT D-16 same-origin prefix check in the loader 1
CONTEXT D-17 client half: h() renderer, no raw sink 2
UI-SPEC S1-S6, Copywriting Contract framework keys, partial style kit, public CSS variables 1-3
RESEARCH Pattern 6, Pitfalls 4, 6, 7, 8 1-3

<threat_model>

Trust Boundaries

Boundary Description
Server schema → SPA loader Asset URLs from the schema decide which scripts run in the admin origin
Server node tree → DOM Partial content from plugin templates becomes DOM nodes
SPA → plugin custom element Same-origin plugin code receives data from the SPA and signals it through events

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10.1-13 Tampering pluginAssets loader (foreign script URL from a schema) medium mitigate loadScript and loadStyles refuse any URL outside ${runtime.base}/assets/; CSP script-src 'self' backs it up (Task 1).
T-10.1-14 Tampering PartialHost node rendering (XSS, client half) high mitigate renderPartialNodes rebuilds only allowlisted tags and attributes with h(), keeps text as text, never parses strings; the Phase 10 hygiene stage refuses raw-HTML sinks in admin/src (Task 2).
T-10.1-16 Tampering plugin CSS bleeding across controllers low mitigate activateStyles disables stylesheet links owned by other controllers on every list and form mount (Tasks 1, 3).
T-10.1-17 Tampering client-side fill write-back medium mitigate WidgetField patches only keys in field.fill that the response returns; the save still goes through the server's writable projection (Task 1).
T-10.1-18 Information Disclosure plugin custom element receiving credentials high mitigate The SPA sets attributes only (record id, field name, locale, fill snapshot, labels) and performs the POST itself; the session cookie stays HttpOnly and no token exists in JS (Phase 10 T-10-01) (Task 1).
T-10.1-SC Tampering npm dependencies high mitigate No package added or re-pinned; the build uses the committed lockfile, and check-admin-dist.sh rebuilds from it.
</threat_model>
After Task 3: `npm --prefix admin run typecheck`, the full `npm --prefix admin test`, `scripts/check-admin-dist.sh`, `scripts/check-phase10.sh --hygiene` and `go test ./modules/phrasebook ./modules/boardwalk` pass; the committed dist contains the widget, partial and toolbar hosts.

<success_criteria>

  • Widgets mount plugin elements and bridge summer-action to the typed POST, patching only fill keys.
  • Header and form partials render allowlisted node trees with the UI-SPEC loading, empty and error states.
  • Custom toolbar actions run from the list with busy state, toast, reload and partial refetch.
  • Plugin assets load per controller from the admin origin only; no npm change; dist rebuilt and drift-free. </success_criteria>
Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md` when done.