--- phase: 10.1-runtime-admin-extension-point plan: 02 subsystem: ui tags: [vue, admin-spa, custom-elements, openapi-fetch, tailwind, boardwalk, phrasebook] requires: - phase: 10.1-runtime-admin-extension-point provides: "10.1-01 widget, toolbar and partial routes, plugin asset route, schema keys assets/toolbarActions/headerPartial and widget/action/actionLabel/fill/path, typed in schema.d.ts" - phase: 10-admin-vue-spa provides: admin SPA, typed openapi-fetch client, field registry, toast queue, hygiene and dist gates provides: - admin/src/app/pluginAssets.ts (loadScript, loadStyles, activateStyles, loadControllerAssets, assetAllowed, assetPrefix, OWNER_ATTRIBUTE) - formContext InjectionKeys FORM_VALUES, FORM_PATCH, FORM_LOCALE, FORM_ASSETS and the summer-action / 5000 ms widget contract - WidgetField (type widget) and PartialField (type partial), both valueless and group-labelled, rendered on create and update - PartialHost with partialNodes.ts (PARTIAL_TAGS, PARTIAL_DROPPED_TAGS, partialAttrAllowed, renderPartialNodes) - ExtensionFailure box (UI-SPEC S5) - list headerPartial host with refetch after bulk delete and toolbar actions - ListToolbar actions/busyAction props and action event; ListView onAction posting {} to toolbar/{action} - partial style kit (.summer-partial, .summer-stats, .summer-stat, .summer-stat__label, .summer-stat__value) documented with the public --c-* variables in the cabana README - backend::lang.extension.{busy,widget_failed,partial_failed,action_failed} in en and pl - Vite dev proxy for {prefix}/assets - rebuilt modules/boardwalk/dist affects: [10.1-03 application widget, partial and toolbar proof, 10.1-04 unit tests and gate, 14 Discogs] actuals: tokens: 17900 tasks: 3 commits: 3 plan_head_before: c3b76b1afb977cb3f72272f9876c65c8da7877ed plan_head_after: a5e7dac10f2ebf10b606e3de1a9c1dc89c0803a1 tech-stack: added: [] patterns: - "Plugin custom elements are created imperatively in a node Vue never renders children into; data goes in as attributes only and requests come out as a bubbling summer-action event" - "Server node trees are rebuilt with h() under a client copy of the server allowlist; no string is parsed as markup" - "Controller assets load when the controller's list or form schema arrives; stylesheet links are owned per controller and toggled on every open" key-files: created: - 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/ui/ExtensionFailure.vue - admin/tests/fixtures/extension.form-schema.json - admin/tests/fixtures/extension.list-schema.json - admin/tests/fixtures/extension.partial.json - admin/tests/smoke/extension.smoke.test.ts modified: - admin/src/api/types.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/styles/main.css - admin/vite.config.ts - admin/tests/fixtures/typed.ts - admin/tests/fixtures/lang.json - modules/phrasebook/backend/lang/en/lang.yaml - modules/phrasebook/backend/lang/pl/lang.yaml - modules/cabana/README.md - modules/boardwalk/dist/** key-decisions: - "A widget posts only the current values of its fill keys as values, since the server passes nothing else to the action" - "loadControllerAssets resolves with the failed script URLs; a widget whose element is still undefined after a script failure shows the failure box at once instead of waiting 5000 ms" - "Stylesheet links are keyed by controller and URL, so two controllers of one plugin sharing a file each keep their own link" - "The client allowlist also drops the server's removed tags (script, style, svg and so on) with their subtree instead of unwrapping them" - "A widget or partial field without a label has no label row and no aria-labelledby; FormField renders the label as a span only when one is declared" - "Empty action messages are not toasted" patterns-established: - "Extension controls read form state through formContext injections with safe defaults, so settings forms without a provider still render" - "Smoke tests stub document.head.appendChild so plugin scripts and links never load in happy-dom" requirements-completed: [ADMIN-07] coverage: - id: D1 description: "A type: widget field loads its controller script, mounts the custom element with attributes only, posts summer-action through the typed client once while busy, patches only returned fill keys without saving, and toasts success or failure" requirement: ADMIN-07 verification: - kind: automated_ui ref: "admin/tests/smoke/extension.smoke.test.ts#widget field (D-04, D-05, D-07, D-08)" status: pass human_judgment: false - id: D2 description: "Plugin assets load only from {base}/assets/, once per URL with retry after failure, and stylesheet links of other controllers are disabled" requirement: ADMIN-07 verification: - kind: automated_ui ref: "admin/tests/smoke/extension.smoke.test.ts#plugin assets (D-14, D-16)" status: pass - kind: automated_ui ref: "admin/tests/smoke/extension.smoke.test.ts#controller stylesheets (D-14, UI-SPEC S6)" status: pass human_judgment: false - id: D3 description: "Header and form partials render the server node tree through the client allowlist with skeleton, empty, failure and keep-on-refetch states; hostile nodes never reach the DOM" requirement: ADMIN-07 verification: - kind: automated_ui ref: "admin/tests/smoke/extension.smoke.test.ts#list header partial (D-03, D-11, UI-SPEC S1)" status: pass - kind: automated_ui ref: "admin/tests/smoke/extension.smoke.test.ts#form partial (D-09, UI-SPEC S2)" status: pass - kind: automated_ui ref: "admin/tests/smoke/extension.smoke.test.ts#partial node allowlist (D-17)" status: pass human_judgment: false - id: D4 description: "Registered toolbar actions render after delete for admins who may run them, post {} with busy state, toast, reload the list and refetch the header partial" requirement: ADMIN-07 verification: - kind: automated_ui ref: "admin/tests/smoke/extension.smoke.test.ts#toolbar actions (D-12, UI-SPEC S4)" status: pass - kind: unit ref: "admin/tests/list/ListToolbar.test.ts (Phase 10 create/delete behaviour unchanged)" status: pass human_judgment: false - id: D5 description: "Framework strings resolve in en and pl, the embedded dist matches a fresh build and the Phase 10 hygiene rules hold" requirement: ADMIN-07 verification: - kind: unit ref: "modules/phrasebook/phase10_test.go#TestPhase10SPAKeysResolve" status: pass - kind: other ref: "scripts/check-admin-dist.sh" status: pass - kind: other ref: "scripts/check-phase10.sh --hygiene" status: pass human_judgment: false - id: D6 description: "Partial style kit and failure box look native in light and dark mode, labels wrap at 768px with pl copy, plugin module scripts load under CSP script-src 'self' and through the Vite /assets dev proxy" verification: [] human_judgment: true rationale: "Visual layout, real module loading and the dev proxy need a real browser; happy-dom neither lays out nor loads module scripts (plan Task 3 human-check, UI-SPEC backstop rows)" duration: 17min completed: 2026-09-29 status: complete --- # Phase 10.1 Plan 02: Runtime admin extension point (SPA half) Summary **The admin SPA now mounts plugin custom elements as form widgets and bridges their summer-action events to the typed widget POST. It renders header and form partials through a client-side h() allowlist, runs registered toolbar actions, and loads each controller's JS and CSS from {base}/assets/ with per-controller stylesheet scoping. The embedded dist is rebuilt.** ## Performance - **Duration:** 17 min - **Started:** 2026-09-28T23:56:52Z - **Completed:** 2026-09-29T00:14:07Z - **Tasks:** 3 - **Files modified:** 29 (24 source and test files plus the dist) ## Accomplishments - `pluginAssets.ts` loads each module script once per URL, and a failed script can retry on a later navigation. It refuses any URL outside `{base}/assets/`, including dot segments, backslashes and control characters. Stylesheet links belong to one controller, and opening another controller's list or form disables them. - `WidgetField` shows a skeleton while it waits for the controller's scripts and `customElements.whenDefined`, with a 5000 ms timeout. It then creates the element and sets attributes only: `record-id`, `field-name`, `locale`, `fill-values`, `label` and `busy-label`. `fill-values` stays in sync as the form changes. On `summer-action` it posts `{record_id, values}` through `api`, ignoring repeat events while `busy` is set. On success it patches only the declared fill keys the server returned, which makes the form dirty without saving it. On failure it shows a danger toast and sets `state="error"`. A load failure shows the S5 failure box. - `partialNodes.ts` and `PartialHost` rebuild the server node tree with `h()` under the server's tag, attribute and URL lists. The first load shows an 80px (header) or 44px (field) skeleton. A refetch keeps the current nodes with `aria-busy`, zero nodes render nothing, and a failure shows the `partial_failed` box. - `type: widget` and `type: partial` are registered as valueless, group-labelled types. They never appear in the save body and they render on create. - The list's `headerPartial` sits between the heading and the list card. It refetches after a bulk delete or a toolbar action, but not on search, filter, sort or page changes. - Registered toolbar actions render after Delete as outline buttons and stay enabled whatever the selection. During their POST of `{}` they are disabled with `aria-busy`. Success shows a toast, reloads the list and refetches the partial. Failure shows a danger toast. - The partial style kit is in `main.css`. The cabana README documents it together with the public `--c-*` variables. The `backend::lang.extension.*` strings exist in en and pl. The Vite dev server proxies `{prefix}/assets`. ## Task Commits 1. **Task 1: widget tracer (assets, form context, WidgetField, strings)** - `107d820` (feat) 2. **Task 2: header and form partials, style kit, README** - `9df9fae` (feat) 3. **Task 3: toolbar actions, per-controller CSS on lists, dev proxy** - `a5e7dac` (feat) ## Files Created/Modified - `admin/src/app/pluginAssets.ts`: asset loader with the same-origin prefix check and per-controller stylesheet ownership - `admin/src/components/form/formContext.ts`: form injection keys and the widget event/timeout constants - `admin/src/components/form/fields/WidgetField.vue`: custom-element host and action bridge - `admin/src/components/form/fields/PartialField.vue`: form row wrapper around PartialHost - `admin/src/components/partial/PartialHost.vue`, `partialNodes.ts`: partial fetch, states and the client allowlist renderer - `admin/src/components/ui/ExtensionFailure.vue`: shared S5 failure box - `admin/src/components/form/registry.ts`, `FormField.vue`: widget/partial registration, valueless set, `groupLabelled`, span label - `admin/src/views/FormView.vue`: form context provider and background asset load - `admin/src/views/ListView.vue`, `admin/src/components/list/ListToolbar.vue`: header partial, toolbar actions, list asset load - `admin/src/styles/main.css`: partial style kit - `admin/vite.config.ts`: `/assets` dev proxy - `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`: `extension:` group - `modules/cabana/README.md`: "Partial style kit and plugin CSS variables" - `admin/tests/smoke/extension.smoke.test.ts` and three `extension.*.json` fixtures - `modules/boardwalk/dist`: rebuilt ## Decisions Made - A widget sends only its fill keys' current values. The server passes nothing else to the action, so sending more would only expose form data. - `loadControllerAssets` resolves with the list of failed script URLs. If a script failed and the element is still undefined, the widget shows its failure box at once instead of waiting out the 5000 ms timeout. - Stylesheet links are keyed by controller and URL. Two controllers of one plugin can share a CSS file without one disabling the other's link. `loadControllerAssets` creates links before toggling them, so every link's `disabled` state is set explicitly. - The client drops the server's removed-with-subtree tags (script, style, svg and so on) along with their subtree. Other unknown tags are unwrapped. This matches the server's behaviour and never turns script text into visible text. - A widget or partial field without a `label` has no label row and no `aria-labelledby`. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Added a shared ExtensionFailure component** - **Found during:** Task 1 - **Issue:** WidgetField, PartialHost and PartialField all need the S5 box. The plan listed no shared component, and copying the markup three times would let the geometry drift. - **Fix:** Created `admin/src/components/ui/ExtensionFailure.vue`, which the smoke test imports to satisfy the hygiene rule. - **Files modified:** admin/src/components/ui/ExtensionFailure.vue - **Committed in:** 107d820 **2. [Rule 3 - Blocking] Added a FORM_ASSETS injection key** - **Found during:** Task 1 - **Issue:** WidgetField has to wait for the form controller's scripts, but it knows neither the controller's assets nor the load promise, which FormView only creates after the schema arrives. - **Fix:** Added `FORM_ASSETS` next to the three planned keys. FormView provides a getter for its load promise. - **Files modified:** admin/src/components/form/formContext.ts, admin/src/views/FormView.vue - **Committed in:** 107d820 **3. [Rule 3 - Blocking] Added the extension strings to the test bundle fixture** - **Found during:** Task 1 - **Issue:** Smoke tests render with `tests/fixtures/lang.json`, which lacked the new keys, so tests could not assert the Polish copy. - **Fix:** Added the four pl `backend::lang.extension.*` entries to the fixture. - **Files modified:** admin/tests/fixtures/lang.json - **Committed in:** 107d820 **4. [Rule 3 - Blocking] Typed the partial fixture with an assertion** - **Found during:** Task 2 - **Issue:** TypeScript widens sibling `attrs` objects in a JSON array with `key?: undefined`, which the generated `{[key]: string}` index signature rejects. - **Fix:** `extensionPartialFixture` uses `as` instead of an annotation, with a comment explaining why. The assertion still refuses a fixture that does not overlap with the schema type. - **Files modified:** admin/tests/fixtures/typed.ts - **Committed in:** 9df9fae **5. [Rule 1 - Bug] Stylesheet activation order** - **Found during:** Task 3 - **Issue:** Calling activateStyles before loadStyles left newly created links without an explicit `disabled` state. - **Fix:** loadControllerAssets now creates the links first and then activates them. - **Files modified:** admin/src/app/pluginAssets.ts - **Committed in:** a5e7dac --- **Total deviations:** 5 auto-fixed (4 blocking, 1 bug) **Impact on plan:** None changes the D-xx contract. The ListToolbar `actions` and `busyAction` props are optional with defaults, so the Phase 10 ListToolbar tests pass unchanged. ## Issues Encountered - happy-dom tries to fetch appended `