--- phase: 10.1-runtime-admin-extension-point plan: 03 type: execute wave: 3 depends_on: [10.1-01, 10.1-02] files_modified: - ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go - ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm - ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml - ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml - ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js - ../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css - ../fonoteka.go/plugins/golem15/fonoteka/admin.go - ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml - ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml - ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go - ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go - ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go - ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go autonomous: true requirements: [ADMIN-07] estimate: tokens: 80000 raw_tokens: 80000 tasks: 3 confidence: low must_haves: truths: - "Per D-01, D-03 and D-11, the Albums list declares `headerPartial: stats` in config_list.yaml and GET /plytadmin/api/v1/golem15/fonoteka/albums/partials/stats returns a statistics strip for the admin's own collection: the total, one item per stored format with count above zero in getFormatOptions order, and a no-shelf item only when that count is above zero." - "Per D-10, every stats count runs through albumsAdminController.scopeAlbums, and the view model is a curated struct of labels and integers, never an Album model; an admin bound to one collection never sees another collection's counts." - "Per D-01, D-02, D-04, D-06, D-07 and D-19, the Albums form declares a `year` number field after `shelf` and a last, full-width `discogs` widget (widget golem15-fonoteka-discogs-lookup, action discogsLookup, fill [year, format]) that renders on create and update; its stub action answers the message key golem15.fonoteka::lang.discogs.stub_filled with fill {year: 1977, format: \"LP\"} and makes no outbound call; Phase 14 replaces the stub." - "Per D-12, the Albums toolbar declares buttons [create, delete, discogsSync]; the discogsSync stub answers golem15.fonoteka::lang.discogs.stub_not_implemented and changes nothing; both Discogs actions require the existing Winter permission golem15.fonoteka.access_albums, so a limited admin gets 403." - "Per D-13 to D-16, the controller declares assets/js/discogs-lookup.js and assets/css/albums.css through AdminJS/AdminCSS, both embedded in the plugin AdminFS and served under /plytadmin/assets/golem15/fonoteka/ with a JavaScript or CSS content type; the element is plain JS with no import, no user-facing string, no network request and no cookie or storage access." - "UI consideration (populated S1 Albums stats strip): one .summer-stats card shows the total, then formats with count above zero in option order, then No shelf when above zero, each a 13px muted label under a 20px/600 value." - "UI consideration (zero-one-many S1): with 0 albums the strip shows All albums 0 and no format items; counts are plain integers and labels are not pluralised, so 0, 1 and many share one layout." - "UI consideration (overflow S1 stats items): the strip uses only the partial style kit classes, so items wrap inside the card with the kit's gaps and never scroll horizontally." - statement: "UI consideration (long-text S1 stat labels): labels wrap without truncation inside min-width 0 items; a visual check with the longest pl labels at 768px width is required." verification: backstop - statement: "UI consideration (long-text S3 widget button and S4 toolbar button): labels are nowrap, the toolbar cluster wraps and the widget button grows past its 160px min-width; a visual check at 768px with the pl labels is required." verification: backstop - "go vet and the fonoteka plugin tests stay green after every task; the Phase 10 tests that pinned the Albums shape (field count, toolbarButtons, built-in field types) are updated in the same commit as the YAML change." artifacts: - path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm" provides: "Albums statistics strip template using the partial style kit" - path: "../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js" provides: "golem15-fonoteka-discogs-lookup custom element" - path: "../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css" provides: "Widget button styles reading only public --c-* variables" - path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go" provides: "PartialData stats view model, AdminJS/AdminCSS, discogsLookup and discogsSync stub actions" - path: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go" provides: "TestPhase101AlbumsSmoke through the assembled router" key_links: - from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml" to: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm" via: "headerPartial: stats" pattern: "headerPartial: stats" - from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go" to: "scopeAlbums" via: "every stats count query" pattern: "scopeAlbums" - from: "../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml" to: "discogsLookup action" via: "widget action key" pattern: "action: discogsLookup" - from: "../fonoteka.go/plugins/golem15/fonoteka/admin.go" to: "assets/js/discogs-lookup.js" via: "//go:embed list" pattern: "assets/js/discogs-lookup.js" prohibitions: - "No real Discogs HTTP client, job or credential lookup (Phase 14)." - "No new permission code; the Winter registerPermissions catalog stays as ported." - "The plugin JS performs no network request and reads no cookie or browser storage." --- ## 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). Prove the extension point on three real Albums surfaces in fonoteka.go: a statistics strip above the Albums list, a "Load from Discogs" widget on the Albums form whose stub fills Release year and Format, and a "Sync with Discogs" toolbar action whose stub toasts. Purpose: D-01 requires the phase to land on Płytarium Albums, not only a toy fixture; D-02 keeps both Discogs actions as stubs that Phase 14 replaces. The work uses only Go, YAML, an html/template partial, plain JS and CSS; no Node step runs in this repository (D-04). Output: Albums controller capabilities, YAML edits, template, JS and CSS assets, pl and en copy, updated Phase 10 tests and a smoke test. Repo: fonoteka.go only (commits land in ../fonoteka.go). The framework it needs (10.1-01 routes, 10.1-02 SPA dist) is consumed through the go.work replace. 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.1-runtime-admin-extension-point/10.1-02-SUMMARY.md @../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go @../fonoteka.go/plugins/golem15/fonoteka/admin.go From 10.1-01 (modules/pact): `AdminClientAssets{AdminJS() []string; AdminCSS() []string}`; `AdminAction{Name, Label string; Permissions []string; Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error)}`; `AdminActionInput{Field string; RecordID *uint64; Record any; Values map[string]any}`; `AdminActionResult{Message string; Fill map[string]any}`; `HasAdminActions{AdminActions() []AdminAction}`; `AdminPartialData{PartialData(ctx context.Context, name string, record any) (any, error)}`. Partial templates live at `{ConfigDir}/_{name}.htm`, get `.Data` as the view model and a `trans "key"` function; output passes through the node allowlist (dl/dt/dd/div/span with class are allowed; style attributes are dropped). Widget tags must start with `golem15-fonoteka-`; fill keys must be writable scalar form fields; asset paths must start with `assets/` and be embedded in AdminFS. From fonoteka.go: `albumsAdminController{db func() *gorm.DB}` with `scopeAlbums(ctx, db) *gorm.DB` (collection scope, `1 = 0` when unbound), `DropdownOptions("format")` returning `models.Album{}.DropdownOptions("format")` (values LP, 2LP, CD, 2CD, MC, Box, EP 7" with `golem15.fonoteka::lang.album_format.*` labels); `models.Album` columns `year` (*int, rule `nullable|integer|between:1889,2100`), `format` (*string, rule `in:LP,2LP,CD,2CD,MC,Box,EP 7"`), `shelf` (*string); permission `golem15.fonoteka.access_albums` (admin_permissions.go). Test helpers: `bootDB(t)`, `assembleTracer(t)`, `phase10CookieAdmin(t, gdb, h, permsJSON)`, `albumsFrontend(t, gdb, email, active)`, `phase10Call(t, h, method, path, body, cookie, ajax)`, `phase10GetJSON`, `adminAPI(rel)`, `testAdminPrefix` = /plytadmin. Tests pinning the current Albums shape: admin_albums_test.go (`len(schema.Fields) != 5` in TestAlbumsAdminRegistration; `"toolbarButtons":["create","delete"]` in TestAlbumsAdminList), admin_phase10_copy_test.go (every controller's toolbarButtons equal "create,delete"), admin_phase10_controllers_test.go (`phase10BuiltinFieldTypes` must contain every served field type; served fields must equal the tracked fields.yaml keys). ## 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 the UI-SPEC "Application proof" rows. - Discretion resolved per UI-SPEC and RESEARCH Open Questions 1 and 3: stats show the total, per-format counts and no-shelf; the widget fills `[year, format]`, which needs the new `year` field (D-19); action names `discogsLookup` and `discogsSync`; both reuse `golem15.fonoteka.access_albums` (Pitfall 12, no Winter catalog divergence); the widget renders on create and update. - Albums has no real form partial; the form `type: partial` path is proven by the acme fixture in 10.1-01 (D-03 discretion). - Tests here are smoke tests; the full Albums acceptance test is 10.1-04 (CLAUDE.md rule 3). ## Artifacts this phase produces - fonoteka methods on `albumsAdminController`: `PartialData`, `AdminJS`, `AdminCSS`, `AdminActions`; view-model types `albumsStatsView` (`Total`, `Formats`, `NoShelf`) and `albumsStatItem` (`Label`, `Count`) - Stub actions `discogsLookup` (fill `year`, `format`) and `discogsSync` - Files: `controllers/albums/_stats.htm`, `assets/js/discogs-lookup.js` (custom element `golem15-fonoteka-discogs-lookup`), `assets/css/albums.css` - YAML: config_list.yaml `headerPartial: stats`, `toolbar.buttons: [create, delete, discogsSync]`; fields.yaml `year` (number) and `discogs` (widget) - Lang keys (en, pl): `golem15.fonoteka::lang.item.year`, `stats.total`, `stats.no_shelf`, `discogs.lookup_button`, `discogs.lookup_label`, `discogs.lookup_comment`, `discogs.stub_filled`, `discogs.sync_button`, `discogs.stub_not_implemented` - Test: `TestPhase101AlbumsSmoke` (subtests stats, widget, assets, toolbar) Task 1: The Albums list shows a statistics strip scoped to the admin's collection 10.1-01 and 10.1-02 are executed: `grep -q 'AdminPartialData' modules/pact/capabilities.go && grep -q 'summer-stats' admin/src/styles/main.css` succeeds from summercms.go. ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go (scopeAlbums, orderedOptions nil-db idiom), ../fonoteka.go/plugins/golem15/fonoteka/models/album.go (albumFormatOptions, columns), ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml and pl/lang.yaml (item, album_format groups), ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go (phase10CookieAdmin), ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go (albumsFrontend), modules/cabana/partial_render.go and modules/cabana/README.md (template contract from 10.1-01), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (Partial style kit markup, Application proof) (1) View model (D-10, T-10.1-15): add `PartialData(ctx context.Context, name string, record any) (any, error)` to albumsAdminController. For `stats` it returns an `albumsStatsView` with `Total int`, `Formats []albumsStatItem` (`Label string` holding the format option's phrase key, `Count int`) and `NoShelf int`; any other name returns an error. Resolve the db like orderedOptions (nil db gives a zero view). Build each of three queries fresh from `db.WithContext(ctx).Model(&models.Album{})` and pass it through `c.scopeAlbums(ctx, q)` (never reuse a chained *gorm.DB between queries): a Count for Total; `Select("format, COUNT(*) AS count").Group("format")` scanned into rows; `Where("shelf IS NULL OR shelf = ''")` Count for NoShelf. Emit Formats in `models.Album{}.DropdownOptions("format")` order, keeping only counts above zero. The view model never holds an Album. (2) Template `controllers/albums/_stats.htm` per the UI-SPEC partial style kit: a `
` whose first `
` has `
{{ trans "golem15.fonoteka::lang.stats.total" }}
{{ .Data.Total }}
`, then `{{ range .Data.Formats }}` one item per format with `{{ trans .Label }}` and `{{ .Count }}`, then `{{ if .Data.NoShelf }}` an item with `{{ trans "golem15.fonoteka::lang.stats.no_shelf" }}` and `{{ .Data.NoShelf }}`. Kit classes only, no inline style, no plugin CSS. With zero albums it still renders the total item with 0. (3) config_list.yaml gains top-level `headerPartial: stats`. admin.go's `//go:embed` list appends `controllers/albums/_stats.htm`. Add a `stats:` group with `total` and `no_shelf` to lang/en/lang.yaml ("All albums", "No shelf") and lang/pl/lang.yaml ("Wszystkie albumy", "Bez półki"). (4) Smoke test: new `admin_phase101_smoke_test.go` with `TestPhase101AlbumsSmoke` and its subtest `stats`: boot with bootDB and assembleTracer, create an admin with `{"golem15.fonoteka.access_albums":1}` through phase10CookieAdmin, bind its email to a collection with albumsFrontend, insert albums in that collection (two LP, one CD, one without shelf) and albums in a second collection; GET `adminAPI("/golem15/fonoteka/albums/schema/list")` shows `"headerPartial":"stats"`; GET `adminAPI("/golem15/fonoteka/albums/partials/stats")` with the cookie and locale en returns 200 whose node tree text contains "All albums", the admin's own total, "LP" with 2, "CD" with 1 and "No shelf", and never the other collection's total. cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestPhase10LangCatalog|TestAlbumsAdminList|TestAlbumsAdminRegistration|TestPhase10ControllerCopy)$' -count=1 -v Non-zero exit; the output lacks "--- PASS: TestPhase101AlbumsSmoke" or "--- PASS: TestPhase101AlbumsSmoke/stats", or prints "no tests to run" or "--- SKIP"; boot fails with a "cabana: admin schema" error. - `grep -c '^headerPartial: stats' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml` prints 1. - `grep -c 'controllers/albums/_stats.htm' ../fonoteka.go/plugins/golem15/fonoteka/admin.go` prints 1. - `grep -c 'scopeAlbums' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go` prints at least 5 (the three stats queries plus the existing list and form scopes). - `grep -c 'summer-stat__value' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm` prints at least 2 and `grep -c 'style=' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm` prints 0. - TestPhase101AlbumsSmoke/stats proves the other collection's albums are not counted. An admin opening Albums gets a server-rendered statistics strip for their own collection above the list. Task 2: The Albums form offers "Load from Discogs", whose stub fills Release year and Format D-06: this is the first application fields.yaml written in the widget key shape; every later ported widget copies it. User-locked, flagged without a checkpoint. ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js, ../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go (TestAlbumsAdminRegistration, TestAlbumsAdminForm), ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go (phase10BuiltinFieldTypes, phase10TrackedKeys), ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go (TestPhase10LangCatalog), modules/cabana/README.md (widget, assets and CSS variable contract from 10.1-01/02), admin/tests/fixtures/extension.form-schema.json (element contract as the SPA uses it), .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S3 plugin element visual contract, Copywriting Contract application rows) (1) fields.yaml (D-06, UI-SPEC Application proof): after `shelf` add `year` (label golem15.fonoteka::lang.item.year, span right, type number), so shelf and year share a row; as the last field add `discogs` with label golem15.fonoteka::lang.discogs.lookup_label, comment golem15.fonoteka::lang.discogs.lookup_comment, type widget, widget golem15-fonoteka-discogs-lookup, action discogsLookup, fill [year, format], span full. No context key: it renders on create and update. (2) Controller (D-02, D-13): `AdminJS()` returns `assets/js/discogs-lookup.js`; `AdminCSS()` returns `assets/css/albums.css`; `AdminActions()` returns `discogsLookup` with Label golem15.fonoteka::lang.discogs.lookup_button, Permissions [golem15.fonoteka.access_albums] and a Run that returns Message golem15.fonoteka::lang.discogs.stub_filled and Fill {"year": 1977, "format": "LP"}. A comment says Phase 14 replaces this stub with the Discogs client; the stub reads no record, no credential and makes no outbound call. Add compile-time assertions that albumsAdminController implements pact.AdminClientAssets, pact.HasAdminActions and pact.AdminPartialData. (3) `assets/js/discogs-lookup.js` (D-04; plain ES module, no import, no Vue, no user-facing string, no network request, no cookie or storage access): a class extending HTMLElement with observedAttributes label, busy-label, busy and state. connectedCallback creates one light-DOM `