---
phase: 10.1-runtime-admin-extension-point
plan: 02
type: execute
wave: 2
depends_on: [10.1-01]
files_modified:
- 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/**
autonomous: true
requirements: [ADMIN-07]
estimate:
tokens: 120000
raw_tokens: 120000
tasks: 3
confidence: low
must_haves:
truths:
- "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: "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'."
verification: backstop
artifacts:
- path: "admin/src/app/pluginAssets.ts"
provides: "Idempotent per-controller script and stylesheet loader with the same-origin prefix check"
exports: ["loadScript", "loadStyles", "activateStyles", "loadControllerAssets"]
- path: "admin/src/components/form/fields/WidgetField.vue"
provides: "Custom-element host bridging summer-action to the typed POST, fill patch and toast"
- path: "admin/src/components/partial/PartialHost.vue"
provides: "Header and form partial host with loading, empty and error states"
- path: "admin/src/components/partial/partialNodes.ts"
provides: "Client allowlist and h() renderer for PartialNode trees"
- path: "admin/src/components/form/formContext.ts"
provides: "InjectionKeys for form values, patch and locale"
- path: "modules/boardwalk/dist/index.html"
provides: "Rebuilt embedded SPA containing the new hosts"
key_links:
- from: "admin/src/components/form/fields/WidgetField.vue"
to: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"
via: "typed openapi-fetch api.POST"
pattern: "widgets/\\{field\\}"
- from: "admin/src/components/partial/PartialHost.vue"
to: "GET /{vendor}/{plugin}/{controller}/partials/{name}"
via: "typed openapi-fetch api.GET"
pattern: "partials/\\{name\\}"
- from: "admin/src/views/ListView.vue"
to: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"
via: "onAction handler"
pattern: "toolbar/\\{action\\}"
- from: "admin/src/components/form/registry.ts"
to: "WidgetField.vue and PartialField.vue"
via: "widget and partial renderer registration plus the valueless set"
pattern: "'(widget|partial)'"
prohibitions:
- "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.
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
@.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>` of scripts. `loadScript(url)` rejects any URL that does not start with `${runtime.base}/assets/` (T-10.1-13), otherwise appends one `