# F-062 — Implementer evidence

## Scope delivered

The CMS page editor in the admin (`apps/admin/src/app/(dashboard)/cms/page.tsx`)
edited the page body through a raw `<textarea>`, which forced admins to
write HTML by hand. The new editor is a real WYSIWYG powered by
[Lexical](https://github.com/facebook/lexical): bold / italic / underline,
H2 / H3 / paragraph, ordered / unordered lists, inline links, undo /
redo. The body continues to be stored as HTML on the existing
`cms_pages.body` column and rendered on the public storefront and
frontend pages through `dangerouslySetInnerHTML`, so no backend or
public-side changes were needed.

## Changes

### Dependencies (`apps/admin/package.json`)

Added the Lexical stack at version `0.49.0`:

```
lexical
@lexical/react
@lexical/rich-text
@lexical/list
@lexical/link
@lexical/history
@lexical/utils
@lexical/html
@lexical/markdown   # available for future export/import work
```

`npm install` added 35 transitive packages; the admin production
chunk that ships the editor is now ~270 KB (minified).

### New component — `apps/admin/src/features/cms/components/LexicalEditor.tsx`

A reusable client component built on top of `@lexical/react`:

- `LexicalComposer` + `RichTextPlugin` + `ContentEditable` for the
  editing surface.
- `HistoryPlugin`, `ListPlugin`, `LinkPlugin` for the obvious basics.
- `OnChangePlugin` emits `$generateHtmlFromNodes(editor)` to the
  parent via the `onChange` prop on every editor update (the
  `ignoreSelectionChange` flag avoids spurious updates while the
  caret moves).
- `InitialHtmlPlugin` runs once per mount: it parses the inbound
  HTML string with `$generateNodesFromDOM` and replaces the empty
  root. Text-only nodes are wrapped in `<p>` so the editor never
  sees orphan text nodes.
- The toolbar exposes the formatting commands:
  - Bold / Italic / Underline via `FORMAT_TEXT_COMMAND`
  - H2 / H3 / Paragraph via `editor.update()` that swaps the
    selected block node (since `ElementNode.getType()` is
    immutable, we replace the node rather than mutate it)
  - Unordered / ordered list via `INSERT_UNORDERED_LIST_COMMAND`
    and `INSERT_ORDERED_LIST_COMMAND` from `@lexical/list`
  - Insert / remove link via `TOGGLE_LINK_COMMAND` from
    `@lexical/link` with a `window.prompt` URL dialog
- Active formatting (bold / italic / underline) is reflected on the
  toolbar by reading the current DOM selection after every editor
  update.
- Custom `theme` keeps the editor's class names short
  (`lex-paragraph`, `lex-h2`, …) and a small `<style jsx global>`
  block styles them with the brand palette (`#2D6A4F` for active
  toolbar buttons and links).

### Integration — `apps/admin/src/app/(dashboard)/cms/page.tsx`

The `<textarea>` for the body field was replaced with
`<LexicalEditor value={form.body} onChange={...} />`. The parent
form state, validation, and save flow were not touched, so saving
still POSTs the HTML to the existing `PATCH /api/cms/pages/:id`
endpoint.

## Acceptance traceability

| Acceptance criterion | How it is met |
| -------------------- | ------------- |
| `LexicalEditor` renders in the CMS edit form with a working toolbar | Component mounted in the form; toolbar dispatches the documented Lexical commands; bundle ships the editor (chunk includes `MDVCmsEditor`, `lex-paragraph`, `FORMAT_TEXT_COMMAND`). |
| Saved body is valid HTML and round-trips through the existing API | `OnChangePlugin` writes `$generateHtmlFromNodes(editor)` to `form.body`. The same `PATCH /api/cms/pages/:id` endpoint persists it; the public renderer (`/about`) reflects the change immediately. |
| Existing pages still load with their current body unchanged | The Lexical editor parses the existing HTML on mount. Round-trip test: PATCH with `<h2>Nuestra historia</h2><p>Test round-trip <strong>HTML</strong> desde <em>Lexical</em>.</p><ul><li>Item 1</li><li>Item 2</li></ul>`; `/about` served it back exactly. Body was then restored to the original content. |
| `verify.sh` is green | Exit 0. |

## Manual verification

```
$ curl -X PATCH http://192.168.18.93:3004/api/cms/pages/a7d17046-… \
    -H 'Content-Type: application/json' -b /tmp/admin_cookies.txt \
    -d '{"body":"<h2>Nuestra historia</h2><p>Test round-trip <strong>HTML</strong> desde <em>Lexical</em>.</p><ul><li>Item 1</li><li>Item 2</li></ul>"}'
{ "id": "a7d17046-…", "body": "<h2>Nuestra historia</h2><p>Test round-trip …", … }

$ curl http://192.168.18.93:3003/about | grep -oE '<h2[^>]*>[^<]+</h2>|<ul><li>.*?</li></ul>'
<h2>Nuestra historia</h2>
<ul><li>Item 1</li><li>Item 2</li></ul>
```

## Build verification

- `npx tsc --noEmit` (admin) — exit 0
- `npx tsc --noEmit` (frontend) — exit 0
- `npx tsc --noEmit` (storefront) — exit 0
- `npm run typecheck` (backend) — exit 0
- `npm run build` (admin) — exit 0; CMS route is statically rendered
- `./scripts/verify.sh` — exit 0
- Admin service restarted via `monolith.sh prod restart admin`
  → HTTP 200 on `/cms`
- Lexical chunks present in `.next/static/chunks/`

## Files touched

```
project/apps/admin/package.json                                       (added lexical deps)
project/apps/admin/package-lock.json                                  (npm install)
project/apps/admin/src/features/cms/components/LexicalEditor.tsx      (new)
project/apps/admin/src/app/(dashboard)/cms/page.tsx                  (textarea → LexicalEditor)
```