Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/7083-richtext-field-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@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 does not read, and it is there because the cross-check measured
`richtext` symmetric with `markdown` and `html` on every available axis — one widget and
one code path, plus `@objectstack/spec` 17.3.0 `FieldSchema` answering identically for
all three (`rows` admitted, the spec's own `maxLength` admitted, the legacy snake_case
`max_length` refused by name alike). Omitting it would have left `richtext` the one key
of the three whose ceiling cannot be authored — a fresh instance of the asymmetry this
member exists to end.
17 changes: 10 additions & 7 deletions packages/fields/src/widgets/RichTextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,16 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
return <Display value={value} field={field} />;
}

// 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
Expand Down
18 changes: 12 additions & 6 deletions packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Textarea>`; the fullscreen dialog deliberately ignores it
Expand All @@ -26,7 +29,12 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { HtmlFieldMetadata, MarkdownFieldMetadata, TextareaFieldMetadata } from '@object-ui/types';
import type {
HtmlFieldMetadata,
MarkdownFieldMetadata,
RichtextFieldMetadata,
TextareaFieldMetadata,
} from '@object-ui/types';

import { RichTextField } from '../RichTextField';
import { TextAreaField } from '../TextAreaField';
Expand All @@ -45,9 +53,7 @@ describe('RichTextField — declared `rows` sizes the inline editor (#6140)', ()
});

it('richtext: the third registry key of the same widget honours rows too', () => {
// No `RichtextFieldMetadata` exists in the union — the runtime shape is
// structural. Cast, deliberately, at the one seam that has no declared type.
const field = { type: 'richtext', name: 'doc', rows: 10 } as unknown as MarkdownFieldMetadata;
const field: RichtextFieldMetadata = { type: 'richtext', name: 'doc', rows: 10 };
render(<RichTextField value="<p>hi</p>" onChange={() => {}} field={field} />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '10');
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `RichtextFieldMetadata` — the third registry key of one widget becomes
* DECLARABLE (objectui#7083, maintainer ruling of 2026-09-07, batch #71).
*
* ## What was missing, and why a cast was the only evidence
*
* `markdown`, `html` and `richtext` are one widget (objectui#5498). Two of the
* three carried an exported metadata type; `richtext` carried none, so the
* runtime served it happily by structure while an author could not write its
* metadata under an annotation at all. The state was neither a union member
* nor a recorded alias — a silent gap whose ONLY trace was a deliberate
* `as unknown as MarkdownFieldMetadata` in `RichTextField.rows.test.tsx`. That
* cast is gone; this file is what replaces it, so the gap cannot be
* rediscovered by the next reader who trips over a cast.
*
* ## The pin has two halves and needs both
*
* The ruling asks that "a typed `richtext` literal compiles AND the widget
* renders it", and the compile half is not decoration: a pin that only rendered
* would still pass with the cast in place, which is the state this card ends.
*
* - COMPILE — {@link richtextField} below is an annotated literal, so TypeScript's
* excess-property check judges every key in it, and it is assigned to
* `FieldMetadata` to pin the UNION membership the ruling actually granted.
* On the pre-change tree this file does not compile at all: there is no
* member to annotate against, which is what makes this leg non-vacuous.
* - RENDER — every key of the derived read set is then asserted at the DOM,
* each against a control that changes only that key, so a green here reads
* "the widget consumed the declared key" and never "the default happened to
* match".
*
* ## The read set is DERIVED from `RichTextField.tsx`, not copied
*
* `type` (`resolveRichTextFieldType`, the discriminator), `rows`
* (`richField?.rows || 8`), `placeholder`, `mobile_fullscreen` and `label` are
* the five keys the widget reads off this carrier on the `richtext` path; the
* readonly branch hands `field` to a `RICH_TEXT_CELL_RENDERERS` entry, and both
* renderers there read `value` only. The last group below pins the measurement
* that put the sixth key, `max_length`, on the member even though the widget
* does not read it — see the member's own docblock in
* `packages/types/src/field-types.ts`.
*/

import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { FieldSchema } from '@objectstack/spec/data';
import type { FieldMetadata, RichtextFieldMetadata } from '@object-ui/types';

import { RichTextField } from '../RichTextField';

/**
* The whole derived read set in ONE annotated literal.
*
* Annotated, never `as`: the annotation is what makes TypeScript judge each
* key, and judging each key is the half of this pin that a cast would have
* silently satisfied.
*/
const richtextField: RichtextFieldMetadata = {
type: 'richtext',
name: 'doc',
label: 'Release notes',
rows: 10,
placeholder: 'Write the release notes…',
mobile_fullscreen: true,
max_length: 5000,
};

describe('RichtextFieldMetadata — the declarable face of the `richtext` key', () => {
it('is a member of the `FieldMetadata` union and discriminates on `type`', () => {
const asUnion: FieldMetadata = richtextField;

// The runtime half of the same statement, so the assertion is not purely a
// compile-time artefact that a `// @ts-expect-error` sweep could hide.
expect(asUnion.type).toBe('richtext');

