diff --git a/.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md b/.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
index 7f621a1..b27158e 100644
--- a/.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
+++ b/.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-CONTEXT.md
@@ -19,13 +19,15 @@ Out of scope: plugin-provided field types and the Blog Editor formwidget (Phase
## Implementation Decisions
-- **D-01: List state lives in both the URL and the server session.** Sort, filters, search and page are reflected in the list URL query string (shareable, bookmarkable, back-button friendly). When the list is opened without list-state query parameters (for example from the admin navigation), the last state stored in the user's session for that list is restored. When the URL carries list-state parameters, the URL wins and also updates the session. User's words: "I may go back to Posts list through navigation and it's great if my filters were remembered + sharable URLs are nice to have."
+- **D-01: List state lives in the URL and in the browser's localStorage.** Sort, filters, search and page are reflected in the list URL query string (shareable, bookmarkable, back-button friendly); the form's back/save/close links and the admin navigation return to the list with its query. When the list is opened without list-state query parameters (for example from the admin navigation), the last query stored in localStorage for that list (keyed per list) is restored before the first list request. When the URL carries list-state parameters, the URL wins and also updates localStorage. No server-side storage: no table, no migration, no new API route (revised 2026-10-09 to keep the phase small; admin auth is a stateless JWT, so a server session would need new storage). User's words: "I may go back to Posts list through navigation and it's great if my filters were remembered + sharable URLs are nice to have."
+- **D-05: Three plans.** 01 all fixes (cabana EXISTS relation search and sort, default locale first in form locales, Journal FormatHTML keeping imported HTML, list state via URL + localStorage); 02 release and redeploy to UAT, including re-saving posts 53 and 64 that lost HTML on import; 03 unit tests.
- **D-02: The relation-search fix is a framework fix in summercms.go `modules/cabana`**, generic for belongsToMany (pivot) relations, not a Journal-specific workaround. Another session is concurrently working in `modules/`; plans must keep cabana changes in their own commits, stage files explicitly by path, and check for conflicting in-flight edits before touching shared cabana files.
- **D-03: Content and excerpt must show the stored values without loss.** Root cause is to be established by research (suspects: translatable-field overlay replacing host values with empty translations; the markdown widget not displaying imported HTML). Saving a post from the admin must not destroy imported HTML (see todo `journal-imported-html-admin-resave.md`); if a full fix needs the Blog Editor, 16.1 at least prevents data loss and 16.2 delivers the editor.
- **D-04: Ship to UAT.** Release a summercms tag carrying the cabana fixes (unless the concurrent session's release already includes them), bump `sm-grzybyfunkcjonalne-app`, redeploy to uat.grzybyfunkcjonalne.pl per its DEPLOY.md, and re-run `scripts/smoke.sh --url` on the server.
### Claude's Discretion
-- Session storage mechanism and query-parameter naming for list state, following existing cabana conventions.
+- localStorage key naming for list state; reuse the existing list query parameters (`search`, `sort`, `dir`, `page`, `per_page`, `filter[name]`).
+- Whether to disable the unused `en` locale on the UAT site during deploy (not required by the framework fix).
- Test strategy, within the lean-mode rule that unit tests are the last plan of the phase.
diff --git a/.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-PATTERNS.md b/.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-PATTERNS.md
new file mode 100644
index 0000000..9f2d8d8
--- /dev/null
+++ b/.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-PATTERNS.md
@@ -0,0 +1,235 @@
+# Phase 16.1: Journal admin urgent fixes - Pattern Map
+
+**Mapped:** 2026-10-09
+**Files analyzed:** 14 (new or modified)
+**Analogs found:** 13 / 14
+
+All analog paths are git-tracked source in summercms.go or sm-journal-plugin. No `.msd/` mirror paths are used.
+
+## File Classification
+
+| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
+|---|---|---|---|---|
+| `modules/cabana/query.go` (search EXISTS, to-many sort) | service (SQL builder) | request-response / CRUD read | itself: `applyListSearch` L323-360, `applyListOrder` L295-321, helpers L486-497 | exact (in-place edit) |
+| `modules/cabana/list_state.go` (NEW) | service + handler | CRUD (upsert/read) | `modules/cabana/account.go` (`accountPrincipal` L214-223, `lockAccount` L225-240) | role-match |
+| `modules/cabana/http.go` (route, save in `list`, locale order) | controller | request-response | itself: routes L253-256, `list` L986-1015, `enabledContentLocales` L966-984 | exact |
+| `modules/cabana/admin_openapi.go` | config (swag docs) | n/a | `AdminListSchema` L340-354, `AdminList` L430+ | exact |
+| `modules/lagoon/backend_admin_migrations.go` | migration | batch DDL | `202610090001_backend_users_metadata` L120-132 | exact |
+| `modules/lagoon/backend_admin_migrations_test.go` | test | integration | prohibited-table loop L117-121 | exact |
+| `modules/cabana/query_relation_test.go` (NEW) | test | DryRun SQL | none in cabana (no DryRun tests exist); use RESEARCH "DryRun SQL test" sketch | no analog |
+| `modules/cabana/list_state_test.go` (NEW) | test | integration | `modules/cabana/ml_smoke_test.go` (`recordingWriter` L67-72), `ml_test.go:51` | role-match |
+| `admin/src/views/ListView.vue` | component | event-driven / request-response | itself L113-153 (`replaceQuery`, `loadList`, watch) | exact |
+| `admin/tests/list/ListView.test.ts` | test | vitest | itself L1-40 (`routes()`, `requestsTo`) | exact |
+| `admin/tests/form/MLFields.test.ts` | test | vitest | itself (fixtures `['en','pl']` L18) | exact |
+| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist/` | generated | n/a | `scripts/check-admin-openapi.sh`, `npm --prefix admin run build` | regenerate only |
+| `../sm-journal-plugin/classes/format_html.go` | utility | transform | itself L1-38 | exact |
+| `../sm-journal-plugin/classes/format_html_test.go`, `posts_admin_smoke_test.go` | test | unit / integration | themselves (`TestFormatHTMLRejectsUnsafeHTML`) | exact |
+| Docs: `modules/cabana/README.md`, `modules/lagoon/README.md`, `docs/backend/lists-and-filters.md` | docs | n/a | existing sections; no app names (use `blog`/`acme`) | exact |
+
+## Pattern Assignments
+
+### `modules/cabana/query.go` (service, SQL builder)
+
+**Current bug, search** (L345-360) — replace the `db.Joins(field)` branch with an `EXISTS` term appended to `parts`/`args`:
+```go
+if col.Relation != "" {
+ field, ok := relationFieldName(model, col.Relation)
+ physical := listRelationColumn(controller, col.Relation, col.Select)
+ if !ok || !identifier(physical) {
+ return nil, listInvalid("search", "is not searchable")
+ }
+ // GORM aliases the joined association with the Go field name.
+ table = field
+ column = physical
+ if _, done := joined[field]; !done {
+ db = db.Joins(field)
+ joined[field] = struct{}{}
+ }
+}
+```
+Keep the existing guards (`relationFieldName`, `listRelationColumn`, `identifier`, `listInvalid`) and `pattern := "%" + escapeLike(strings.ToLower(term)) + "%"` (L336). Build the subquery from `gorm.Statement{DB: db}.Parse(model)` → `Schema.Relationships.Relations[field]` (RESEARCH Pattern 1 `relationExists` sketch). Aliases `cabana_p` / `cabana_r`.
+
+**Current bug, sort** (L303-309): `db = db.Joins(field)` — keep for `schema.BelongsTo`/`schema.HasOne`; for `HasMany`/`Many2Many` emit `clause.OrderBy{Expression: clause.Expr{SQL: "(SELECT string_agg(...) ...)"}}` with `Desc` handled in SQL. Keep the trailing pk tiebreak (L316-318).
+
+**Identifier helpers to reuse** (L486-497):
+```go
+func qualifiedColumn(db *gorm.DB, table, column string) string {
+ return quotedIdent(db, table) + "." + quotedIdent(db, column)
+}
+func quotedIdent(db *gorm.DB, name string) string { ... db.Dialector.QuoteTo(&b, name) ... }
+```
+Never guess pivot/fk names (README rule).
+
+---
+
+### `modules/cabana/http.go` (controller)
+
+**Route registration** (L253-254) — add the literal route next to `schema/list`, each followed by `constrainController(g)`:
+```go
+g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
+constrainController(g)
+```
+
+**Handler shape / save point** (`list`, L986-1015):
+```go
+func (s *service) list(w http.ResponseWriter, r *http.Request) {
+ s.protect(w, r, func(cc *CompiledController) {
+ db, err := s.db()
+ if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError); return }
+ result, err := ExecuteList(r.Context(), db, cc, listQueryFromRequest(r))
+ var invalid *ListValidationError
+ if errors.As(err, &invalid) {
+ WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", invalid.Details)
+ return
+ }
+ if err != nil { WriteError(...); return }
+ // <-- best-effort saveListState(ctx, db, principal.ID, cc id, input); slog.Error on failure
+ ...
+ WriteData(w, http.StatusOK, result.Data, meta)
+ })
+}
+```
+The new `listState` handler copies this exact `s.protect` + `s.db()` + `WriteData` skeleton. Controller id is built in `protect` as `vendor.plugin.controller` (L1019).
+
+**Locale order** (`enabledContentLocales`, L966-984): after `codes, err := writer.EnabledLocales(ctx, db)`, call `writer.DefaultLocale(ctx, db)` and move it to index 0 (RESEARCH "Default locale first" sketch). Error path follows `slog.Error("cabana: enabled locales", "error", err)`.
+
+---
+
+### `modules/cabana/list_state.go` (NEW, service, CRUD upsert/read)
+
+**Analog:** `modules/cabana/account.go`
+
+Principal pattern (L214-223):
+```go
+principal, ok := bouncer.User(r.Context())
+if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
+ WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
+ return nil
+}
+```
+Query pattern (L227-230) — fresh session scoped to the request context, filter by principal id only:
+```go
+err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
+ Clauses(clause.Locking{Strength: "UPDATE"}).
+ Preload("Role").Where("id = ?", id).Take(&user).Error
+if errors.Is(err, gorm.ErrRecordNotFound) { ... }
+```
+Upsert: no non-test `clause.OnConflict` exists in modules yet; use `Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}, {Name: "controller"}}, DoUpdates: clause.AssignmentColumns([]string{"state", "updated_at"})})`. Sanitize on restore by reusing `normalizePage`, `normalizeSort`, filter allowlist, `switchArgument`/`parseDateRange`/`scopeArgument` (drop invalid parts, never 422). Cap JSON at 4 KiB.
+
+---
+
+### `modules/cabana/admin_openapi.go` (swag)
+
+**Analog:** `AdminListSchema` (L340-354):
+```go
+// AdminListSchema documents the list schema route.
+//
+// @Summary Admin list schema
+// @Tags admin
+// @Produce json
+// @Security BackendBearer
+// @Param vendor path string true "Vendor"
+// @Param plugin path string true "Plugin"
+// @Param controller path string true "Controller"
+// @Success 200 {object} Envelope[ListSchema]
+// @Failure 401 {object} ErrorEnvelope
+// @Failure 403 {object} ErrorEnvelope
+// @Failure 404 {object} ErrorEnvelope
+// @Router /{vendor}/{plugin}/{controller}/schema/list [get]
+func AdminListSchema() {}
+```
+Add `AdminListState` with `@Router /{vendor}/{plugin}/{controller}/list-state [get]`; extend `AdminList` `@Description` (L433) with one sentence. Regenerate via `scripts/check-admin-openapi.sh`.
+
+---
+
+### `modules/lagoon/backend_admin_migrations.go` (migration)
+
+**Analog** (L120-132):
+```go
+{
+ // WinterCMS ...: rationale comment.
+ ID: "202610090001_backend_users_metadata",
+ Migrate: func(tx *gorm.DB) error {
+ return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
+ },
+ Rollback: func(tx *gorm.DB) error {
+ return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
+ },
+},
+```
+New entry `202610100001_backend_list_states` appended last, raw `CREATE TABLE IF NOT EXISTS` (DDL in RESEARCH Pattern 2) / `DROP TABLE IF EXISTS`. Test: extend `backend_admin_migrations_test.go` (keep the prohibited-tables loop L117-121 including `backend_user_preferences`).
+
+---
+
+### `admin/src/views/ListView.vue` (component)
+
+**Current entry** (L113-153):
+```ts
+function replaceQuery(next: ListQuery): void {
+ void router.replace({ path: route.path, query: toListQuery(next) })
+}
+let generation = 0
+async function loadList(): Promise {
+ const current = ++generation
+ ...
+ const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
+ if (current !== generation) return
+ ...
+}
+watch(() => listQueryKey(query.value), () => { selected.value = []; searchText.value = query.value.search ?? ''; void loadList() }, { immediate: true })
+```
+Change: drop `immediate: true`; add an entry routine that, on empty `parseListQuery(route.query)`, calls `api.GET('/{vendor}/{plugin}/{controller}/list-state', { params: { path } })` then `replaceQuery(state)` or `loadList()`. Track the last key set by `replaceQuery` for same-path re-entry. Page clamp after `loadList` on `meta.page > meta.last_page`. Reuse `parseListQuery`/`toListQuery`/`listQueryKey` from `admin/src/app/listQuery.ts`. FormView unchanged.
+
+**Test analog** (`admin/tests/list/ListView.test.ts` L18-27):
+```ts
+const BASE = `${API}/acme/demo/widgets`
+function routes(overrides: Record = {}): Record {
+ return { [`GET ${BASE}/schema/list`]: { body: listSchemaFixture }, [`GET ${BASE}`]: { body: listFixture }, ...overrides }
+}
+```
+Add `[`GET ${BASE}/list-state`]` route; assert ordering with `requestsTo(calls, 'GET', BASE)` (L64, L234-258 pattern).
+
+---
+
+### `../sm-journal-plugin/classes/format_html.go` (utility, transform)
+
+**Current** (L9-18):
+```go
+import (
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+)
+var (
+ markdownEngine = goldmark.New(
+ goldmark.WithExtensions(extension.Footnote, extension.Table),
+ goldmark.WithParserOptions(parser.WithAttribute()),
+ )
+```
+Add `"github.com/yuin/goldmark/renderer/html"` and `goldmark.WithRendererOptions(html.WithUnsafe())`. Note: `FormatHTML` has a local variable named `html` (L33) that will shadow the package; rename the local (e.g. `out`). Keep `rejectUnsafe` call unchanged. Do not touch `cabana.RenderMarkdown`.
+
+## Shared Patterns
+
+### Permission gate before SQL
+**Source:** `modules/cabana/http.go` `protect` (L1016+). **Apply to:** `list`, new `listState`. Controller lookup → principal check → permission → fn.
+
+### Error envelopes
+**Source:** `WriteError`, `WriteErrorDetails`, `WriteData`, `msgServerError` as used in `list` (L990-1013). **Apply to:** list-state route. Save failures: `slog.Error("cabana: ...", "error", err)`, never fail the response.
+
+### ML writer test double
+**Source:** `modules/cabana/ml_smoke_test.go` L67-72 `recordingWriter{defaultLocale, enabled, ...}`; usage `ml_test.go:51` `&recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}`. **Apply to:** `TestEnabledLocalesDefaultFirst` with `defaultLocale: "pl"`.
+
+### Commit hygiene (D-02)
+Cabana/lagoon changes in their own commits, staged by explicit path; run `git status --porcelain -- modules admin` before each cabana task. Rebuild `modules/boardwalk/dist` and regenerate OpenAPI in the same change as SPA/swag edits; update cabana/lagoon READMEs and `docs/backend/lists-and-filters.md`.
+
+## No Analog Found
+
+| File | Role | Data Flow | Reason |
+|---|---|---|---|
+| `modules/cabana/query_relation_test.go` | test | DryRun SQL | No DryRun-based tests in cabana; use RESEARCH "DryRun SQL test (pure, no Docker)" sketch (`gorm.Config{DryRun: true, DisableAutomaticPing: true}` + `db.ToSQL`) |
+
+## Metadata
+
+**Analog search scope:** `modules/cabana`, `modules/lagoon`, `admin/src/views`, `admin/tests`, `../sm-journal-plugin/classes`
+**Files scanned:** ~12
+**Pattern extraction date:** 2026-10-09