From fdee6b626e86081894e41eaac2e480db0aaf48bc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 21:38:08 +0200 Subject: [PATCH] docs(quick-261006-tnp): add mltextarea field type --- .planning/STATE.md | 3 +- .../261006-tnp-PLAN.md | 256 ++++++++++++++++++ .../261006-tnp-SUMMARY.md | 133 +++++++++ 3 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 .planning/quick/261006-tnp-add-mltextarea-field-type/261006-tnp-PLAN.md create mode 100644 .planning/quick/261006-tnp-add-mltextarea-field-type/261006-tnp-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index c56c9ee..7b71295 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-09-16) Phase: 15 (Journal plugin) — EXECUTING Plan: 4 of 4 Status: Ready for phase verification -Last activity: 2026-10-06 — Completed quick task 261006-t5r: Make SPA slug preset follow mltext source fields +Last activity: 2026-10-06 — Completed quick task 261006-tnp: Add mltextarea field type Progress: [██████████] 100% @@ -648,6 +648,7 @@ Recent decisions affecting current work: | 261006-seq | Fix nullable pointer scalars rendering as /address in cabana ML form fields | 2026-10-06 | 90b87d6 | [261006-seq-fix-nullable-pointer-scalars-rendering-a](./quick/261006-seq-fix-nullable-pointer-scalars-rendering-a/) | | 261006-sne | Render Markdown field Preview via sanitized cabana.RenderMarkdown | 2026-10-06 | a0116df | [261006-sne-render-markdown-field-preview-via-saniti](./quick/261006-sne-render-markdown-field-preview-via-saniti/) | | 261006-t5r | Make SPA slug preset follow mltext source fields | 2026-10-06 | b5d8506 | [261006-t5r-make-spa-slug-preset-follow-mltext-sourc](./quick/261006-t5r-make-spa-slug-preset-follow-mltext-sourc/) | +| 261006-tnp | Add mltextarea field type | 2026-10-06 | 3596df4 | [261006-tnp-add-mltextarea-field-type](./quick/261006-tnp-add-mltextarea-field-type/) | ## Deferred Items diff --git a/.planning/quick/261006-tnp-add-mltextarea-field-type/261006-tnp-PLAN.md b/.planning/quick/261006-tnp-add-mltextarea-field-type/261006-tnp-PLAN.md new file mode 100644 index 0000000..53cddaa --- /dev/null +++ b/.planning/quick/261006-tnp-add-mltextarea-field-type/261006-tnp-PLAN.md @@ -0,0 +1,256 @@ +--- +phase: quick-261006-tnp +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - modules/cabana/field_ml.go + - modules/cabana/form_schema.go + - modules/cabana/crud.go + - modules/cabana/relation.go + - modules/cabana/ml_textarea_test.go + - modules/cabana/preset_ml_test.go + - admin/src/components/form/fields/MLField.vue + - admin/src/components/form/fields/MLTextField.vue + - admin/src/components/form/fields/MLMarkdownField.vue + - admin/src/components/form/fields/MLTextareaField.vue + - admin/src/components/form/mlLocale.ts + - admin/src/components/form/formState.ts + - admin/src/components/form/registry.ts + - admin/src/views/FormView.vue + - admin/src/components/relation/RelationChildModal.vue + - admin/tests/form/MLFields.test.ts + - admin/tests/form/registry.test.ts + - admin/tests/form/FormView.test.ts + - modules/boardwalk/dist/ + - modules/cabana/README.md + - docs/backend/forms.md + - docs/backend/admin-controllers.md +autonomous: true +requirements: [QUICK-261006-tnp] + +estimate: + tokens: 80000 + raw_tokens: 80000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "A fields.yaml field with `type: mltextarea` (optionally with `size`) compiles and boots; the form schema serves it as type mltextarea with its size; an unknown ml* type such as mltextareas still stops the start-up" + - "Saving an mltextarea field with a locale map writes the default locale (multi-line text intact) to the host column, including a nullable *string column, and the other locales through the published TranslationWriter inside the same save transaction; the default locale is not duplicated into the translation store" + - "Show and save responses carry an mltextarea field as a map of every enabled locale; a nil host column hydrates to an empty string, never or a pointer address; a missing non-default locale is an empty string" + - "An mltextarea map with an undeclared locale, a non-string value or no default locale is a 422 on that field, exactly as for mltext" + - "A preset on an mltextarea field, or a preset naming an mltextarea source, stops the start-up, as for textarea and mlmarkdown" + - "In the admin SPA an mltextarea field renders a textarea (rows from size, as on type textarea) with one locale select; it follows ML_LOCALE_CHANGE with every other ML control, edits only the active locale and emits the whole map; create seeds every enabled locale, a GET host string is merged onto the seed, and the save body sends the nested map" + - "mltext and mlmarkdown fields behave exactly as before: the existing Go ML tests and the existing MLFields, MarkdownField, registry, formState and FormView Vitest cases pass without edits to their assertions" + - "modules/boardwalk/dist matches a fresh build; admin.json and schema.d.ts match a fresh generation; the cabana README and the forms docs list mltextarea; TestDocsTree, go vet ./... and go test ./... are green" + artifacts: + - path: modules/cabana/field_ml.go + provides: "mlTextareaFieldType constant; mlFieldType admits mltext, mltextarea and mlmarkdown, so lift, apply and hydrate cover the new type" + - path: modules/cabana/form_schema.go + provides: "formFieldTypes admits mltextarea" + - path: modules/cabana/crud.go + provides: "scalarFormField admits mltextarea (writable binding, required checks, settings and relation forms)" + - path: modules/cabana/ml_textarea_test.go + provides: "DB-free compile test and Postgres save/hydrate round trip for mltextarea over a nullable *string column" + - path: admin/src/components/form/fields/MLField.vue + provides: "The one locale-switching wrapper: locale select, ML_LOCALE_CHANGE follow, active-locale edit, inner control passed as a component" + - path: admin/src/components/form/fields/MLTextareaField.vue + provides: "mltextarea control: MLField around TextareaField" + - path: admin/src/components/form/mlLocale.ts + provides: "isMLFieldType, the single list of ML field types used by formState, FormView and RelationChildModal" + - path: modules/boardwalk/dist + provides: "Rebuilt embedded admin SPA" + key_links: + - from: modules/cabana/field_ml.go mlFieldType + to: modules/cabana/field_ml.go liftMLValues, hydrateMLRecord and relation.go child save/show + via: "every ML code path selects fields through mlFieldType, so adding the constant there is the whole server wiring" + - from: modules/cabana/form_schema.go formFieldTypes + to: modules/cabana/crud.go scalarFormField + via: "a type must be in both to compile and to be bound as a writable model column" + - from: admin/src/components/form/registry.ts renderers + to: admin/src/components/form/fields/MLTextareaField.vue + via: "['mltextarea', MLTextareaField] plus 'mltextarea' in groupLabelledTypes" + - from: admin/src/components/form/mlLocale.ts isMLFieldType + to: formState.ts editablePayload/initialValues/presetUpdates, FormView.vue adopt/update, RelationChildModal.vue adopt + via: "every place that treats a value as a locale map asks isMLFieldType instead of comparing type names inline" +--- + + +Add the WinterCMS Translate `mltextarea` form field type to cabana and the admin SPA: a multi-line, translatable text field that uses the same translation storage and default-locale host column as `mltext`, and honours `size` like `textarea`. Ported fields.yaml files that say `type: mltextarea` then work unchanged. + +Purpose: Journal plugin UAT found the post excerpt is a textarea in PHP (`mltextarea`) but a single-line `mltext` in Go. The orchestrator chose a new type for parity, not a `size:` switch on mltext. +Output: the cabana type registration and tests; the shared SPA ML wrapper plus MLTextareaField, tests and the rebuilt dist; the README and docs updates. sm-journal-plugin is not edited. The SUMMARY records the YAML change it needs. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/STATE.md +@./CLAUDE.md +@modules/cabana/field_ml.go +@admin/src/components/form/fields/MLTextField.vue +@admin/src/components/form/registry.ts + +Facts gathered during planning (line numbers as of HEAD 673c6ca): +- Server type inventory. `mltext` is recognised in exactly these places: form_schema.go formFieldTypes (line ~29), crud.go scalarFormField (line ~1227), and field_ml.go mlFieldType (line 44), which liftMLValues, hydrateMLRecord and the relation-child path in relation.go all use. Preset code (form_schema.go compilePresetKey ~651, checkPresets ~709) names text/mltext explicitly. extension.go FormVirtualFields and list_schema.go are unrelated (virtual fields and list columns) and stay unchanged. No swag enum lists field types. The only OpenAPI text that names ML types is the preset sentence in admin_openapi.go (~line 285), which this plan does not change, so admin.json and schema.d.ts must show no drift. +- `size` is accepted on every type (form_schema.go ~509 checks it against formSizes: tiny, small, large, huge, giant) and serialised as FormField.Size, so an mltextarea field keeps its size with no extra server code. The SPA TextareaField maps size to rows (tiny 2, small 4, large 8, huge 12, giant 18, default 5). +- hostScalarString (field_ml.go) already dereferences nullable pointer columns (quick 261006-seq). mltextarea gets that behaviour through mlFieldType. +- Go test fixtures to reuse (package cabana): DB-free `CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, yaml))` (formFS and formConfig in form_schema_test.go; pattern in ml_test.go TestMLFieldTypes). Postgres via `newListService(t)` (testcontainers, query_test.go). `recordingWriter{defaultLocale: "en", enabled: []string{"en","pl"}}` and `mlFS()` in ml_smoke_test.go (`writer.attrs[locale][field]` holds written translations). `mlNullablePost` (Title string, Description *string), `mlNullableController`, `mlLocaleMap`, `assertNoPointerLeak` and the compileRegistry/formPlugin pattern of `mlNullableCompiled` in ml_nullable_test.go. Preset refusal rows in preset_ml_test.go TestPresetML (compileErr "preset is only valid on type: text or mltext", checkErr "field slug: preset field X must be a text or mltext field"). +- SPA. MLTextField.vue and MLMarkdownField.vue duplicate the same script and template; they differ only in the inner control (TextField with type text vs MarkdownField with type markdown) and the data-ml-field value. The type check that treats a value as an ML locale map is written inline six times: formState.ts editablePayload (~68), the private isMLType (~114), initialValues (~191), FormView.vue adopt (~180) and update (~252), and RelationChildModal.vue adopt (~220). FormView and RelationChildModal import helpers from formState.ts, which re-exports ML helpers from mlLocale.ts (line 8). presetUpdates (formState.ts ~146) restricts preset targets to text/mltext and that stays as is. +- Existing Vitest coverage that must stay green unchanged: tests/form/MLFields.test.ts (mounts MLTextField/MLMarkdownField with FORM_ENABLED_LOCALES provided, asserts `wrapper.emitted('update:modelValue')` on the ML component itself, one `[data-ml-locale]` per field, no copy-from chrome, cross-control locale broadcast, `findComponent(MarkdownField)`), tests/form/MarkdownField.test.ts ("MarkdownField inside mlmarkdown"), tests/form/registry.test.ts (rendererFor('mltext') toBe MLTextField, and the same for mlmarkdown), tests/form/FormView.test.ts (describe 'password and preset on the form' with helpers people(), save(), mlSchema(nameType, slugType), editor(), localeSelect(); the roster fixture has no textarea field, so a test retypes `name`). +- Gate scripts. No scripts/check-phase*.sh holds a field-type or component inventory. check-phase14.2.1.sh runs the registry, MLFields and MarkdownField Vitest files and refuses any raw-HTML sink in admin/src other than the one MarkdownField preview line, so the new components must not bind raw HTML. check-phase10/12.1/12.2 have the same raw-HTML allowlist. +- Build/check scripts: `npm --prefix admin run build` writes modules/boardwalk/dist; `scripts/check-admin-dist.sh` is the dist drift gate (it runs the typecheck); `scripts/check-admin-openapi.sh --check` is the OpenAPI/TS types drift gate. Never hand-edit admin.json or schema.d.ts. +- The translate plugin lives outside this repo (../sm-translate-plugin) and its markdown does not list field types, so no plugin docs change here. +- Commit rules: one logical change per commit; planning docs never go in a code commit; NO co-author trailers (the user's global CLAUDE.md overrides any attribution reminder). + +Discretion choices made in this plan (record them in the SUMMARY): +- Preset: mltextarea is neither a preset target nor a preset source. Preset is the single-line slug/copy rule: plain `textarea` and `mlmarkdown` are refused today, and ML-ness does not change eligibility. So the minimal, consistent choice is to leave compilePresetKey/checkPresets untouched and pin the refusals with tests. Widening preset to multi-line sources (textarea and mltextarea together) would be its own change. +- SPA reuse: one MLField.vue wrapper parameterised by the inner control component, its field type and the data-ml-field value. MLTextField, MLMarkdownField and the new MLTextareaField become thin shells that keep their component identity (the registry and tests reference them) and re-emit update:modelValue. One isMLFieldType helper replaces the inline type-name checks. +- Preview screen: ML locale maps keep showing as they do today for mltext. No preview change. + +Plugin follow-up (sm-journal-plugin, NOT edited in this task; copy into the SUMMARY verbatim). models/post/fields.yaml, the excerpt field becomes: + +```yaml + excerpt: + label: golem15.journal::lang.post.excerpt + type: mltextarea + size: small + tab: golem15.journal::lang.post.tab_manage +``` + + + + + + Task 1: cabana accepts mltextarea end to end (compile, nested save, hydrate) with preset refused + modules/cabana/ml_textarea_test.go, modules/cabana/preset_ml_test.go, modules/cabana/field_ml.go, modules/cabana/form_schema.go, modules/cabana/crud.go, modules/cabana/relation.go + modules/cabana/field_ml.go (whole file), modules/cabana/ml_test.go (TestMLFieldTypes, TestMLNestedSave), modules/cabana/ml_nullable_test.go (mlNullablePost, mlNullableController, mlNullableCompiled, mlLocaleMap, TestMLNullablePointerHost), modules/cabana/ml_smoke_test.go (recordingWriter, mlFS), modules/cabana/preset_ml_test.go (case table shape) + A Docker daemon is reachable so the testcontainers Postgres behind newListService can start. + + - DB-free: a fields.yaml with `title: {type: mltext}` and `excerpt: {type: mltextarea, size: small}` compiles through CompileForm; the excerpt field has Type "mltextarea" and Size "small"; json.Marshal of it contains "type":"mltextarea" and "size":"small". `type: mltextareas` fails with an error containing "unsupported type mltextareas". mlFieldType("mltextarea") and scalarFormField("mltextarea") are true; mlFieldType("textarea") is false. + - Postgres round trip over mlNullablePost with a fields.yaml whose title is mltext (required) and description is `type: mltextarea, size: small`: ShowRecord on a row with nil Description hydrates description to {en: "", pl: ""} with no pointer leak. UpdateRecord with description {en: "Line one\nLine two", pl: "Linia pierwsza\nLinia druga"} stores "Line one\nLine two" in the host *string column (newline intact). writer.attrs["pl"]["description"] is the Polish text and writer.attrs["en"] has no description. The returned Data and a fresh ShowRecord both carry the full two-locale map. + - Invalid maps on the mltextarea field: an undeclared locale (de), a non-string value, and a map without the default locale each fail UpdateRecord with a *ValidationError whose Details has a description key. + - Preset: `preset: title` on an mltextarea field fails CompileForm with "preset is only valid on type: text or mltext". An mltext slug with `preset: excerpt` naming an mltextarea source compiles, but checkPresets returns "field slug: preset field excerpt must be a text or mltext field". + + +RED first. Create modules/cabana/ml_textarea_test.go (package cabana) with TestMLTextareaFieldType (the DB-free rows of the behavior block) and TestMLTextareaRoundTrip (the Postgres rows). For the round trip, reuse mlNullablePost and mlNullableController unchanged. Add a local mlTextareaFields constant and an mlTextareaCompiled(t) helper. The helper copies the mlNullableCompiled pattern, but its fsys is mlFS() with "models/post/fields.yaml" replaced by mlTextareaFields. Drop and auto-migrate mlNullablePost, then use CRUDService{DB: db, writer: &recordingWriter{...}} plus mlLocaleMap and assertNoPointerLeak for the hydration checks. Read the host column back with db.First into a fresh mlNullablePost and dereference Description. Add the two preset refusal rows to the TestPresetML table in preset_ml_test.go ("mltextarea target is refused", "mltextarea source is refused"). Run the new tests and confirm the compile and round-trip rows fail on the current code with "unsupported type mltextarea". The preset rows may also fail for that reason. + +GREEN, per the quick-task decision (a new type, WinterCMS Translate parity, so ported YAML works unchanged): +- field_ml.go: add mlTextareaFieldType = "mltextarea" next to the existing constants. mlFieldType returns true for mltext, mltextarea and mlmarkdown (a switch over the three constants). Update the doc comments of liftMLValues and hydrateMLRecord to name all three ML types. +- form_schema.go: add "mltextarea" to formFieldTypes next to mltext. +- crud.go: add "mltextarea" to the scalarFormField case list. Update the TranslationWriter field comment (~line 33) to name all three ML types. relation.go (~line 190) comment likewise. +- Leave compilePresetKey and checkPresets untouched. Their existing text/mltext rule is the preset decision recorded in the context block. +- Do not touch README, docs or admin_openapi.go here. Task 3 owns the docs. + +Run gofmt, go vet and the targeted tests, then the existing ML and preset suites to prove mltext/mlmarkdown are unchanged. Commit: feat(cabana): add mltextarea field type (code + tests only; no .planning files, no co-author trailers). + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && bash -c 'test -z "$(gofmt -l modules/cabana)"' && go vet ./modules/cabana && go test ./modules/cabana -run 'TestMLTextarea|TestPresetML|TestMLFieldTypes|TestMLNestedSave|TestMLHydration|TestMLNullablePointerHost|TestHostScalarString|TestRelationChildMLNestedSave|TestMLOpenAPIConformance' -count=1 + + TestMLTextareaFieldType and TestMLTextareaRoundTrip failed before the change and pass after it. mltextarea compiles with its size, saves the default locale to a nullable host column with newlines intact, writes only non-default locales through the writer and hydrates every enabled locale with no pointer leak. Invalid maps are 422s on the field. Preset on or from mltextarea is refused. The existing ML, nullable, relation-child and preset tests pass unchanged. One code commit. + + + + Task 2: SPA renders mltextarea through one shared ML wrapper; rebuild dist + admin/src/components/form/fields/MLField.vue, admin/src/components/form/fields/MLTextField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/fields/MLTextareaField.vue, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts, admin/src/components/form/registry.ts, admin/src/views/FormView.vue, admin/src/components/relation/RelationChildModal.vue, admin/tests/form/MLFields.test.ts, admin/tests/form/registry.test.ts, admin/tests/form/FormView.test.ts, modules/boardwalk/dist/ + admin/src/components/form/fields/MLTextField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/fields/TextareaField.vue, admin/src/components/form/control.ts (FieldControlProps), admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts (lines 1-10, editablePayload, isMLType, presetUpdates, initialValues), admin/src/components/form/registry.ts, admin/tests/form/MLFields.test.ts (mlMount helper), admin/tests/form/registry.test.ts (describe 'markdown and multilingual field types'), admin/tests/form/FormView.test.ts lines 441-640 (helpers and the mltext cases) + + - registry: rendererFor('mltextarea') is MLTextareaField; isRegistered('mltextarea') true; needsRecord false; ownsLabel false; groupLabelled true. 'MLTEXTAREA' falls back to UnsupportedField and is not registered. + - isMLFieldType: true for mltext, mltextarea, mlmarkdown; false for text, textarea, markdown, undefined. + - formState: editablePayload sends an mltextarea value as its locale record (non-string entries dropped). initialValues seeds {en: '', pl: ''} for an mltextarea field when enabled locales are en/pl. + - MLTextareaField mounted with field {name: 'excerpt', type: 'mltextarea', size: 'small'} and modelValue {en: 'Line 1\nLine 2', pl: 'Linia'}: the root has data-ml-field="mltextarea" and role group. There is exactly one [data-ml-locale] select with options en, pl, and no input element. The textarea has rows 4 and value 'Line 1\nLine 2'. Selecting pl shows 'Linia'. Typing 'Nowa\nlinia' emits [{en: 'Line 1\nLine 2', pl: 'Nowa\nlinia'}] on the MLTextareaField wrapper itself. Without size the textarea has rows 5. + - Changing the locale select of a mounted MLTextField switches a mounted MLTextareaField to the same locale (ML_LOCALE_CHANGE). + - FormView, update mode, roster schema with en/pl locales and `name` retyped to mltextarea with size small, record name {en: 'Ada\nLovelace', pl: ''}: #field-name-editor is a textarea showing 'Ada\nLovelace'. After the locale select is set to pl and 'Ada po polsku' is typed, the PUT body name equals {en: 'Ada\nLovelace', pl: 'Ada po polsku'}. + - Every existing MLFields, MarkdownField, registry, formState and FormView case passes without changing its assertions. + + +RED first: add the behavior cases. Registry rows go into the 'markdown and multilingual field types' describe in tests/form/registry.test.ts (extend the unknown-type it.each list with 'MLTEXTAREA'). isMLFieldType, editablePayload, initialValues and the MLTextareaField mount cases go into a new describe in tests/form/MLFields.test.ts, reusing mlMount; widen its component parameter type to accept MLTextareaField. The FormView case goes into the 'password and preset on the form' describe in tests/form/FormView.test.ts: build the schema from mlSchema('mltextarea', 'text') and set size 'small' on the name field, give the record a cloned rosterRecordFixture with the name map, and route PUT to rosterRecordFixture. Run them; they must fail. + +GREEN: +- mlLocale.ts: export ML_FIELD_TYPES (a readonly set of 'mltext', 'mltextarea', 'mlmarkdown') and isMLFieldType(type: string | undefined): boolean. Re-export isMLFieldType from formState.ts alongside the existing ML re-exports. +- Replace every inline comparison of a field type against the ML type names with isMLFieldType: formState.ts editablePayload and initialValues, the private isMLType (delete it and call isMLFieldType in presetUpdates), FormView.vue adopt and update, and RelationChildModal.vue adopt. Leave the presetUpdates target filter (text or mltext targets only) as it is, per the preset decision. Update the FormView comment that says MLTextField broadcasts the choice to say any ML control does. +- Create fields/MLField.vue as the single ML wrapper: props are FieldControlProps plus inner (a Vue Component), innerType (string) and mlType (string). It emits update:modelValue with Record of string to string. Move the script logic from MLTextField.vue into it unchanged: localeRecord values, FORM_ENABLED_LOCALES injection, activeLocale starting on the first enabled locale or 'en', setLocale, onLocaleInput with broadcastMLLocale, onText emitting the whole map, and the window ML_LOCALE_CHANGE listener added on mount and removed on unmount. Add a computed innerField that spreads props.field with type set to innerType, so the inner control gets a stable object and keeps size and attributes. The template is MLTextField's: the role=group root with aria-labelledby, data-ml-field bound to mlType, the same locale select markup and aria-label. In place of the hard-coded inner control it renders a dynamic component whose is binding is inner, given innerField, the active-locale text, the editor id `${controlId}-editor`, invalid and describedBy, and listening to update:modelValue with onText. No raw-HTML binding. +- Rewrite MLTextField.vue and MLMarkdownField.vue as thin shells: defineProps FieldControlProps, defineEmits update:modelValue. Each renders MLField with the passed props, inner TextField / innerType 'text' / mlType 'mltext' (respectively MarkdownField / 'markdown' / 'mlmarkdown'), and re-emits MLField's update:modelValue from the shell. The existing tests read emitted events on the shell, so a fallthrough listener is not enough. Create MLTextareaField.vue the same way with TextareaField / 'textarea' / 'mltextarea'. +- registry.ts: import MLTextareaField, register ['mltextarea', MLTextareaField] after mltext, add 'mltextarea' to groupLabelledTypes. Mention the ML wrapper in the header comment if the Phase notes there list control additions. + +Run the targeted and full Vitest suites and the typecheck. Then run npm --prefix admin run build and stage modules/boardwalk/dist together with the SPA change. Commit: feat(admin): render mltextarea fields per locale (SPA, tests, dist; no .planning files, no co-author trailers). + + + cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && npm --prefix admin test -- tests/form/MLFields.test.ts tests/form/registry.test.ts tests/form/MarkdownField.test.ts tests/form/formState.test.ts tests/form/FormView.test.ts && npm --prefix admin test && npm --prefix admin run typecheck && bash -c '! grep -rn "=== '"'"'mlmarkdown'"'"'" admin/src' && bash -c '! grep -nE "v-html=" admin/src/components/form/fields/MLField.vue admin/src/components/form/fields/MLTextareaField.vue | grep -vE ":[0-9]+:[[:space:]]*(//|