From ec9700763721e060629c358d0a60a4bc56d4a1c2 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 27 Sep 2026 13:23:46 +0200 Subject: [PATCH] docs(10): research admin Vue SPA phase --- .../phases/10-admin-vue-spa/10-RESEARCH.md | 791 ++++++++++++++++++ .../phases/10-admin-vue-spa/10-VALIDATION.md | 88 ++ 2 files changed, 879 insertions(+) create mode 100644 .planning/phases/10-admin-vue-spa/10-RESEARCH.md create mode 100644 .planning/phases/10-admin-vue-spa/10-VALIDATION.md diff --git a/.planning/phases/10-admin-vue-spa/10-RESEARCH.md b/.planning/phases/10-admin-vue-spa/10-RESEARCH.md new file mode 100644 index 0000000..db8b923 --- /dev/null +++ b/.planning/phases/10-admin-vue-spa/10-RESEARCH.md @@ -0,0 +1,791 @@ +# Phase 10: Admin Vue SPA - Research + +**Researched:** 2026-09-27 +**Domain:** Vue 3 + TypeScript admin SPA embedded in a Go binary; `cabana` admin API amendments (prefix, cookie auth, relation options/save, messages, toolbar, string bundle, framework-owned OpenAPI) +**Confidence:** HIGH for the codebase baseline and the Go/OpenAPI pipeline (read or probed this session); MEDIUM for the JS stack choices (registry-verified, team-precedent-backed) + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +#### Packaging and serving +- **D-01:** The SPA source lives in `summercms.go/admin` (Vite project). Its built `dist/` is embedded through `embed.FS` in a framework package and served by the binary. No separate deploy, no CORS. +- **D-02:** The mount path is configurable (`backend.uri`, Winter-style), e.g. `/plytadmin`, `/manage`, `/horoadmin`, chosen per project so the admin location is not guessable. The committed build is path-agnostic: the Go server injects the configured base path into `index.html` at serve time. Client-side routes are handled by an SPA fallback under the prefix. — **Reversibility:** reversible +- **D-03:** The admin API moves with the prefix: `{backend.uri}/api/v1/...` replaces the hardcoded `/_admin/api/v1` from Phase 9 (D-09 there). This is safe because nothing outside the SPA calls the admin API yet (Nuxt and fonoteka-mcp never touch it). The `api` segment under the prefix is reserved and never used as an SPA route. OpenAPI paths are written relative (`/navigation`, `/auth/login`) and the SPA client gets its base URL at runtime. — **Reversibility:** costly — every admin route, test and the OpenAPI document are keyed to the prefix scheme. +- **D-04:** The built `dist/` is committed, with a check script that rebuilds and fails on drift (same pattern as `check-openapi.sh`). Rationale agreed with the user: everything an app plugin changes in the admin (YAML columns/fields, navigation, permissions, hooks, and in 10.1 partials and JS/CSS assets) is embedded in the plugin and needs only a Go binary rebuild; the Node build is needed only when the framework SPA itself changes (new built-in field type, layout). App developers never need Node. +- **D-05:** Extension seam only: a field-type renderer registry (`FieldRenderer` maps `type` to component). Any unregistered type renders the design's `UnsupportedField` box ("Nieobsługiwany typ pola: ``") instead of breaking the form. The full extension point is Phase 10.1 (see Deferred). + +#### Look and components +- **D-06:** The design reference is Direction C v2 from claude.ai/design, stored at `.planning/phases/10-admin-vue-spa/design/` (README.md is the spec; `Direction C v2.dc.html` + `support.js` is the viewable reference). High fidelity: tokens, typography, radii, spacing, control heights, focus rings, copy, a11y roles, states (selected, empty, loading, 422, toast, modal), responsive rules (collapse below ~1100px, tablet width), light and dark mode. Build the generic components it lists (PluginRail, SectionPanel, SectionFlyout, AppShell, DataTable, ListToolbar, Pagination, FormTabs, FormGrid, FormField, FieldRenderer, RelationManager, RelationPickerModal, Toast, UserMenu). +- **D-07:** Stack: Vue 3 + Vite + TypeScript + Tailwind + Reka UI primitives + lucide-vue-next icons (same family as `vue-fonoteka-app`). DM Sans and DM Mono are self-hosted inside `dist/`, not loaded from Google Fonts (works offline, no third-party requests from a hidden admin URL). +- **D-08:** Real YAML only. Płytarium screens render exactly what their YAML declares (Albums: name, artists, format, genre, shelf; no tabs). The mock's extra fields, tabs and columns (Opis, Wypożyczony, Ulubiony, Utworzono, Szczegóły tab) are component fixtures for tests, not additions to Płytarium. +- **D-09:** The SPA follows Phase 9's API contract where the mock differs: error envelope `{"error": {code, message, details}}` with 422 details as field → messages (not the mock's `{errors}`); list query and meta per Phase 9 D-11. +- **D-10:** Routes derive from controller IDs: `golem15.fonoteka.albums` → `{backend.uri}/golem15/fonoteka/albums`, `/create`, `/:id`. The rail groups by plugin from `/navigation`; the active rail item comes from the route's owning plugin. Items the admin lacks permission for are absent (server-filtered), and a plugin whose side menu is empty after filtering is absent from the rail. +- **D-11:** Navigation icons are lucide names in the registry. The SPA also carries a small map from Winter `icon-*` names to lucide so ported Winter plugins work unchanged; unknown names get a neutral fallback icon. `fonoteka.go` switches its navigation and settings icons to lucide names. +- **D-12:** The Albums format column renders as plain text in Phase 10 (no icon pills). + +#### Controller copy and toolbar +- **D-13:** `config_list.yaml` and `config_form.yaml` accept an optional `messages:` block of phrasebook keys (e.g. `recordCount`, `searchPrompt`, `deleteConfirm`, `empty` on lists; `create`, `saved` on forms). Keys are resolved server-side in the admin's locale. Plural keys are served with all their CLDR forms (`{"one": ..., "few": ..., "many": ..., "other": ...}`) and the SPA picks the form with `Intl.PluralRules(locale)`, because only the client knows the count (e.g. selected rows). Placeholders `{count}`, `{name}`, `{term}` are interpolated by the SPA. Every key is optional; the framework ships generic defaults (e.g. "Nowy rekord", "Usunąć zaznaczone ({count})?", "Zapisano"). Unknown `messages` keys fail at boot (Phase 9 `DisallowUnknownField` rule). +- **D-14:** Toolbar is declarative: `toolbar.buttons: [create, delete]` lists built-in actions in display order (`create` gated by create permission; `delete` is bulk delete, enabled with a selection, confirming with `deleteConfirm`), plus `toolbar.search`. A string value (Winter's `buttons: list_toolbar` partial) is a boot error pointing at the new syntax. The five Płytarium config files are updated accordingly. Custom actions are Phase 10.1. — **Reversibility:** costly — changes the YAML contract every ported controller writes. + +#### Types and relations +- **D-15:** The SPA's types come from a framework-owned admin OpenAPI document: swag v1 annotations on `cabana` handlers, generated in `summercms.go`, converted to OpenAPI 3 (reuse the `swagger2openapi.go` approach), committed, and fed to openapi-typescript, with a drift check. The framework SPA never reads an app repo's document. `fonoteka.go/docs/openapi.json` remains the parity API document. +- **D-16:** Records are typed generically: generated types cover envelopes, form/list/filter/relation/navigation/settings schemas, list meta and errors; a record is `Record` read through its field schema. No per-controller TypeScript types and no hand-maintained duplicates of API shapes (success criterion 4). +- **D-17:** Relation fields get their choices from a new endpoint, `GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/options?search=&page=&per_page=`, returning `{value, label}` with the label from `nameFrom`, on the Phase 9 D-11 list contract. An optional `RelationExtendOptionsQuery(ctx, field, *gorm.DB) *gorm.DB` hook on the admin controller lets apps scope options (e.g. to the active collection). Permission gating is the controller's. The `emptyOption` stays a schema property rendered by the SPA. +- **D-18:** Form saves send relation values keyed by the YAML field name with ids: `{"genre": 3, "artists": [4, 9]}`. `cabana` maps belongs-to to the foreign key and syncs many-to-many through the Phase 5 join-table contract inside the save transaction, after `FormBeforeCreate`/`FormBeforeUpdate`. Record GET returns the same shape plus display labels so the form can show chips and the selected dropdown value without extra calls. + +#### Session and UX +- **D-19:** The admin JWT moves into an httpOnly, Secure, SameSite=Strict cookie scoped to `{backend.uri}`, set by login and refresh and cleared by logout. JS never reads the token (this matters once 10.1 lets plugin JS run in the admin origin). The `backend` guard accepts the cookie and still accepts `Authorization: Bearer` for CLI and tests. State-changing requests must carry a custom header (e.g. `X-Requested-With`) as CSRF defence in depth. It is still a JWT with the Phase 7 blacklist and sliding refresh; no session store. — **Reversibility:** costly — changes Phase 9's auth transport and the security matrix tests that pin it. +- **D-20:** The SPA's own UI strings (Zapisz, Anuluj, Wyloguj, pagination, confirmations, defaults from D-13) live in the framework's phrasebook (`backend::lang`, pl and en) and are fetched as a resolved bundle for the admin's locale at startup. Projects can override or add locales without a Node rebuild. +- **D-21:** The settings screen is in scope: the rail's "Ustawienia" lists `HasSettings` pages the admin may manage and renders each through the same form renderer against the Phase 9 settings endpoints (fonoteka: `search_use_typesense`). +- **D-22:** The filter bar is in scope: it renders `config_filter.yaml` scopes of the three Phase 9 shapes (switch, daterange, model-backed) and drives `filter[]`. Płytarium has no filters, so it is verified with fixtures. +- **D-23:** Testing follows the lean rules: Vitest component and unit tests as the phase's last plan; Go tests for every `cabana` change. Browser e2e (Playwright) is not part of Phase 10. + +### Claude's Discretion +- SPA state management (Pinia or composables), router setup, HTTP client (openapi-fetch or a thin wrapper over generated types). +- Dev loop: Vite dev server proxying the API vs rebuilding into the embedded path; recommended a Vite proxy to a running `summer serve` for development. +- Exact `messages` key vocabulary beyond the examples, and the generic default wording. +- Refresh timing (proactive before expiry plus one retry on 401, then redirect to login with the return URL). +- Per-page options (design says 12/24/48/96; reconcile with `recordsPerPage` and the Phase 9 cap). +- Unsaved-changes confirmation, create-then-redirect behaviour (use `config_form` `create.redirect`/`redirectClose` semantics mapped to SPA routes), toast queue. +- Whether admin paths are removed from `fonoteka.go/docs/openapi.json` once the framework document exists (recommended: yes, one owner per path). +- Package layout inside `summercms.go` for the embed/serve package, following beach-themed naming. + +### Deferred Ideas (OUT OF SCOPE) +- **Phase 10.1: runtime admin extension point.** `AdminAssets()` on controllers (Winter `addJs`/`addCss`), served under the admin prefix and loaded when the controller opens; `type: widget` rendered as custom elements (plugins ship plain JS, no Vue coupling); `type: partial` rendered server-side via html/template; custom toolbar actions; a dev-mode switch serving plugin assets from disk. Needs a roadmap entry. +- Ctrl+K command palette across all plugins' side menus. +- Generic badge/icon column type (Albums format pills with disc/cassette icons). +- Playwright browser e2e for the admin SPA (joins the Phase 8 carried-forward Playwright follow-up). +- Admin navigation for the user and media plugins (shown in the mock) arrives with those ports. + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| ADMIN-06 | A minimal Vue 3 + TypeScript SPA renders login, permission-gated navigation, lists, forms and the relation manager for Albums, Artists, Collections, Genres and Styles using generated types | Baseline of `cabana` (§Current State), prefix + serving (§Pattern 1–2), cookie auth (§Pattern 3), OpenAPI → TS pipeline probed end-to-end (§Pattern 4), relation options/save (§Pattern 5), messages/bundle (§Pattern 6), SPA structure (§Pattern 7–9), hidden prerequisites (§Gaps Found), Validation Architecture maps the four success criteria to tests | + + +## Project Constraints (from CLAUDE.md) + +- **Lean planning:** few, large plans; present a plan-count checkpoint (one-line scope each) before writing PLAN.md files. +- **Unit tests are the last plan of the phase.** Earlier plans may include smoke tests but are not blocked on coverage. +- **Go stdlib first:** `net/http` ServeMux, `html/template`, `encoding/json`. No new Go dependency unless research/decision names it. This phase needs **zero new Go module dependencies** (swag runs via `go run ...@v1.16.6`, not a `require`). +- **`go vet ./...` and `go test ./...` green at every commit**, in both repos. +- **Compiled plugins only;** no runtime plugin loading (the SPA extension point is 10.1). +- **Two repositories:** `summercms.go` is framework-only and must contain **no Płytarium names** — this includes SPA source, SPA test fixtures and the framework OpenAPI doc. `fonoteka.go` carries the YAML/registry/lang/icon updates. Planning docs stay in `summercms.go/.planning`. +- **Commits:** no co-author tags; one logical change per commit; planning docs and code in separate commits. +- **Core plugin contracts** (user, blog, pages, payment) must not break — the PHP originals are not touched. +- **GSD workflow:** file edits happen through GSD commands. +- Global user rule: never add co-author tags to commit messages. + +## Summary + +Phase 9 left `cabana` as a complete, well-tested admin API, but hardcoded at `/_admin/api/v1`, Bearer-only, with untyped `SuccessEnvelope{Data any}` swag annotations that are consumed by the **app** repo's OpenAPI run and cross-read by a **framework** test (`cabana/phase09_contract_test.go` reads `../../fonoteka.go/docs/openapi.json`). Relation form fields (`type: relation`) are schema-only today: `scalarFormField` excludes them and `nestedValue` drops arrays, so saves silently ignore `genre`/`artists`. Toolbar buttons are a Winter string (`buttons: list_toolbar`) compiled to `["create"]`. There is no `backend::` phrasebook namespace at all and — a bigger surprise — **the fonoteka plugin ships no lang files**, so every `golem15.fonoteka::lang.*` label the SPA would render currently comes back as the raw key. These gaps are prerequisites the planner must schedule, not polish. + +The Go side is well served by what exists: surf compiles routes onto `http.ServeMux` with `METHOD /path` patterns, so the SPA fallback is two `GET` routes (`{prefix}` and `{prefix}/{path...}`) in a raw group; the bouncer JWT guard already supports cookie extraction (`NewJWTGuard(..., cookieNames...)`) — the backend constructor simply doesn't pass a cookie name. The OpenAPI pipeline was probed end-to-end this session: swag v1.16.6 handles Go generics (`Envelope[[]Option]`), `--requiredByDefault` marks non-`omitempty` fields required, opaque structs like `jsonScalar` become a single named definition that a converter can rewrite to a `oneOf` union, and openapi-typescript 7.13.0 produces a clean `paths` type for openapi-fetch. + +On the JS side, pin the versions the team already runs in `vue-fonoteka-app` (Vue 3.5, Vite 7.3, Tailwind 4.3 via `@tailwindcss/vite`, Reka UI 2.9, Vitest 3.2 + happy-dom + @vue/test-utils, TypeScript 5.9 — openapi-typescript's peer is `typescript ^5.x`). `lucide-vue-next` is **deprecated** on npm in favour of `@lucide/vue` (same Lucide family, already in the team's app). Build with Vite `base: './'` and have Go rewrite `index.html` once at startup (asset URLs + a `` carrying the prefix) — no `` element, no inline script. + +**Primary recommendation:** Start with a tracer plan that delivers `backend.uri` + prefix-mounted API + cookie/CSRF auth + an embedded SPA (login → navigation shell → Genres list) + the framework OpenAPI→TS pipeline, then grow the backend contract (options, relation save, messages, toolbar, bundle, lang namespaces) before the remaining SPA screens; finish with the Vitest/Go coverage plan and a `check-phase10.sh` gate. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Admin prefix (`backend.uri`) resolution + validation | API / Backend (cabana, config) | — | Config is server state; routes and cookie Path derive from it | +| Serving `dist/`, index.html injection, SPA fallback | Frontend Server (Go `boardwalk` package) | CDN/Static (embedded FS) | Same binary, same origin; injection is per-config, not per-request | +| Login, refresh, logout, cookie issuance, CSRF header check | API / Backend (cabana + bouncer guard) | Browser (sends header, never reads token) | httpOnly cookie means the browser cannot hold auth logic | +| Permission filtering of navigation/settings | API / Backend | Browser (renders only what it gets) | D-10: server-filtered, SPA never hides items itself | +| List/form/filter/relation schema compilation | API / Backend (boot-time YAML compile) | — | Existing Phase 9 pipeline; SPA is a pure renderer | +| Relation options (search/paginate/scope) | API / Backend | Browser (debounced search UI) | Scope hooks and permissions are server-side | +| Relation value mapping on save (FK + pivot sync) | API / Backend (+ Database) | — | Must run inside the save transaction with hooks | +| Plural form selection for messages | Browser (`Intl.PluralRules`) | API (serves all CLDR forms) | Only the client knows the count (D-13) | +| UI strings bundle | API / Backend (phrasebook) | Browser (cache for session) | Overrides/locales without Node rebuild (D-20) | +| Routing, list state in URL, toasts, dirty-guard | Browser / Client | — | Pure client concerns | +| TS types for API | Build-time (swag → OpenAPI 3 → openapi-typescript) | — | No hand-written duplicates (SC-4) | + +## Current State of `cabana` After Phase 9 (verified baseline) + +Every row below was read this session. Quotes are verbatim. + +| Area | Current code | Phase 10 change | +|------|--------------|-----------------| +| Route mount | `cabana/http.go:124-128`: `r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) {` / `g.Post("/login", s.login, throttle)` / `g.Post("/refresh", s.refresh)` / `})` / `r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {` [VERIFIED: cabana/http.go:122-166] | Prefix from config; add `/fields/{field}/options`, `/lang` (bundle), SPA routes (D-03, D-17, D-20, D-02) | +| Issuer | `cabana/auth.go:405-414`: `return "/_admin/api/v1/auth/login"` / `return base + "/_admin/api/v1/auth/login"` [VERIFIED: cabana/auth.go:405-414] | Derive from prefix | +| Login body | `"access_token": token,` `"token_type": "bearer",` [VERIFIED: cabana/auth.go:175-178] | Cookie transport omits the token from the body (see Pattern 3) | +| Token extraction in handlers | `bearerToken` reads only `r.Header.Get("Authorization")` with `strings.CutPrefix(value, "Bearer ")` [VERIFIED: cabana/auth.go:269-279] | `refresh`/`logout` must read the cookie too | +| `/auth/me` | `profileOf` returns `"id"`, `"login"`, `"email"`, `"first_name"`, `"last_name"`, `"is_superuser"` and `data["role"] = map[string]any{"id": ..., "code": ..., "name": ...}` [VERIFIED: cabana/auth.go:250-266] | Role name **already present** — UserMenu needs no backend change | +| Backend guard | `// NewBackendJWTGuard is bearer-only and requires AudienceBackend.` / `func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error)) Guard` [VERIFIED: bouncer/jwt.go:90-101] | Pass a cookie name | +| Cookie support exists | `func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard` and `extractToken` tries `bearerToken(r)` first, then each `r.Cookie(name)` [VERIFIED: bouncer/jwt.go:86-88, 244-263] | Reuse; bearer still wins when present | +| Toolbar | `type listToolbar struct { Buttons string \`yaml:"buttons"\` ...}` and `case "list_toolbar": return []string{"create"}, nil` [VERIFIED: cabana/list_schema.go:47-53, 273-283] | `buttons` becomes a list `[create, delete]`; string is a boot error (D-14) | +| Form config doc | `formConfigDocument` fields `Name`, `Form`, `ModelClass`, `DefaultRedirect`, `Create *formRedirects`, `Update *formRedirects` [VERIFIED: cabana/form_schema.go:40-52] | Add `Messages`; also surface `create.redirect`/`redirectClose` to the SPA (discretion item) | +| Strict YAML | `yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())` [VERIFIED: cabana/schema.go:13-19] | A fixed `messages` struct gets unknown-key rejection for free | +| Field types | `"text": {}, "textarea": {}, "number": {}, "checkbox": {},` `"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},` [VERIFIED: cabana/form_schema.go:23-26] | SPA `FieldRenderer` registry covers exactly these + `UnsupportedField` fallback | +| Writable fields | `scalarFormField`: `case "text", "textarea", "number", "checkbox", "switch", "dropdown": return true` [VERIFIED: cabana/crud.go:744-751]; `nestedValue` returns true for `map[string]any, []any` and those keys are dropped [VERIFIED: cabana/crud.go:765-772] | Relation fields are currently **ignored on save** — D-18 is net-new code | +| Protected FKs | `protectedFillKey`: `"id", "created_at", "updated_at", "deleted_at",` `"owner_id", "user_id", "collection_id", "organisation_id", "organization_id",` `"scope_id", "role_id", "permissions",` `"is_superuser", "is_system", "is_activated", "password"` [VERIFIED: cabana/crud.go:753-763] | Collections `owner` → `owner_id` collides with this list (Open Question 2) | +| Record projection | `projectRecord` emits `id` + `cc.Writable` fields only [VERIFIED: cabana/crud.go:695-720] | Add relation values + labels (D-18) | +| Relation contract | `// Framework code never guesses a plugin pivot table or foreign key.` `type RelationContract struct { Name string; NewRelated func() any; NewPivot func() any; ParentForeignKey string; RelatedForeignKey string; Columns map[string]string; HookPivotColumns []string; ExcludedRelatedIDs func(parent any) ([]uint, error) }` [VERIFIED: cabana/relation.go:20-35] | Extend for form `relation` fields (belongs-to FK, pivot order column) | +| Navigation JSON | `NavigationEntry{Code, Label, Icon, Order, Controller, SideMenu}` with JSON `code,label,icon,order,controller,sideMenu` [VERIFIED: cabana/navigation.go:13-20] | Unchanged; SPA derives URL from `controller` | +| Option value type | `type Option struct { Value string \`json:"value"\`; Label string \`json:"label"\` }` [VERIFIED: pact/capabilities.go:183-186] | Options endpoint should emit numeric ids (D-18 sends numbers) — use a cabana type, not `pact.Option` | +| Page sizes | `pageSizeAllowed`: with no `perPageOptions`, only `n == schema.RecordsPerPage` is accepted [VERIFIED: cabana/query.go:180-194] | Fonoteka lists declare `recordsPerPage: 20` and no `perPageOptions` → SPA must render the schema's options, not 12/24/48/96 | +| Route inventory test | `var phase09Routes = []struct {` ... entries `"POST /_admin/api/v1/auth/login"` ... [VERIFIED: cabana/security_coverage_test.go:17-43] | Rewrite to prefix-relative keys; add new routes | +| Cross-repo test read | `specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "fonoteka.go", "docs", "openapi.json"))` [VERIFIED: cabana/phase09_contract_test.go:19] | Point at the framework doc (D-15) | +| OpenAPI generation | `go run github.com/swaggo/swag/cmd/swag@v1.16.6 init` `--dir plugins/golem15/fonoteka,../summercms.go/cabana` `--generalInfo controllers/genre_controller.go` ... `--parseDependency` [VERIFIED: ../fonoteka.go/scripts/check-openapi.sh:11-16] | Framework runs its own; fonoteka drops `../summercms.go/cabana` | +| Router core | `mux.Handle(rt.method+" "+rt.path, h)` inside `handleRoute`, which converts ServeMux conflict panics to errors [VERIFIED: surf/router.go:359-367] | SPA fallback uses `{path...}` wildcard | +| Request locale | `towel.WithLocale(r.Context(), r.Header.Get("Accept-Language"))` [VERIFIED: surf/router.go:665-668] | The "admin's locale" is the browser's Accept-Language (backend users have no locale column) | + +**Hardcoded prefix blast radius:** `grep -c "_admin/api/v1"` finds ~92 occurrences in `summercms.go` (31 in `admin_openapi.go`, 23 in `security_coverage_test.go`, 22 in `auth_test.go`, rest in tests) and ~142 in `fonoteka.go` admin tests, plus `docs/openapi.json` [VERIFIED: grep this session]. No proxy/deploy config references `/_admin` [VERIFIED: grep of *.conf/*.yml/Caddyfile/Dockerfile found none]. + +## Gaps Found (hidden prerequisites — plan these explicitly) + +1. **No `backend::` namespace exists.** `phrasebook.Activate` loads only `cat.Load("lagoon", systemLangFS)` and then each `pact.HasLang` plugin [VERIFIED: phrasebook/translator.go:86-117]; `phrasebook/lang/` contains only `en/validate.yaml` and `pl/validate.yaml` [VERIFIED: find]. Keys already used in YAML — `backend::lang.list.no_records`, `backend::lang.list.search_prompt`, `backend::lang.form.none`, `backend::lang.list.column_created`, `backend::lang.form.update`, `backend::lang.list.delete_selected` — resolve to the raw key today (`GetIn` returns `key` when `find` misses [VERIFIED: phrasebook/translator.go:134-146]). Winter's Polish source values exist at `/media/nvme/dev/golem15/fonoteka/modules/backend/lang/pl/lang.php` (e.g. `'no_records' => 'Brak rekordów w tym widoku.'`, `'search_prompt' => 'Szukaj...'`, `'save_and_close' => 'Zapisz i zamknij'`) [VERIFIED: grep]. +2. **The fonoteka plugin has no lang files and no `LangFS()`.** Only `plugins/golem15/user/lang/{en,pl}/lang.yaml` exist and only the user plugin implements `LangFS` [VERIFIED: find + grep in fonoteka.go]. The PHP source is `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/{en,pl}/lang.php` (148 lines each; e.g. `'menu_label' => 'Fonoteka'`, `'item' => ['menu_label' => 'Albumy', ...]`) [VERIFIED: read]. Without porting it, the rail, columns, fields and titles render raw keys and success criteria 1–3 cannot pass visually. +3. **Collections has no navigation entry** in either the Go port or the PHP original (`sideMenu` = albums, genres, styles, artists) [VERIFIED: fonoteka.go/.../admin_navigation.go; PHP Plugin.php:423-460]. The design mock shows "Kolekcje". See Open Question 1. +4. **Namespace overrides are impossible today:** `Catalog.Load` fails with `"duplicate namespace owner %q"` when a namespace is loaded twice, and `put` fails on `"duplicate key"` [VERIFIED: phrasebook/loader.go:69-71, 215-229]. D-20's "projects can override or add locales" needs a new, explicit override layer. +5. **Placeholder syntax mismatch:** phrasebook interpolates Laravel `:name` / `:Name` / `:NAME` placeholders [VERIFIED: phrasebook/translator.go:420-450] and Winter's backend strings use them (`'create_title' => 'Nowy :name'`); D-13 writes `{count}`, `{name}`, `{term}`. See Open Question 3. +6. **Model-backed filter scopes have no choices source.** `ListFilter` for `type: scope` carries `ModelClass` (a PHP class string) and `NameFrom`, but nothing serves its options [VERIFIED: cabana/filter_schema.go:183-186, schema_types.go:24-35]. See Open Question 4. +7. **Page-size mismatch:** design says 12/24/48/96; Phase 9 only accepts `perPageOptions` or `recordsPerPage` (fonoteka: `[20]`). The SPA must render `schema.perPageOptions` and hide the "Na stronę" select when it has one entry. +8. **`recordUrl` is Winter-shaped** (`golem15/fonoteka/albums/update/:id`) and `create.redirect` likewise [VERIFIED: fonoteka.go controllers/*/config_*.yaml]; the SPA must map these onto D-10 routes (`update/:id` → `/:id`), not use them verbatim. +9. **SPA reserved segments:** controller IDs map to `/{vendor}/...`; a vendor named `api`, `login` or `settings` would collide with reserved SPA/API segments. Add a boot check in the registry. + +## Standard Stack + +### Go side (no new module dependencies) +| Tool/Package | Version | Purpose | Why | +|---|---|---|---| +| `net/http` ServeMux via surf | Go 1.27.0 | `GET {prefix}/{path...}` SPA fallback | Already the router core [VERIFIED: surf/router.go:359-367] | +| `embed` (`//go:embed all:dist`) | stdlib | Ship `dist/` in the binary | D-01; `all:` needed for `_`-prefixed chunk names | +| `io/fs`, `mime`, `net/http.ServeContent` | stdlib | Serve assets with correct types/ETag-less caching | stdlib-first | +| swag (tool, `go run ...@v1.16.6`) | v1.16.6 | Swagger 2.0 from cabana annotations | Already the project tool; generics + `--requiredByDefault` verified this session | +| `swagger2openapi.go` (copied into `summercms.go/scripts/`) | local | Swagger 2 → OpenAPI 3.0.3 + scalar/context union rewrite | Existing fonoteka converter [VERIFIED: ../fonoteka.go/scripts/swagger2openapi.go] | + +### SPA (`summercms.go/admin`) +| Library | Version to pin | Purpose | Why this version | +|---|---|---|---| +| vue | ^3.5.35 | UI | Team's installed 3.5.35 [VERIFIED: vue-fonoteka-app node_modules]; registry latest 3.5.43 [VERIFIED: npm view] | +| vite | ^7.3.6 | Build/dev server | Team runs 7.3.5 [VERIFIED: .pnpm dir]; Vitest 3.2 peers `vite ^5‖^6‖^7` — Vite 8 (latest 8.3.1) would force Vitest 4/5 | +| @vitejs/plugin-vue | ^6.0.8 | SFC compile | Peer `vite: '^5.0.0 \|\| ^6.0.0 \|\| ^7.0.0 \|\| ^8.0.0'` [VERIFIED: npm view] | +| typescript | ~5.9.3 | Types | openapi-typescript peer `typescript: '^5.x'` [VERIFIED: npm view]; do **not** take TS 7.0.2 (latest) | +| vue-tsc | ^3.3.11 | SFC typecheck in build | Peer `typescript: '>=5.0.0'` [VERIFIED: npm view] | +| vue-router | ^5.1.0 | History routing with runtime base | Team already on 5.1.0 via Nuxt [VERIFIED: .pnpm dir]; `vite`/`pinia` peers are optional [VERIFIED: npm view peerDependenciesMeta] | +| tailwindcss + @tailwindcss/vite | ^4.3.0 | Styling, CSS-first `@theme` tokens | Team's 4.3.0; v4 dark variant via `@custom-variant` [CITED: tailwindcss.com/docs/dark-mode] | +| reka-ui | ^2.9.10 | Headless primitives (Dialog, DropdownMenu, Tabs, Switch, Checkbox, Combobox/Listbox, Tooltip) | Team's 2.9.10 | +| @lucide/vue | ^1.17.0 | Icons | `lucide-vue-next` is **deprecated**: "Package deprecated. Please use @lucide/vue instead." [VERIFIED: npm view lucide-vue-next deprecated]; team already depends on `@lucide/vue` 1.17.0; `sideEffects: false`, named exports like `Disc3`, `CircleAlert`, `Trash2` [VERIFIED: tarball] | +| openapi-fetch | 0.17.0 | Typed fetch client from `paths` | Zero runtime deps, ~6 kB, `createClient({baseUrl, credentials, headers})`, `client.use({onRequest,onResponse})` [CITED: openapi-ts.dev/openapi-fetch] | +| openapi-typescript (dev, via script) | 7.13.0 | `paths` types from OpenAPI 3 | Same pin as fonoteka's pipeline; probed this session | +| @fontsource/dm-sans, @fontsource/dm-mono | 5.3.0 | Self-hosted fonts | `400.css` etc. include latin + latin-ext with `unicode-range` [VERIFIED: tarball] | +| vitest | ^3.2.7 | Unit/component tests | Team's version; matches Vite 7 | +| @vue/test-utils | ^2.4.11 | Component mounting | Team's version | +| happy-dom | ^20.11.6 | DOM env | Team's version and config precedent (`environment: 'happy-dom'`) [VERIFIED: vue-fonoteka-app/vitest.config.ts] | + +**No Pinia.** Global state (auth user, navigation, strings bundle, toasts, sidebar collapsed) is five small module-level `reactive`/`ref` composables; this keeps the dependency list aligned with the stdlib-first ethos. (Claude's discretion.) + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|---|---|---| +| Vite 7 + Vitest 3 | Vite 8.3 + Vitest 4.1 | Newer (Rolldown); team has no Vite 8 experience; more churn for no phase benefit | +| `@lucide/vue` | `lucide-vue-next` (as D-07 literally says) | Deprecated, frozen at 1.0.0 (May 2026) [VERIFIED: npm view]; same icon family, so `@lucide/vue` honours D-07's intent — confirm with user (A2) | +| openapi-fetch | thin hand wrapper over `paths` | Re-implements path/query typing; openapi-fetch already does it | +| composables | Pinia 3 | Extra dep; devtools niceties only | +| npm + `package-lock.json` | pnpm (team's Nuxt app) | npm ships with Node and `npx` is already used by gate scripts; pnpm is fine if preferred (A5) | + +**Installation (inside `summercms.go/admin`):** +```bash +npm install vue@^3.5.35 vue-router@^5.1.0 reka-ui@^2.9.10 @lucide/vue@^1.17.0 openapi-fetch@0.17.0 @fontsource/dm-sans@5.3.0 @fontsource/dm-mono@5.3.0 +npm install -D vite@^7.3.6 @vitejs/plugin-vue@^6.0.8 typescript@~5.9.3 vue-tsc@^3.3.11 tailwindcss@^4.3.0 @tailwindcss/vite@^4.3.0 vitest@^3.2.7 @vue/test-utils@^2.4.11 happy-dom@^20.11.6 openapi-typescript@7.13.0 +``` + +## Package Legitimacy Audit + +`gsd-tools query package-legitimacy check --ecosystem npm` was run this session. "SUS: too-new" means the **latest** published version is days old; every flagged package is a multi-million-download project with an official repo and no postinstall. Pinning the team's already-installed (older) versions above sidesteps the recency flag. + +| Package | Registry | Downloads/wk | Source Repo | Verdict | Disposition | +|---|---|---|---|---|---| +| vue | npm | 18.6M | github.com/vuejs/core | SUS (too-new latest) | Approved at pinned ^3.5.35 (team-installed) | +| vite | npm | 206M | github.com/vitejs/vite | SUS (too-new latest) | Approved at ^7.3.6 | +| @vitejs/plugin-vue | npm | 10.4M | github.com/vitejs/vite-plugin-vue | SUS (too-new latest) | Approved at ^6.0.8 | +| vue-router | npm | 9.5M | github.com/vuejs/router | SUS (too-new latest) | Approved at ^5.1.0 (team-installed) | +| typescript | npm | 328M | github.com/microsoft/TypeScript | OK | Approved (~5.9.3) | +| vue-tsc | npm | 6.7M | github.com/vuejs/language-tools | OK | Approved | +| tailwindcss | npm | 147M | github.com/tailwindlabs/tailwindcss | OK | Approved | +| @tailwindcss/vite | npm | 55M | github.com/tailwindlabs/tailwindcss | OK | Approved | +| reka-ui | npm | 2.2M | github.com/unovue/reka-ui | SUS (too-new latest) | Approved at ^2.9.10 (team-installed) | +| @lucide/vue | npm | 643K | github.com/lucide-icons/lucide | SUS (too-new latest) | Approved at ^1.17.0 (team-installed) | +| openapi-typescript | npm | 8.4M | (openapi-ts) | OK | Approved (7.13.0) | +| openapi-fetch | npm | 10.5M | (openapi-ts) | OK | Approved (0.17.0) | +| vitest | npm | 118M | github.com/vitest-dev/vitest | SUS (too-new latest) | Approved at ^3.2.7 (team-installed) | +| @vue/test-utils | npm | 5.5M | github.com/vuejs/test-utils | SUS (too-new latest) | Approved at ^2.4.11 (team-installed) | +| happy-dom | npm | 20.3M | github.com/capricorn86/happy-dom | SUS (too-new latest) | Approved at ^20.11.6 (team-installed) | +| @fontsource/dm-sans | npm | 463K | fontsource | OK | Approved | +| @fontsource/dm-mono | npm | 202K | fontsource | OK | Approved | +| lucide-vue-next | npm | — | lucide | deprecated | **Not used** (replaced by @lucide/vue) | + +**Packages removed due to [SLOP]:** none. +**Packages flagged [SUS]:** the "too-new" ones above — all are pinned to versions already in the team's lockfile, so the planner may treat the pin itself as the verification; if a plan takes a newer version, add a `checkpoint:human-verify` before install. +**Postinstall scripts:** none reported (`postinstall: null` for every package) [VERIFIED: legitimacy seam]. + +## Architecture Patterns + +### System Architecture Diagram + +``` +Browser (admin at https://host/{prefix}/...) + │ + │ GET {prefix}/golem15/fonoteka/albums GET/POST {prefix}/api/v1/... (cookie + X-Requested-With) + ▼ ▼ +surf ServeMux ──────────────────────────────────────────────────────────────────────────────┐ + │ "GET {prefix}" / "GET {prefix}/{path...}" │ "METHOD {prefix}/api/v1/…" (more specific wins) + ▼ ▼ +boardwalk.Handler cabana admin group + ├─ path starts "api/" → JSON 404 envelope ├─ csrf middleware (unsafe method + no Bearer ⇒ require header) + ├─ file exists in dist → ServeContent ├─ public: /auth/login, /auth/refresh, /lang + │ (immutable cache for assets/) ├─ "backend" guard: Bearer, else cookie → Principal + ├─ has file extension → 404 └─ handlers → protect(controller perms) + └─ else → index.html (rewritten once at boot: ├─ schema/list/form/relation/settings/navigation + ./assets → {prefix}/assets, meta prefix) ├─ fields/{field}/options → RelationExtendOptionsQuery + └─ create/update → fill scalars → validate → FormBefore* + → save row → relation sync (FK/pivot) → FormAfter* +SPA runtime: │ + main.ts reads ──► router base + openapi-fetch baseUrl ({prefix}/api/v1) + boot: GET /lang (strings) → GET /auth/me (401 ⇒ /login) → GET /navigation → route view + views: ListView(schema/list + list) · FormView(schema/form + show + options) · RelationManager · SettingsView + │ + Postgres (GORM) +``` + +### Recommended Project Structure + +``` +summercms.go/ +├── admin/ # Vite project (D-01) — node_modules ignored by go.mod +│ ├── index.html # contains +│ ├── package.json / package-lock.json +│ ├── vite.config.ts # base './' on build; outDir ../boardwalk/dist; dev proxy +│ ├── vitest.config.ts +│ ├── openapi/admin.json # committed framework OpenAPI 3 doc (D-15) +│ ├── src/ +│ │ ├── api/schema.d.ts # generated by openapi-typescript (committed, drift-checked) +│ │ ├── api/client.ts # openapi-fetch client + CSRF header + 401/refresh middleware +│ │ ├── app/ # runtime config (meta), router, i18n (bundle + plural), icons map +│ │ ├── state/ # useAuth, useNavigation, useStrings, useToasts, useSidebar +│ │ ├── components/shell/ # AppShell, PluginRail, SectionPanel, SectionFlyout, UserMenu, Breadcrumbs +│ │ ├── components/list/ # DataTable, ListToolbar, FilterBar, Pagination, cells/ +│ │ ├── components/form/ # FormTabs, FormGrid, FormField, FieldRenderer + fields/*, UnsupportedField +│ │ ├── components/relation/ # RelationManager, RelationPickerModal +│ │ ├── components/ui/ # Button, Input, Toast, ConfirmDialog (Reka-wrapped) +│ │ ├── views/ # LoginView, ListView, FormView, SettingsIndexView, SettingsFormView, NotFound +│ │ └── styles/ # tokens.css (@theme + light/dark vars), fonts.css +│ └── tests/ # *.test.ts + fixtures (generic "acme.demo" names — no Płytarium) +├── boardwalk/ # beach-named embed/serve package +│ ├── boardwalk.go # //go:embed all:dist ; Handler(prefix) http.Handler +│ ├── boardwalk_test.go +│ └── dist/ # committed Vite output (D-04) +├── cabana/ # API changes (prefix, cookie, options, relation save, messages, toolbar, lang) +│ └── lang/{en,pl}/lang.yaml # backend:: namespace (or phrasebook/backendlang — see Pattern 6) +├── scripts/ +│ ├── swagger2openapi.go # copy of fonoteka converter + union rewrite +│ ├── check-admin-openapi.sh # swag → convert → openapi-typescript → drift +│ ├── check-admin-dist.sh # npm ci && npm run build → git diff --exit-code boardwalk/dist +│ └── check-phase10.sh # phase gate (pattern of check-phase9.sh) +└── go.mod # + `ignore ./admin/node_modules` +``` + +Package name suggestion: **`boardwalk`** (where you walk along and look at the beach — the admin view). Any beach name works; it must be a directory the `//go:embed` pattern can reach without `..`, so Vite's `build.outDir` points into it. + +### Pattern 1: `backend.uri` configuration + +- Config file `config/backend.yaml` → key `backend.uri` (compass maps `config/.yaml` to `.*` and env `SUMMER_BACKEND__URI`) [VERIFIED: compass/config.go section loading + existing `SUMMER_ADMIN__JWT__SECRET` convention in cabana/auth.go:349]. +- Normalize: trim, ensure leading `/`, strip trailing `/`. Validate `^(/[a-z0-9][a-z0-9_-]*)+$`; reject `/`, and a first segment of `api`, `oauth`, `.well-known`, `storage` (or better: at `BuildRouter` time, fail boot if **any non-cabana route** path starts with `{prefix}/` or equals `{prefix}` — generic and future-proof). +- Default: **`/backend`** (Winter's `backendUri` default) [ASSUMED — A1]; fonoteka sets `/plytadmin` in `config/backend.yaml`. +- The prefix is computed once in `cabana.Activate` and stored on `service` (e.g. `s.prefix`); `mount` uses `s.api() = s.prefix + "/api/v1"`. A zero `service{}` in tests must default to the same value, since `TestPhase09PermissionMatrix` calls `(&service{}).mount(router)` [VERIFIED: cabana/security_coverage_test.go:46-48]. +- Test churn: introduce one test helper per repo (`adminAPI(path string) string`) and mechanically replace `"/_admin/api/v1` literals. + +### Pattern 2: Embedded SPA serving (`boardwalk`) + +**What:** path-agnostic build + one-time index rewrite + safe fallback. + +1. Vite: `base: command === 'build' ? './' : '/'`. Relative base is the documented option "for scenarios where the base path is unknown in advance" and requires `import.meta` support [CITED: vite.dev/guide/build]. JS chunks and CSS `url()`s resolve relative to their own file, so only `index.html` needs rewriting. +2. `index.html` source carries ``. At `Handler(prefix)` construction, read `dist/index.html` once, replace `="./` with `="{prefix}/` and the token with `html.EscapeString(prefix)`, keep the bytes. Fail loudly (boot error) if the token is missing (catches a stale or hand-edited dist). +3. Do **not** use `` (fragment links and SVG `url(#id)` references resolve against it) and do **not** inject inline `