// …and the union NARROWS on it: reaching `rows` through the union without a
// cast is the authoring capability the ruling granted. Before this member
// existed there was no branch of the union this literal could inhabit.
if (asUnion.type !== 'richtext') throw new Error('unreachable: literal is a richtext field');
expect(asUnion.rows).toBe(10);
expect(asUnion.max_length).toBe(5000);
});

it('renders the declared `rows` on the inline editor', () => {
render(<RichTextField value="<p>hi</p>" onChange={() => {}} field={richtextField} />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '10');
});

it('renders the declared `placeholder`', () => {
render(<RichTextField value="" onChange={() => {}} field={richtextField} />);
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'Write the release notes…');
});

it('renders the expand affordance for `mobile_fullscreen`, titled with `label`', () => {
render(<RichTextField value="<p>hi</p>" onChange={() => {}} field={richtextField} />);

const toggle = screen.getByTestId('richtext-fullscreen-toggle');
expect(toggle).toBeInTheDocument();

// `label` reaches the dialog title, which is a direct render of the key
// rather than an interpolated sentence — so this assertion cannot be
// satisfied by a fallback string.
fireEvent.click(toggle);
expect(screen.getByTestId('richtext-fullscreen-dialog')).toBeInTheDocument();
expect(screen.getByText('Release notes')).toBeInTheDocument();
});

it('control: the same widget, same type, with the optional keys omitted', () => {
// One key different per assertion above; here they are all absent at once,
// and the widget answers with its own defaults. Without this, a green above
// would be compatible with the widget ignoring the metadata entirely.
const bare: RichtextFieldMetadata = { type: 'richtext', name: 'doc' };
render(<RichTextField value="" onChange={() => {}} field={bare} />);

expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
expect(screen.getByRole('textbox')).not.toHaveAttribute('placeholder', 'Write the release notes…');
expect(screen.queryByTestId('richtext-fullscreen-toggle')).not.toBeInTheDocument();
});
});

/**
* The measurement behind the ONE key on the member that `RichTextField` does
* not read.
*
* `max_length` is on {@link import('@object-ui/types').MarkdownFieldMetadata}
* and `HtmlFieldMetadata`. Putting it on the new member was a decision, and it
* rests on this reading rather than on the two siblings having it: at
* `@objectstack/spec` 17.3.0 the authoring boundary answers IDENTICALLY for all
* three of the field types this one widget serves. Pinned so the member's
* docblock cannot rot into a false canonical claim — the failure mode
* objectui#7014 was opened for.
*/
describe('spec boundary — `richtext` is symmetric with `markdown`/`html` on the ceiling key', () => {
const base = (type: string) => ({ name: 'body', type, label: 'Body' });

/**
* Pull the `unrecognized_keys` issue naming `key`, or undefined.
*
* Typed off `safeParse`'s own return rather than through `any`: narrowing on
* the issue's `code` is what makes `keys` reachable, and it is also what
* keeps this helper honest — an issue of a different code can never satisfy
* it by carrying a same-named field.
*/
const refusedByName = (result: ReturnType<typeof FieldSchema.safeParse>, key: string) =>
result.success
? undefined
: result.error.issues.find((i) => i.code === 'unrecognized_keys' && i.keys.includes(key));

for (const type of ['markdown', 'html', 'richtext'] as const) {
it(`control: \`${type}\` with no ceiling key is accepted`, () => {
expect(FieldSchema.safeParse(base(type)).success).toBe(true);
});

it(`\`${type}\` ADMITS the spec's own \`maxLength\``, () => {
expect(FieldSchema.safeParse({ ...base(type), maxLength: 5000 }).success).toBe(true);
});

it(`\`${type}\` refuses the objectui legacy spelling \`max_length\` BY NAME`, () => {
const res = FieldSchema.safeParse({ ...base(type), max_length: 5000 });
expect(res.success).toBe(false);
expect(refusedByName(res, 'max_length'), `expected unrecognized_keys naming 'max_length' on ${type}`).toBeDefined();
// Control, per fixture: the key is the only difference from the accepted
// payload above, so "refused" is about the key and not about the field.
expect(FieldSchema.safeParse(base(type)).success).toBe(true);
});
}
});
72 changes: 72 additions & 0 deletions packages/types/src/field-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,77 @@ export interface HtmlFieldMetadata extends BaseFieldMetadata {
rows?: number;
}

