diff --git a/.changeset/7083-richtext-field-metadata.md b/.changeset/7083-richtext-field-metadata.md
new file mode 100644
index 0000000000..85242174ed
--- /dev/null
+++ b/.changeset/7083-richtext-field-metadata.md
@@ -0,0 +1,56 @@
+---
+'@object-ui/types': minor
+---
+
+`RichtextFieldMetadata` — the third registry key of `RichTextField` becomes declarable
+(objectui#7083, maintainer ruling 2026-09-07, director decision batch #71).
+
+`markdown`, `html` and `richtext` are one widget (objectui#5498). Two of the three
+already had an exported metadata type; `richtext` had none, so the runtime served it by
+structure while an author could not write its metadata under an annotation at all. The
+only way to write one was `as unknown as MarkdownFieldMetadata`, and that deliberate
+cast — in this repo's own pin test — was the gap's sole evidence. The state was neither
+a union member nor a recorded alias, which is why it had to be rediscovered to be seen.
+
+**New.** `RichtextFieldMetadata` is exported from `@object-ui/types` and joins the
+`FieldMetadata` union, so a richtext field's metadata can be written as a typed literal
+and narrowed out of the union on `type`:
+
+```ts
+import type { RichtextFieldMetadata } from '@object-ui/types';
+
+const doc: RichtextFieldMetadata = {
+ type: 'richtext',
+ name: 'doc',
+ label: 'Release notes',
+ rows: 10,
+ placeholder: 'Write the release notes…',
+};
+```
+
+**Additive only.** Nothing is removed or narrowed: `richtext` field metadata that was
+previously written through a cast keeps compiling, and every other member of the union
+is untouched. The one behavioural surface — `RichTextField` — is unchanged; it already
+served all three keys and this release only gives the third one a face.
+
+**The member's shape was derived, not copied from its two siblings.** `type`, `rows`,
+`placeholder`, `mobile_fullscreen` and `label` are the keys `RichTextField` actually
+reads on the `richtext` path (the last three already sit on `BaseFieldMetadata`, so the
+member declares `type` and `rows`); the readonly branch hands the metadata to a cell
+renderer that reads `value` only and contributes no key.
+
+`max_length` is the one declared key the widget itself does not read, and it is declared
+because a live reader outside the widget does: `buildValidationRules` compiles
+`maxLength ?? max_length` into a react-hook-form rule for **every** field it is handed —
+it is generic, with no field-type gate — and both form producers call it on every field
+they build. So `max_length` on a `richtext` field is enforced when the form is
+submitted. (It is not, however, forwarded to the editor's HTML `maxlength` attribute,
+and it gets no default cap in `EmbeddableForm`: both of those enumerate field types and
+omit `richtext`. That predates this release and is unchanged by it.) Omitting the key
+would have left `richtext` the one type of the three whose ceiling cannot be authored
+under an annotation while the submit-time rule enforcing it stayed live — a fresh
+instance of the asymmetry this member exists to end.
+
+Docs: `content/docs/fields/rich-text.mdx` teaches all three metadata types and carries a
+`RichtextFieldMetadata` snippet; before this release the page stated there was no third
+type.
diff --git a/content/docs/fields/rich-text.mdx b/content/docs/fields/rich-text.mdx
index f337af863c..8bfe7787d0 100644
--- a/content/docs/fields/rich-text.mdx
+++ b/content/docs/fields/rich-text.mdx
@@ -14,13 +14,18 @@ The Rich Text Field component edits formatted text content with markdown or HTML
## Field Schema
-`markdown` and `html` are two field types served by one widget, and each has its own
-exported metadata type — `MarkdownFieldMetadata` and `HtmlFieldMetadata`
-(`@object-ui/types`). Both extend `BaseFieldMetadata` and add a length bound and an
-inline-editor height; there is no combined "rich text" metadata type.
+`markdown`, `html` and `richtext` are three field types served by one widget, and each
+has its own exported metadata type — `MarkdownFieldMetadata`, `HtmlFieldMetadata` and
+`RichtextFieldMetadata` (`@object-ui/types`). All three extend `BaseFieldMetadata` and
+add a length bound and an inline-editor height; there is no combined "rich text"
+metadata type.
```ts
-import type { HtmlFieldMetadata, MarkdownFieldMetadata } from '@object-ui/types';
+import type {
+ HtmlFieldMetadata,
+ MarkdownFieldMetadata,
+ RichtextFieldMetadata,
+} from '@object-ui/types';
const releaseNotes: MarkdownFieldMetadata = {
type: 'markdown',
@@ -39,15 +44,36 @@ const emailBody: HtmlFieldMetadata = {
label: 'Email Body',
max_length: 50000,
};
+
+const articleBody: RichtextFieldMetadata = {
+ type: 'richtext',
+ name: 'article_body',
+ label: 'Article Body',
+ rows: 10,
+ max_length: 50000,
+};
```
-`rows` sizes the inline editor, in text rows — declared on both types (and on
+`richtext` gained its exported type in
+[objectui#7083](https://github.com/objectstack-ai/objectui/issues/7083); until then it
+was the one key of the three with no member to annotate against, so its metadata could
+only be written through a cast to one of its siblings. It stores **HTML** — it reads
+through the same display pipeline as `html`, not the markdown one
+([objectui#5452](https://github.com/objectstack-ai/objectui/issues/5452)) — and that
+pipeline is the only thing `type` changes between the three.
+
+`max_length` is a bound on the stored text. The widget itself does not read it; the
+form does — `buildValidationRules` compiles it into the submit-time validation rules
+for every field, whatever its type — so it is enforced when the form is submitted, not
+as a `maxlength` attribute on the rich-text editor.
+
+`rows` sizes the inline editor, in text rows — declared on all three types (and on
`@objectstack/spec`'s `FieldSchema` for the multiline editor types), matching the
`textarea` field's key of the same name ([objectui#6140](https://github.com/objectstack-ai/objectui/issues/6140)).
The editor is a plain textarea today — there is no formatting toolbar, no preview pane
-and no pixel height to configure, so neither metadata type declares one: `toolbar`,
-`preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing them
-does nothing.
+and no pixel height to configure, so none of the three metadata types declares one:
+`toolbar`, `preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing
+them does nothing.
The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).
@@ -116,3 +142,8 @@ The rich text field can operate in different modes:
- Full HTML editing
- Advanced formatting
- Embedded content support
+
+3. **Rich Text Mode** (`type: 'richtext'`)
+ - Stores HTML, and reads through the same display pipeline as `html`
+ - Editable under `RichtextFieldMetadata`
+ - Same plain-textarea editing surface as the other two
diff --git a/packages/fields/src/widgets/RichTextField.tsx b/packages/fields/src/widgets/RichTextField.tsx
index 598f1a4198..a744d6c772 100644
--- a/packages/fields/src/widgets/RichTextField.tsx
+++ b/packages/fields/src/widgets/RichTextField.tsx
@@ -280,13 +280,16 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
return ;
}
- // The declared metadata face for this widget's registry keys. `markdown` and
- // `html` each have an exported type; the third key, `richtext`, has no union
- // member of its own and structurally matches the same three optional reads
- // below — every key this widget consumes (`rows`, `mobile_fullscreen`,
- // `placeholder`, `label`) is DECLARED on both members, `rows` since the
- // objectui#6140 Option A ruling (which is what retired the `as any` that
- // used to launder this carrier).
+ // The declared metadata face for this widget's registry keys. All three of
+ // them have an exported type: `markdown` and `html` always did, and the third
+ // key, `richtext`, gained `RichtextFieldMetadata` in objectui#7083 — which is
+ // what retired the deliberate `as unknown as MarkdownFieldMetadata` its pin
+ // test needed for as long as the union had no branch to write it against.
+ // The cast below names two of the three because it does not have to
+ // discriminate: every key this widget consumes (`rows`, `mobile_fullscreen`,
+ // `placeholder`, `label`) is DECLARED on all three, so the two named already
+ // admit every read below — `rows` since the objectui#6140 Option A ruling
+ // (which is what retired the `as any` that used to launder this carrier).
const richField = field as MarkdownFieldMetadata | HtmlFieldMetadata;
const rows = richField?.rows || 8;
// The stored syntax, DERIVED from the type's display pipeline rather than
diff --git a/packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx b/packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx
index 031a8050ca..4757897eb1 100644
--- a/packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx
+++ b/packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx
@@ -15,8 +15,11 @@
* 2026-08-25, Option A, aligning the `TextareaFieldMetadata` precedent), so
* the field literals below carry it under the excess-property check rather
* than through a cast. The `richtext` registry key resolves to the same
- * widget (objectui#5498) with no union member of its own, so its case is the
- * one deliberate `as` in this file.
+ * widget (objectui#5498) and now has a union member of its own too
+ * (`RichtextFieldMetadata`, objectui#7083), so all three literals here are
+ * annotated and this file holds no `as` at all — the deliberate cast that used
+ * to sit on the richtext case WAS the only evidence that the third key had no
+ * declarable face, and it went with the gap it recorded.
*
* Direction of the DOM assertion: `rows` lands on the HTML `rows` attribute of
* the inline `