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

330 lines
40 KiB
Markdown

---
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).
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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
<interfaces>
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<string, unknown>} → `cabana.Envelope-cabana_AdminActionResult` {data: {message: string; fill: Record<string, unknown>}}; 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<string, string>; 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.
</interfaces>
</context>
## 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`
<tasks>
<task type="tracer">
<name>Task 1: An admin clicks a plugin widget on a form, and only its fill fields change before a toast confirms</name>
<reversibility rating="costly">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.</reversibility>
<precondition>10.1-01 is executed: `grep -q 'widgets/{field}' admin/src/api/schema.d.ts &amp;&amp; grep -q 'cabana.ControllerAssets' admin/src/api/schema.d.ts` succeeds.</precondition>
<files>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/**</files>
<read_first>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)</read_first>
<action>(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.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; scripts/check-phase10.sh --hygiene</automated>
<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>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Header partials and form partials render as native admin markup through an allowlisted node renderer</name>
<files>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/**</files>
<read_first>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)</read_first>
<action>(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.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; scripts/check-phase10.sh --hygiene</automated>
<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>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: Custom toolbar actions run from the list, and plugin CSS applies only to its own controller</name>
<reversibility rating="costly">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.</reversibility>
<files>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/**</files>
<read_first>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)</read_first>
<action>(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.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; scripts/check-phase10.sh --hygiene &amp;&amp; go test ./modules/phrasebook ./modules/boardwalk -count=1</automated>
<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>
<human-check>
<test>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.</test>
<expected>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.</expected>
<why_human>The Vite dev proxy and real module loading run only in a browser; happy-dom does not load module scripts.</why_human>
</human-check>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
## 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>
<verification>
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.
</verification>
<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>
<output>
Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md` when done.
</output>