From 55c47ca3bc0d291c2eaa3fc2b80e9e934951a06a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 29 Sep 2026 02:15:15 +0200 Subject: [PATCH] docs(10.1-02): complete SPA extension seams plan --- .../10.1-02-SUMMARY.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 .planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md diff --git a/.planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md b/.planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md new file mode 100644 index 0000000..6c23ff7 --- /dev/null +++ b/.planning/phases/10.1-runtime-admin-extension-point/10.1-02-SUMMARY.md @@ -0,0 +1,277 @@ +--- +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 `