Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-03-PLAN.md
Jakub Zych 9b98d8409f docs(10.1): record D-18/D-19, resolve research questions, add pattern map
Plan checker iteration 1 flagged unresolved research questions and a
missing decision note for golang.org/x/net/html. D-18 approves x/net/html
for the partial sanitizer; D-19 fixes the Discogs widget fill to
[year, format]. STATE marks the phase ready to execute.
2026-09-28 22:44:34 +02:00

34 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
10.1-runtime-admin-extension-point 03 execute 3
10.1-01
10.1-02
../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
true
ADMIN-07
tokens raw_tokens tasks confidence
80000 80000 3 low
truths artifacts key_links prohibitions
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 verification
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. backstop
statement verification
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. 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.
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm Albums statistics strip template using the partial style kit
path provides
../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js golem15-fonoteka-discogs-lookup custom element
path provides
../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css Widget button styles reading only public --c-* variables
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go PartialData stats view model, AdminJS/AdminCSS, discogsLookup and discogsSync stub actions
path provides
../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go TestPhase101AlbumsSmoke through the assembled router
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm headerPartial: stats headerPartial: stats
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go scopeAlbums every stats count query scopeAlbums
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml discogsLookup action widget action key action: discogsLookup
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/admin.go assets/js/discogs-lookup.js //go:embed list assets/js/discogs-lookup.js
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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.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 <dl class="summer-stats"> whose first <div class="summer-stat"> has <dt class="summer-stat__label">{{ trans "golem15.fonoteka::lang.stats.total" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd>, 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 <fails_when>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.</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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 <button type="button"> if it does not exist yet, with text from the label attribute. A click dispatches new CustomEvent('summer-action', { bubbles: true, composed: true }) unless the element has busy. attributeChangedCallback keeps the button in sync: with busy present the button is disabled, has aria-busy="true" and shows the busy-label text; otherwise it is enabled and shows label. Define it only when customElements.get('golem15-fonoteka-discogs-lookup') is undefined.

(4) assets/css/albums.css styles only golem15-fonoteka-discogs-lookup button per the UI-SPEC S3 table, reading only public --c-* variables: height 42px, padding 0 16px, min-width 160px, border-radius 10px, 1px solid var(--c-border-strong), background var(--c-surface), color var(--c-text), font: inherit, font-weight 600, white-space nowrap, background var(--c-hover) on hover with a 150ms ease-out transition, :focus-visible outline 3px solid var(--c-ring) with offset 2px, and [disabled] opacity .6 with cursor not-allowed. No hex colours.

(5) admin.go's //go:embed list appends assets/js/discogs-lookup.js assets/css/albums.css. Lang en and pl, using the UI-SPEC strings: item.year ("Release year" / "Rok wydania"); in the existing discogs group lookup_button ("Load from Discogs" / "Wczytaj z Discogs"), lookup_label ("Discogs"), lookup_comment ("Fills in Release year and Format. Click Save to keep them." / "Uzupełnia Rok wydania i Format. Kliknij Zapisz, aby je zachować."), stub_filled ("Release year and Format were filled with test data. Save to keep them." / "Uzupełniono Rok wydania i Format danymi testowymi. Zapisz, aby je zachować.").

(6) Tests pinning the old shape, in the same commit: admin_albums_test.go's form field count becomes 7; admin_phase10_controllers_test.go's phase10BuiltinFieldTypes gains "widget" (the SPA ships its renderer since 10.1-02). Extend TestPhase101AlbumsSmoke with subtests widget (the form schema has the discogs field with widget, action and fill [year format], an actionLabel resolved to "Load from Discogs" in en and "Wczytaj z Discogs" in pl, and assets.scripts naming /plytadmin/assets/golem15/fonoteka/js/discogs-lookup.js?v=; creating an album then POSTing widgets/discogs with its record_id and values returns 200 with fill exactly {year: 1977, format: "LP"} and the resolved stub_filled message; a PUT carrying year 1977 and format LP persists both) and assets (GET the script and stylesheet URLs from the schema returns 200 with text/javascript; charset=utf-8 and text/css; charset=utf-8, nosniff and an ETag; GET /plytadmin/assets/golem15/fonoteka/models/album/fields.yaml is 404). cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestPhase10LangCatalog|TestAlbumsAdminForm|TestAlbumsAdminRegistration|TestAlbumsAdminCRUD|TestPhase10Controllers|TestPhase10AlbumRelations|TestPhase10AssembledAcceptance)$' -count=1 -v <fails_when>Non-zero exit; the output lacks a "--- PASS" line for TestPhase101AlbumsSmoke/widget, TestPhase101AlbumsSmoke/assets, TestPhase10LangCatalog, TestPhase10Controllers or TestPhase10AssembledAcceptance, or prints "no tests to run" or "--- SKIP"; boot fails with a "cabana: admin schema" error.</fails_when> <acceptance_criteria> - grep -c 'action: discogsLookup' ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml prints 1 and grep -c 'widget: golem15-fonoteka-discogs-lookup' ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml prints 1. - grep -cE 'fetch\(|XMLHttpRequest|document\.cookie|localStorage|sessionStorage' ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js prints 0 and grep -cE '^\s*import\s' ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js prints 0. - grep -cE '#[0-9a-fA-F]{3,6}' ../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css prints 0. - grep -c 'golem15.fonoteka.access_albums' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go prints at least 2. - grep -c 'assets/js/discogs-lookup.js' ../fonoteka.go/plugins/golem15/fonoteka/admin.go prints 1. </acceptance_criteria> The Albums form serves a Discogs lookup widget whose stub returns Release year 1977 and Format LP into the unsaved form, with its plain-JS element and stylesheet served from the plugin's embedded files.

Task 3: A "Sync with Discogs" toolbar action toasts from its stub, and the three surfaces pass a browser check D-12: first application list YAML to register a custom toolbar name in toolbar.buttons; user-locked, flagged without a checkpoint. ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.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_copy_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go (Task 2 AdminActions), ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go (TestAlbumsAdminList), ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase101_smoke_test.go, .planning/phases/10.1-runtime-admin-extension-point/10.1-UI-SPEC.md (S4, Copywriting Contract), .planning/phases/10.1-runtime-admin-extension-point/10.1-VALIDATION.md (Manual-Only Verifications) (1) config_list.yaml `toolbar.buttons` becomes `[create, delete, discogsSync]` (D-12). AdminActions gains `discogsSync` with Label golem15.fonoteka::lang.discogs.sync_button, Permissions [golem15.fonoteka.access_albums] and a Run returning Message golem15.fonoteka::lang.discogs.stub_not_implemented and no fill (D-02 stub; a comment names Phase 14; it changes nothing and makes no outbound call). Lang en and pl in the discogs group: sync_button ("Sync with Discogs" / "Synchronizuj z Discogs") and stub_not_implemented ("Discogs sync is in test mode. Nothing was changed." / "Synchronizacja z Discogs działa w trybie testowym. Nic nie zmieniono.").

(2) Tests pinning the old toolbar, in the same commit: admin_albums_test.go's expected "toolbarButtons":["create","delete"] for Albums becomes ["create","delete","discogsSync"]; admin_phase10_copy_test.go keeps "create,delete" for the other four controllers and expects "create,delete,discogsSync" for albums (a per-case expectation). Extend TestPhase101AlbumsSmoke with subtest toolbar: the developer admin's list schema has toolbarActions [{name: discogsSync, label: "Sync with Discogs"}] in en and the pl label in pl; POST adminAPI("/golem15/fonoteka/albums/toolbar/discogsSync") with {}, the cookie and X-Requested-With returns 200 with the resolved stub_not_implemented message and an empty fill; the same POST without X-Requested-With is 403; an admin whose role grants only golem15.fonoteka.access_genres gets 403 on the toolbar, widget and partial routes.

(3) Run the whole fonoteka plugin suite and the Phase 10 hygiene gate from summercms.go to confirm nothing application-specific leaked into the framework. (cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... && go test ./plugins/golem15/fonoteka/... -count=1 && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestAlbumsAdminList|TestPhase10ControllerCopy)$' -count=1 -v) && scripts/check-phase10.sh --hygiene <fails_when>Any command exits non-zero; the verbose run lacks "--- PASS: TestPhase101AlbumsSmoke/toolbar", "--- PASS: TestAlbumsAdminList" or "--- PASS: TestPhase10ControllerCopy", or prints "no tests to run" or "--- SKIP"; check-phase10.sh prints a line starting with "refuse:".</fails_when> In ../fonoteka.go run summer serve (or the built binary's serve command), open http://localhost:8080/plytadmin, log in as an admin bound to a collection and open Albumy with the browser console open. Then: (a) check the statistics strip above the list; (b) click "Synchronizuj z Discogs"; (c) open an album, click "Wczytaj z Discogs", then Zapisz and reload; (d) open the create form; (e) narrow the window to 768px in pl and repeat (a) to (c); (f) switch to another controller such as Gatunki. No CSP or module-loading error in the console (RESEARCH A4: the same-origin module script loads under script-src 'self'); the strip shows Wszystkie albumy with the collection's counts and wraps without horizontal scroll at 768px with labels such as "Cassette (MC)" and "Bez półki" readable; the toolbar action shows its test-mode toast; the widget button shows its label, turns busy during the request, fills Rok wydania 1977 and Format LP without saving, and after Zapisz and reload both values persist; the widget also renders on the create form; button labels do not truncate at 768px; on Gatunki the Albums stylesheet no longer applies. <why_human>CSP enforcement, real module loading and custom-element upgrade happen only in a browser, and the 768px layout backstops need visual judgment; Vitest's happy-dom does not enforce CSP.</why_human> <acceptance_criteria> - grep -c 'buttons: \[create, delete, discogsSync\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml prints 1. - grep -c 'discogsSync' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go prints at least 1 and grep -c 'stub_not_implemented' ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml prints 1. - TestPhase101AlbumsSmoke/toolbar proves the limited admin's 403 on the toolbar, widget and partial routes. - The whole go test ./plugins/golem15/fonoteka/... run passes. </acceptance_criteria> The Albums list offers a permission-gated "Sync with Discogs" action that toasts from its stub, and the three Albums surfaces are ready for the in-browser check.

Source coverage (this plan)

Source Item Task
CONTEXT D-01 proof on Albums (strip, widget, toolbar) 1, 2, 3
CONTEXT D-02 enabled widget button, stub endpoint with fixture payload 2, 3
CONTEXT D-03 strip is list chrome via headerPartial 1
CONTEXT D-04 plain JS custom element 2
CONTEXT D-06, D-07 widget YAML shape and fill write-back 2
CONTEXT D-10 curated view model, escaping on 1
CONTEXT D-11 headerPartial declared in config_list.yaml 1
CONTEXT D-12 third toolbar action registered on the controller 3
CONTEXT D-13 to D-16 controller JS/CSS through AdminJS/AdminCSS, embedded, same-origin 2
UI-SPEC Application proof rows, S1 populated/zero/overflow, backstops 1-3
RESEARCH Open Questions 1, 3; Pitfalls 3, 11, 12, 14; A4 manual UAT 1-3

<threat_model>

Trust Boundaries

Boundary Description
Admin session → Albums stats view model Counts must stay inside the admin's resolved collection
Plugin JS → admin origin Same-origin script shipped by the application
Admin → Discogs stub actions Stub endpoints reachable by any admin who passes the Albums permission

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10.1-10 Elevation of Privilege Albums plugin JS running in the admin origin medium mitigate Plugin JS is trusted compiled code (like plugin Go); the element makes no network request and reads no cookie or storage, and signals only through summer-action (D-05); the session cookie is HttpOnly; 10.1-04's hygiene stage scans plugin asset JS for network and cookie access (Task 2).
T-10.1-15 Information Disclosure Albums statistics strip high mitigate Every stats query is built fresh and scoped with scopeAlbums (collection binding, 1 = 0 when unbound); the view model holds labels and integers only; the smoke test and 10.1-04's two-collection test prove isolation (Task 1).
T-10.1-21 Elevation of Privilege discogsLookup and discogsSync stubs medium mitigate Both require golem15.fonoteka.access_albums on top of the controller permission; a Genres-only admin gets 403 (Task 3).
T-10.1-22 Tampering stub fill payload low accept The stub only patches the unsaved form; nothing persists until the admin saves, and the save runs the Album rules (year between 1889 and 2100, format in the option list).
T-10.1-SC Tampering dependencies high mitigate No Go module or npm package added in fonoteka.go; the element is hand-written plain JS.
</threat_model>
After Task 3: `go vet ./... ./plugins/golem15/fonoteka/...` and `go test ./plugins/golem15/fonoteka/...` pass in fonoteka.go; TestPhase101AlbumsSmoke covers stats, widget, assets and toolbar; `scripts/check-phase10.sh --hygiene` passes in summercms.go. The browser check in Task 3 is collected at /gsd-verify-work.

<success_criteria>

  • The Albums list shows a collection-scoped statistics strip; the Albums form shows the Discogs lookup widget filling Release year and Format; the Albums toolbar offers Sync with Discogs, both Discogs actions as stubs.
  • All application copy exists in pl and en; the Phase 10 Albums tests reflect the new shape; no framework file names the application. </success_criteria>
Create `.planning/phases/10.1-runtime-admin-extension-point/10.1-03-SUMMARY.md` when done.