/**
* Rich-text (WYSIWYG) field metadata — the THIRD registry key `RichTextField`
* serves, and the last of the three to get a declarable face.
*
* `markdown`, `html` and `richtext` are ONE widget (objectui#5498). The first
* two carried an exported metadata type; `richtext` carried none, so the only
* way to write a richtext field's metadata was
* `as unknown as MarkdownFieldMetadata` — a deliberate cast whose presence in
* `RichTextField.rows.test.tsx` was the gap's only evidence. Neither a member
* nor a recorded alias: a silent hole that had to be rediscovered to be seen.
* Closed by the maintainer's objectui#7083 ruling (decision batch #71,
* 2026-09-07), which is why the cast and its comment are gone as well.
*
* ## The read set below is DERIVED, not copied from the siblings
*
* Read off `RichTextField.tsx` on the `richtext` path, key by key:
*
* - `type` — `resolveRichTextFieldType` reads `field.type` (stripping a
* `field:` prefix). It is THE discriminator for the three keys, and the
* ONLY thing that differs between them at runtime: it selects the display
* pipeline (`richtext` reads through the HTML renderer, objectui#5452) and
* the editor's format label. Nothing else in the widget branches on it.
* - `rows` — `richField?.rows || 8`, the inline editor's height. Declared
* here for the same reason objectui#6140 declared it on the two siblings:
* the running widget honoured a key an annotated literal rejected.
* - `mobile_fullscreen`, `placeholder`, `label` — also read off this carrier
* (the expand affordance, the textarea placeholder, the dialog title), and
* all three already sit on {@link BaseFieldMetadata}, so they need no
* redeclaration here. Listed because "derived" has to name what it found,
* including the keys that turned out to need no line.
*
* The readonly branch hands `field` whole to the `RICH_TEXT_CELL_RENDERERS`
* entry for the type; both renderers there destructure `value` only, so the
* display half contributes no metadata key.
*
* ## `max_length` — the one key here the widget does NOT read
*
* It is declared because the cross-check against {@link MarkdownFieldMetadata}
* and {@link HtmlFieldMetadata} found `richtext` symmetric with both on every
* axis that can be measured, not because they happen to have it:
*
* - one widget, one code path — see `type` above;
* - `@objectstack/spec` 17.3.0 `FieldSchema` answers IDENTICALLY for all three
* field types: `rows` admitted, the spec's own camelCase `maxLength`
* admitted, and the legacy snake_case `max_length` refused BY NAME on each
* of them alike. Pinned in
* `packages/fields/src/widgets/__tests__/richtext-field-metadata-7083.test.tsx`
* so this paragraph cannot rot into a false canonical claim.
*
* Omitting it would have left `richtext` the one key of the three whose
* ceiling cannot be authored under an annotation — a fresh instance of the
* asymmetry this member exists to end.
*
* ⚠️ The `rows` docblocks on the two siblings still describe the
* `@objectstack/spec` 17.2.0 boundary, where `rows` was refused by name. That
* prose is objectui#7635's declared surface, not this member's; the 17.3.0
* reading above is stated for `richtext` only and is not a correction of it.
*/
export interface RichtextFieldMetadata extends BaseFieldMetadata {
type: 'richtext';
max_length?: number;
/**
* Height of the INLINE editor, in text rows. Same read as
* `MarkdownFieldMetadata.rows` and `HtmlFieldMetadata.rows` — literally the
* same expression, since the three registry keys are one widget: `rows`
* lands on the inline `<Textarea>`'s `rows` attribute and the fullscreen
* dialog ignores it (`rows={fullHeight ? undefined : rows}`).
*/
rows?: number;
}

/**
* Number field metadata
*/
Expand Down Expand Up @@ -826,6 +897,7 @@ export type FieldMetadata =
| TextareaFieldMetadata
| MarkdownFieldMetadata
| HtmlFieldMetadata
| RichtextFieldMetadata
| NumberFieldMetadata
| CurrencyFieldMetadata
| PercentFieldMetadata
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ export type {
TextareaFieldMetadata,
MarkdownFieldMetadata,
HtmlFieldMetadata,
RichtextFieldMetadata,
NumberFieldMetadata,
CurrencyFieldMetadata,
PercentFieldMetadata,
Expand Down
Loading