0024. A forbidden block or cell color is clamped as the author edits, and a stripped document is written back after a refused save¶
Status: Accepted
Date: 2026-09-20
Context¶
Upstream parses an inline style color on a block element into a block prop and on a cell into a cell attribute, any string, unvalidated (node_modules/@blocknote/core/src/blocks/defaultProps.ts:30-42, 88-99, 110-121; node_modules/@blocknote/core/src/extensions/tiptap-extensions/BackgroundColor/BackgroundColorExtension.ts:8-16). Chrome serializes color: rgb(...) and background-color: rgb(...) on a copied paragraph, heading, list item and table cell alike, so a paste from another site routinely carries such a value.
0010 took back a forbidden color mark in the changed ranges (js/src/links.tsx) and chose not to rewrite block and cell color props, because the save already refused them (0010 Alternatives, "Rewriting pasted slices to strip block and cell color props"; Consequences, "a block or cell color prop that arrives by paste ... refused it with a message naming the color"). Measured 2026-09-20: the refusal names the value verbatim, for example "The text format blocknote does not permit rgb(69, 91, 102) text," a value outside the palette has no CSS rule in the editor and so paints nothing an author can see or find, and after the refused save the re-rendered form's notice said "saving without editing submits the document as it is stored," which is untrue: the textarea then holds the refused submission, not stored data (js/src/stripped.tsx:4-8, js/src/index.tsx:711-728 before this record). The author's only way out was to make any edit and save a second time.
Decision¶
The changed-range guard resets a forbidden block or cell color the same way it resets a forbidden mark. policyGuard() in js/src/links.tsx is extended rather than duplicated: in the same appended transaction and the same changed ranges it already scans for marks, it now also visits every non-inline node in range and, for each of textColor and backgroundColor the node's attributes carry, resets a value the format does not permit to default. This reaches a blockContent node's own color, a table cell's or table header's, and the table node's own textColor. A table cell or header is checked with cellColorIsPermitted(); every other node is checked with colorIsPermitted(). cellColorIsPermitted(), exported from js/src/policy.ts beside colorIsPermitted(), is for now colorIsPermitted() itself; a later record narrows it further for a table that turns its own cell colors off, and both the guard and the load-time strip call the one function, so the two cannot drift apart. Amended 2026-09-20. Narrowed the same day by the table's cell_colors setting; see 0017's amendment of this date.
The load-time strip shares the same rule. stripTableContent() in js/src/policy.ts now calls a new stripCellProps(), which mirrors stripBlockProps() but checks a cell's color with cellColorIsPermitted() instead of colorIsPermitted(). A document opened in a format that has since narrowed its palette is stripped the same way on load as a paste is clamped afterwards.
A policy strip is written into the textarea when the field is already in error. js/src/index.tsx's attach callback computes writtenBack, true when a block had to be wrapped or unwrapped (as before) or when something was stripped and the textarea's aria-invalid attribute reads "true", the state core gives a field a submission refused. Instance gains strippedWrittenBack, set once at attach and read by render() to choose the notice's hint. When writtenBack is true the stripped document is written into the textarea and made the value core restores, exactly as a carried-block transform already was. Outside an error state nothing changes: the stored value is left alone, and a save without an edit submits it as stored, which the server refuses by name, the rule 0010 and docs/extending.md already state.
The notice's hint says which is true. StrippedContentNotice in js/src/stripped.tsx takes a writtenBack prop. While false it keeps the existing hint, that saving after an edit stores the document this way and saving without one submits the document as it is stored. While true it reads "The document is shown without it, and saving stores it this way," a plain quoted Drupal.t() literal, because the Kernel extraction test refuses a template string inside that call.
Alternatives considered¶
Refusing on save only, as 0010 chose. Rejected. The reason 0010 gave no longer holds: a refusal naming a color the author cannot see or locate anywhere in the editor is not a usable outcome, and the workaround, any edit and a second save, is not something an author who has not read the source would find on their own.
A separate guard extension for block and cell colors. Rejected. One transaction and one range scan already exist in policyGuard(); splitting the color check into its own plugin would scan the same ranges twice and give an undo two removals to walk back instead of one, and the mark and attribute halves already mirror stripStyles() and stripBlockProps()/stripCellProps() one to one.
Writing the strip back on every attach, not only while the field is in error. Rejected. Outside an error state the textarea holds stored data, and a save without an edit is meant to submit exactly what is stored; the server stays the authority over a stored document a format has since narrowed, the same posture 0011 and 0012 already take for other kinds of drift between a stored document and a narrowed format.
Drawing the refused color in the editor instead of clamping it. Rejected. A value outside the palette has no stylesheet rule to draw with; painting an arbitrary CSS color, however it arrived, would let the editor show what the format forbids, which is the opposite of what a trimmed palette is for.
Consequences¶
Upstream's row and column handle menu still lists all nine colors, the posture 0010 already keeps for the reason given there, but a color it sets that the format forbids is now taken back in the same transaction, so the pick appears to do nothing, the same posture 0010 already records for an address pasted over a selection.
The load-time strip is unchanged in shape and still runs first, before any transaction the guard could see; stripCellProps() sits beside stripBlockProps() rather than replacing it, since a table cell's own permission rule may narrow further than a block's.
After a refused save, one Save with no further edit now succeeds instead of refusing the same document a second time.
docs/extending.md is corrected in the two places that described the old posture. Amended 2026-09-21. A third place, the paragraph saying a color chosen from the row and column handle menu is refused on save, was corrected on 2026-09-20 after review.
The blocknoteLinkPolicy key policyGuard() is registered under does not change, so an alter that names it to disable or reorder it sees no change in what it is naming.
References¶
js/src/links.tsx(policyGuard()),js/src/policy.ts(colorIsPermitted(),cellColorIsPermitted(),stripBlockProps(),stripCellProps(),stripTableContent()),js/src/index.tsx(Instance.strippedWrittenBack, the attach callback's write-back),js/src/stripped.tsx(StrippedContentNotice).src/Document/DocumentValidator.php:946-958(checkColor(), reportingDocumentError::ForbiddenColor, whose message isforbiddenColorMessageinsrc/Plugin/Validation/Constraint/BlockNoteDocumentConstraint.php:202), the refusal this record makes unreachable for a pasted or handle-menu color.- BlockNote 0.54.2:
@blocknote/coresrc/blocks/defaultProps.ts,src/extensions/tiptap-extensions/BackgroundColor/BackgroundColorExtension.ts. - Tests:
js/src/__tests__/paste.test.tsx,lifecycle.test.tsx,stripped.test.tsx;tests/src/FunctionalJavascript/PasteTest.php::testPastedColorsOutsideThePaletteAreDroppedBeforeSave(),EditorPolicyTest.php::testForbiddenStyleComesOffTheStoredDocument(). Amended 2026-10-31. ThePasteTestmethod is gone:paste.test.tsxresets a pasted block color and a pasted cell color against a real editor, andtests/src/Kernel/BlockContentValidationTest.phpandTableBlockSettingsTest.phphold the refusal on save. - 0010, 0011, 0023.