# 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 `