diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
index 2ab0b1c870..c10f34f2a6 100644
--- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
@@ -10,6 +10,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- We fixed a security vulnerability (CVE-2026-13149).
+### Added
+
+- We added new configuration to allow users to use class names instead of inline styling in generated HTML to support strict CSP.
+
+- We added a help button to the toolbar that opens a dialog listing the editor's keyboard shortcuts. The button is shown when the full toolbar is enabled and can be turned off with the new "Keyboard shortcuts" setting.
+
+- We added translations for the toolbar, dialogs, and help texts. The editor UI now follows the page language automatically, with bundled translations for English, Dutch, German, French, and Spanish (falling back to English).
+
+- We added a clear button to every color picker (font color, font background, and the table and cell background and border color pickers) so a color can be reset to none.
+
+### Fixed
+
+- We fixed the image dialog to only show the image source tabs that are available: the Media Library tab now appears only when an image data source is configured, and the Upload tab is hidden when default upload is disabled.
+
+- We fixed Tiptap duplicate extension warnings for `link`, `textStyle`, `underline`, and `textDirection` by properly configuring StarterKit, removing redundant extension registrations in TextColorClass, memoizing the extensions array to prevent re-registration on component re-renders, and disabling the core TextDirection extension that conflicts with our custom implementation.
+
+- We fixed an issue where empty editor content was saving as `
` instead of an empty string, which incorrectly passed required field validation. Empty content now correctly saves as `""`, ensuring proper validation behavior. Note: This is a breaking change for forms that were relying on the incorrect behavior - required RichText fields will now correctly reject empty content.
+
+- We fixed an issue where the editor pasting back the whole sentence instead of the single copied word
+
+- We fixed an issue where table header cells did not honor background color and border (color, style, width) configuration. Header cells can now be styled the same way as regular cells.
+
+### Changed
+
+- We aligned the image dialog's "Upload" tab dropzone with the File Uploader widget's dropzone so both share the same look, states, and messages.
+
+- We removed codemirror from code dialog viewer due to unsupported strict CSP policy. A simple internally built code editor using highlightjs is now replacing it.
+
## [4.12.0] - 2026-04-22
### Added
diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js
index c05860d869..9edf3d3ca6 100644
--- a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js
+++ b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js
@@ -2,6 +2,7 @@ import { expect, test } from "@mendix/run-e2e/fixtures";
import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers";
test.describe("RichText", () => {
+ test.describe.configure({ mode: "serial" });
test("compares with a screenshot baseline and checks if inline basic mode are rendered as expected", async ({
page
}) => {
@@ -32,8 +33,8 @@ test.describe("RichText", () => {
await expect(page.locator(".mx-name-richText1")).toBeVisible();
await expect(page.locator(".mx-name-richText1")).toHaveScreenshot(`bottomToolbarAdvancedMode.png`);
- await page.click(".mx-name-richText1 .ql-toolbar button.ql-image");
- await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
+ await page.click('.mx-name-richText1 .tiptap-toolbar button[title="Insert Image"]');
+ await expect(page.locator(".mx-name-richText1 .toolbar-dialog.image-dialog").first()).toHaveScreenshot(
`insertImageDialog.png`
);
});
@@ -47,8 +48,8 @@ test.describe("RichText", () => {
await expect(page.locator(".mx-name-richText4")).toBeVisible();
await expect(page.locator(".mx-name-richText4")).toHaveScreenshot(`toolbarAdvancedMode.png`);
- await page.click(".mx-name-richText1 .ql-toolbar button.ql-view-code");
- await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
+ await page.click('.mx-name-richText4 .tiptap-toolbar button[title="View/Edit Code"]');
+ await expect(page.locator(".mx-name-richText4 .highlighted-code-editor").first()).toHaveScreenshot(
`viewCodeDialog.png`
);
});
@@ -115,23 +116,107 @@ test.describe("RichText", () => {
await expect(page.locator(".mx-name-richText6")).toHaveScreenshot(`readOnlyModeReadPanel.png`);
});
+ test("compares with a screenshot baseline and checks if class mode editor is rendered as expected", async ({
+ page
+ }) => {
+ await page.goto("/p/classmode");
+ await waitForMendixApp(page);
+ await expect(page.locator(".mx-name-richText1")).toBeVisible();
+ await expect(page.locator(".mx-name-richText1")).toHaveScreenshot(`classModeEditor.png`, { threshold: 0.4 });
+ });
+
+ test("checks that class mode editor output uses CSS classes instead of inline styles", async ({ page }) => {
+ await page.goto("/p/classmode");
+ await waitForMendixApp(page);
+
+ const editor = page.locator(".mx-name-richText1 .tiptap");
+ await expect(editor).toBeVisible();
+
+ // Apply text color, highlight and indent to the first block so the
+ // class-based output can be asserted. Re-select before each command
+ // because clicking a toolbar control collapses the DOM selection.
+ const firstBlock = editor.locator("h1, h2, h3, p").first();
+
+ await firstBlock.click({ clickCount: 3 });
+ await page.click('.mx-name-richText1 button[title="Text Color"]');
+ await page.locator(".color-picker-dropdown div[title]").nth(10).click();
+
+ await firstBlock.click({ clickCount: 3 });
+ await page.click('.mx-name-richText1 button[title="Background Color"]');
+ await page.locator(".color-picker-dropdown div[title]").nth(10).click();
+
+ await firstBlock.click({ clickCount: 3 });
+ await page.click('.mx-name-richText1 button[title="Increase Indent"]');
+
+ const html = await editor.innerHTML();
+
+ // Class mode emits class + data-* attributes, not inline styles.
+ expect(html).toMatch(/class="[^"]*has-text-color/);
+ expect(html).toMatch(/data-text-color="/);
+ expect(html).toMatch(/class="[^"]*has-text-highlight/);
+ expect(html).toMatch(/data-text-highlight="/);
+ expect(html).toMatch(/class="[^"]*indent-\d/);
+ expect(html).toMatch(/data-indent="/);
+ expect(html).not.toMatch(/style="[^"]*color:/);
+ expect(html).not.toMatch(/style="[^"]*background-color:/);
+ expect(html).not.toMatch(/style="[^"]*padding-left:/);
+ });
+
+ test("compares with a screenshot baseline of the View/Edit Code dialog in class mode", async ({ page }) => {
+ await page.goto("/p/classmode");
+ await waitForMendixApp(page);
+ await page.click('.mx-name-richText1 .tiptap-toolbar button[title="View/Edit Code"]');
+ await expect(page.locator(".mx-name-richText1 .highlighted-code-editor").first()).toHaveScreenshot(
+ `classModeViewCodeDialog.png`
+ );
+ });
+
test("compares with a screenshot for rich text inside modal popup layout", async ({ page }) => {
await page.goto("/");
await waitForMendixApp(page);
await page.click(".mx-navbar-item [title='Demo']");
- await page.click(".mx-name-customWidget1 .ql-toolbar button.ql-video");
- await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
+ await page.click('.mx-name-customWidget1 .tiptap-toolbar button[title="Insert YouTube Video"]');
+ await expect(page.locator(".toolbar-dialog.video-dialog").first()).toHaveScreenshot(
`richTextDialogInsidePopup.png`
);
- await page.click(".widget-rich-text .widget-rich-text-modal-body #rich-text-video-src-input");
- await page
- .locator(".widget-rich-text .widget-rich-text-modal-body #rich-text-video-src-input")
- .fill("https://www.mendix.com");
- await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
+ await page.locator(".toolbar-dialog.video-dialog #video-url").fill("https://www.mendix.com");
+ await expect(page.locator(".toolbar-dialog.video-dialog").first()).toHaveScreenshot(
`richTextDialogInsidePopupEdit.png`
);
});
+
+ test("clearing all content leaves the editor empty, not a stray ", async ({ page }) => {
+ await page.goto("/");
+ await waitForMendixApp(page);
+ await page.click("text=Generate Data");
+ await page.goto("/p/basic");
+ await waitForMendixApp(page);
+
+ // Find the first editable rich text editor
+ const editor = page.locator(".mx-name-richText1 .tiptap");
+ await editor.scrollIntoViewIfNeeded();
+ await expect(editor).toBeVisible();
+
+ // Click into the editor and clear all content. selectText() reliably
+ // selects the editor contents across platforms, unlike Control+A which
+ // depends on OS focus behaviour.
+ await editor.click();
+ await editor.selectText();
+ await page.keyboard.press("Backspace");
+
+ // Blur the editor to trigger the save/normalize path.
+ await page.keyboard.press("Tab");
+ await page.waitForTimeout(500);
+
+ // The editor should now be empty. Tiptap keeps a placeholder paragraph
+ // in the DOM, but the widget normalizes that empty paragraph to an
+ // empty string on save (see normalizeEmpty in EditorWrapper).
+ expect((await editor.textContent())?.trim() || "").toBe("");
+ // No text nodes remain — only an empty placeholder paragraph/break.
+ const strippedText = (await editor.innerHTML()).replace(/<[^>]*>/g, "").trim();
+ expect(strippedText).toBe("");
+ });
});
diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/inlineBasicMode-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/inlineBasicMode-chromium-linux.png
index 9fe9cee055..2581241d11 100644
Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/inlineBasicMode-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/inlineBasicMode-chromium-linux.png differ
diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png
index 1f2effedfc..508576582d 100644
Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png differ
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/.openspec.yaml
new file mode 100644
index 0000000000..c53ef21aaa
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-06-05
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/design.md
new file mode 100644
index 0000000000..b1909f37d3
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/design.md
@@ -0,0 +1,186 @@
+## Context
+
+The rich text editor uses TipTap, which already supports column widths via the `colwidth` attribute on table cells. TipTap's base `TableCell` extension includes this attribute, and the `TableBackgroundColor` extension reads it to generate `
` elements for column sizing.
+
+Currently:
+
+- `colwidth` is an array of pixel values (e.g., `[150]` for single column, `[100, 150, 200]` for colspan)
+- TipTap has a `resizable: true` option that should enable drag-to-resize, but the custom `TableBackgroundColorNodeView` doesn't implement resize handles
+- Users can click column borders, which sets a width, but cannot drag to adjust
+- No UI control exists to set exact pixel widths
+
+The existing cell configuration dropdown already has sections for background color, border color/style/width using a `ConfigurationSection` pattern.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Add a number input control to the cell configuration dropdown for setting column width
+- Support exact pixel values (25-1000px range)
+- Provide clear button to reset to auto width
+- Handle colspan cells by setting only the first column width
+- Follow existing configuration dropdown patterns and styling
+- Validate input and clamp to acceptable ranges
+
+**Non-Goals:**
+
+- Implementing drag-to-resize handles (would require significant NodeView work)
+- Supporting percentage or other CSS units (only pixels for now)
+- Setting widths for all columns in a colspan cell (only first column)
+- Syncing widths across all rows (only first row widths apply per TipTap's design)
+- Adding column width presets dropdown (keep it simple with number input only)
+
+## Decisions
+
+### Decision 1: Use number input type (not dropdown with presets)
+
+**Rationale:** User requested "exact pixel values" in requirements. A number input provides precision without limiting users to preset sizes.
+
+**Alternatives considered:**
+
+- Dropdown with presets (Small/Medium/Large): Too restrictive, doesn't meet "exact values" requirement
+- Hybrid (input + preset buttons): Over-engineered for initial implementation
+- Text input: Number input provides better UX with built-in increment/decrement and validation
+
+**Implementation:** Add new `"numberInput"` type to `ConfigurationSection` interface alongside existing `"colorPicker"` and `"dropdown"` types.
+
+---
+
+### Decision 2: Set only first column width for colspan cells
+
+**Rationale:**
+
+- User explicitly requested "set only first column" in requirements
+- Simplifies implementation and UX (no need for multi-column width editor)
+- Matches TipTap's array-based `colwidth` structure where `colwidth[0]` is the first column
+
+**Alternatives considered:**
+
+- Set all spanned columns to same width: Could surprise users if they had different widths set
+- Show per-column width array editor: Complex UI for edge case (most cells don't have colspan)
+- Disable control for colspan cells: Too restrictive
+
+**Implementation:** `onChange` handler creates `colwidth` array with single value: `[width]`
+
+---
+
+### Decision 3: Use setCellAttribute command (not custom command)
+
+**Rationale:**
+
+- `setCellAttribute("colwidth", value)` should work because `colwidth` is in TipTap's base TableCell
+- Other cell properties (backgroundColor, borderColor, etc.) use `setCellAttribute` successfully
+- No need to define custom commands in extensions
+
+**Alternatives considered:**
+
+- Custom `setCellWidth` command in `TableCellBackgroundColor`: Unnecessary abstraction
+- Modify `TableCellBackgroundColor.addAttributes()` to re-declare colwidth: Already inherits via `...this.parent?.()`
+
+**Implementation:** Direct call to `editor.chain().focus().setCellAttribute("colwidth", [width]).run()`
+
+---
+
+### Decision 4: Render clear button conditionally (when value exists)
+
+**Rationale:**
+
+- Provides explicit way to reset to auto width (null)
+- Visual indicator that a custom width is set
+- Follows common UI pattern (seen in search inputs, etc.)
+
+**Alternatives considered:**
+
+- Always show clear button: Clutters UI when empty
+- Use empty string to mean auto: Less explicit, could be confusing
+- Add separate "Auto" button: Redundant with clear functionality
+
+**Implementation:** Render `×` button only when `currentValue !== null`
+
+---
+
+### Decision 5: Store null for auto width (not empty array or zero)
+
+**Rationale:**
+
+- TipTap treats `colwidth: null` as auto-sizing behavior
+- Consistent with how TipTap's base extensions work
+- `colwidth: []` or `colwidth: [0]` could cause layout issues
+
+**Implementation:** When user clears input, call `setCellAttribute("colwidth", null)`
+
+## Risks / Trade-offs
+
+### Risk: Parent attribute inheritance might not work
+
+If `TableCellBackgroundColor` doesn't properly inherit `colwidth` from base `TableCell`, `setCellAttribute` calls will fail silently.
+
+**Mitigation:** The extension already uses `...this.parent?.()` in `addAttributes()`, which should preserve `colwidth`. If issues arise, explicitly re-declare `colwidth` in the extension's attributes.
+
+---
+
+### Risk: First-row-only behavior might confuse users
+
+TipTap only uses `colwidth` from the first row of the table to generate `
`. Setting widths on other rows has no effect.
+
+**Mitigation:** Could add tooltip or helper text: "Note: Only first row column widths apply." For initial implementation, leave as-is since it matches TipTap's inherent behavior.
+
+---
+
+### Risk: Custom NodeView might interfere with colwidth updates
+
+The `TableBackgroundColorNodeView` manually generates `
` in `updateColgroup()`. If it's not reactive to `colwidth` changes, widths might not update visually.
+
+**Mitigation:** The NodeView's `update()` method already calls `updateColgroup()` on node changes, so it should react to `colwidth` updates. Test thoroughly during implementation.
+
+---
+
+### Trade-off: No drag-to-resize (only manual input)
+
+Users lose the convenience of dragging column borders to resize.
+
+**Mitigation:** The number input provides precision that drag handles lack. If drag-to-resize is needed later, it would require integrating ProseMirror's `columnResizing` plugin into the custom NodeView (~200 lines of code). For now, manual input meets the stated requirements.
+
+---
+
+### Trade-off: Pixel-only units (no percentages)
+
+Users cannot set responsive column widths using percentages.
+
+**Mitigation:** Percentage support would require custom rendering logic (TipTap's `colwidth` is pixel-only). Tables in rich text editors typically use fixed layouts. Can be added later if users request it.
+
+## Migration Plan
+
+No migration needed. This is a purely additive feature:
+
+- Existing tables without `colwidth` continue to work (auto width)
+- Existing tables with `colwidth` set (via TipTap's base functionality) display correctly
+- No data model changes or breaking API changes
+
+Deployment:
+
+1. Merge code changes
+2. Run build
+3. Deploy widget to Mendix project
+4. Feature is immediately available in cell configuration dropdown
+
+Rollback:
+
+- If issues arise, remove the new configuration section from `createCellConfigurationSections()`
+- No data corruption risk (colwidth attribute is standard TipTap)
+
+## Open Questions
+
+1. **Should we show the actual rendered column width vs. the set value?**
+ - The set `colwidth` might differ from actual rendered width due to table layout constraints
+ - For initial implementation: Show the set value only (simpler)
+ - Can add "measured width" tooltip later if needed
+
+2. **Should we prevent setting widths on non-first-row cells?**
+ - Pro: Avoids confusion about why widths don't apply
+ - Con: Restricts user control, might not match mental model
+ - Decision: Allow setting on any cell (simpler), rely on TipTap's first-row behavior
+
+3. **Should we add keyboard shortcuts for common widths?**
+ - Out of scope for initial implementation
+ - Can be added later if users request it
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/proposal.md
new file mode 100644
index 0000000000..282fd1302a
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/proposal.md
@@ -0,0 +1,41 @@
+## Why
+
+Users need precise control over table column widths in the rich text editor. Currently, while TipTap supports column widths via the `colwidth` attribute, there's no UI control to set them. Users can only click column borders which sets widths without drag-to-resize capability, making it difficult to achieve desired layouts.
+
+## What Changes
+
+- Add a "Column Width" number input control to the cell configuration dropdown
+- Support setting exact pixel widths (25-1000px range)
+- Provide "Clear" button to reset to auto width
+- Handle colspan cells by setting only the first column width
+- Integrate with existing `setCellAttribute` command for `colwidth` attribute
+
+## Capabilities
+
+### New Capabilities
+
+- `table-column-width-control`: UI control in cell configuration dropdown allowing users to set precise column widths in pixels, with validation, auto width support, and colspan handling
+
+### Modified Capabilities
+
+
+
+## Impact
+
+**Affected Files:**
+
+- `src/components/toolbars/components/ConfigurationDropdown.tsx` - Add number input type support
+- `src/components/toolbars/components/ConfigurationDropdown.scss` - Add number input styling
+- `src/components/toolbars/ToolbarConfig.ts` - Update ConfigurationSection type definition
+- `src/components/toolbars/helpers/configurationHelpers.ts` - Add column width section
+
+**User Impact:**
+
+- Positive: Users gain precise control over table column widths via familiar number input UI
+- No breaking changes: Feature is additive, existing tables continue to work
+
+**Technical Impact:**
+
+- Uses existing TipTap `colwidth` attribute mechanism (no changes to data model)
+- No impact on existing table resize behavior (custom NodeView remains unchanged)
+- Follows established configuration dropdown patterns
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/specs/table-column-width-control/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/specs/table-column-width-control/spec.md
new file mode 100644
index 0000000000..1bafe2051b
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/specs/table-column-width-control/spec.md
@@ -0,0 +1,102 @@
+## ADDED Requirements
+
+### Requirement: Column width input control
+
+The cell configuration dropdown SHALL include a number input control labeled "Column Width" that allows users to set the column width in pixels.
+
+#### Scenario: Opening cell configuration shows current width
+
+- **WHEN** user selects a table cell and opens the cell configuration dropdown
+- **THEN** the "Column Width" input displays the current column width value if set, or shows empty with "Auto" placeholder if width is auto
+
+#### Scenario: Setting a valid column width
+
+- **WHEN** user enters a numeric value between 25 and 1000 in the "Column Width" input
+- **THEN** the column width is set to that exact pixel value and the column resizes accordingly
+
+#### Scenario: Clearing column width for auto sizing
+
+- **WHEN** user clicks the clear button or deletes the value from the "Column Width" input
+- **THEN** the column width is reset to auto (null) and the column resizes to fit content
+
+### Requirement: Column width validation
+
+The system SHALL validate column width input to ensure values are within acceptable ranges.
+
+#### Scenario: Entering value below minimum
+
+- **WHEN** user enters a value less than 25 pixels
+- **THEN** the system clamps the value to 25 pixels
+
+#### Scenario: Entering value above maximum
+
+- **WHEN** user enters a value greater than 1000 pixels
+- **THEN** the system clamps the value to 1000 pixels
+
+#### Scenario: Entering invalid input
+
+- **WHEN** user enters non-numeric text
+- **THEN** the system ignores the input and retains the previous value
+
+### Requirement: Colspan cell width handling
+
+The system SHALL set only the first column width when the selected cell has a colspan greater than 1.
+
+#### Scenario: Setting width on colspan cell
+
+- **WHEN** user sets column width on a cell with colspan=3 that currently has colwidth=[100, 150, 200]
+- **THEN** the system sets colwidth=[new_value] affecting only the first spanned column
+
+#### Scenario: Reading width from colspan cell
+
+- **WHEN** user opens cell configuration for a cell with colspan=3 and colwidth=[100, 150, 200]
+- **THEN** the "Column Width" input displays 100 (the first column's width)
+
+### Requirement: Number input UI component
+
+The configuration dropdown SHALL support a "numberInput" type with number-specific controls.
+
+#### Scenario: Number input with unit label
+
+- **WHEN** a configuration section has type "numberInput" with unit "px"
+- **THEN** the UI renders a number input field with "px" unit label beside it
+
+#### Scenario: Number input with clear button
+
+- **WHEN** a configuration section has type "numberInput" and the field has a value
+- **THEN** the UI displays a clear button (×) that resets the field to empty when clicked
+
+#### Scenario: Number input with placeholder
+
+- **WHEN** a configuration section has type "numberInput" with placeholder "Auto"
+- **THEN** the empty input field shows "Auto" as placeholder text
+
+### Requirement: Column width persistence
+
+The system SHALL persist column width values in the document's colwidth attribute.
+
+#### Scenario: Width persists after save and reload
+
+- **WHEN** user sets a column width to 150 pixels, saves the document, and reloads the page
+- **THEN** the column width remains 150 pixels and is displayed correctly in the cell configuration
+
+#### Scenario: Width persists across editing sessions
+
+- **WHEN** user sets column widths on multiple columns and closes the editor
+- **THEN** reopening the document displays all columns at their configured widths
+
+### Requirement: Visual feedback
+
+The system SHALL provide clear visual feedback when column width changes.
+
+#### Scenario: Immediate column resize on input
+
+- **WHEN** user changes the column width value in the input field
+- **THEN** the table column resizes immediately without requiring additional confirmation
+
+#### Scenario: Clear button visibility
+
+- **WHEN** the column width input has a value
+- **THEN** the clear button is visible
+- **WHEN** the column width input is empty
+- **THEN** the clear button is hidden
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/tasks.md
new file mode 100644
index 0000000000..dbd8a58ee0
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/tasks.md
@@ -0,0 +1,48 @@
+## 1. Type Definition Updates
+
+- [x] 1.1 Update `ConfigurationSection` interface in `src/components/toolbars/ToolbarConfig.ts` to add `"numberInput"` to type union
+- [x] 1.2 Add optional fields to `ConfigurationSection` interface: `min?: number`, `max?: number`, `step?: number`, `placeholder?: string`, `unit?: string`
+- [x] 1.3 Update `ConfigurationSection` interface in `src/components/toolbars/components/ConfigurationDropdown.tsx` to match the updated type definition
+- [x] 1.4 Update `getCurrentValue` return type to allow `string | number | null` instead of just `string | null`
+
+## 2. UI Component Implementation
+
+- [x] 2.1 Add number input rendering case in `ConfigurationDropdown.tsx` after the dropdown case (around line 73)
+- [x] 2.2 Create wrapper div with className `configuration-number-input` containing the input, unit label, and clear button
+- [x] 2.3 Implement number input with type="number", min/max/step/placeholder props from section config
+- [x] 2.4 Implement conditional unit label display using `configuration-unit` className when `section.unit` exists
+- [x] 2.5 Implement conditional clear button (×) with `configuration-clear-button` className, shown only when `currentValue !== null`
+- [x] 2.6 Wire up onChange handler to call `section.onChange(e.target.value)` on input change
+- [x] 2.7 Wire up clear button onClick handler to call `section.onChange("")` to reset to auto width
+
+## 3. Styling
+
+- [x] 3.1 Add `.configuration-number-input` styles in `ConfigurationDropdown.scss` with flex layout and 4px gap
+- [x] 3.2 Add `.configuration-input` styles matching `.configuration-select` with padding, borders, transitions, and focus states
+- [x] 3.3 Add number input specific styles to hide spinner arrows (`::-webkit-inner-spin-button`, `::-webkit-outer-spin-button`, `-moz-appearance: textfield`)
+- [x] 3.4 Add `.configuration-input::placeholder` styles with gray color and italic font
+- [x] 3.5 Add `.configuration-unit` styles with 12px font size, gray color, and nowrap
+- [x] 3.6 Add `.configuration-clear-button` styles with padding, borders, hover/active states matching the design
+
+## 4. Cell Configuration Logic
+
+- [x] 4.1 Add new configuration section object in `createCellConfigurationSections()` function in `src/components/toolbars/helpers/configurationHelpers.ts` after `cellBorderWidth`
+- [x] 4.2 Set section id to `"cellWidth"`, label to `"Column Width"`, type to `"numberInput"`
+- [x] 4.3 Set min to 25, max to 1000, step to 1, placeholder to "Auto", unit to "px"
+- [x] 4.4 Implement `getCurrentValue` function that calls `getCellAttributes(editor)`, extracts `colwidth` array, and returns `colwidth[0]` or null
+- [x] 4.5 Implement `onChange` function that parses input value, validates it, clamps to min/max range, and calls `setCellAttribute("colwidth", [value])` or `setCellAttribute("colwidth", null)` for empty input
+- [x] 4.6 Handle edge case where input is empty string by calling `setCellAttribute("colwidth", null)` to reset to auto width
+- [x] 4.7 Handle colspan cells by creating colwidth array with single value `[clampedValue]` regardless of colspan
+
+## 5. Testing & Verification
+
+- [x] 5.1 Test setting column width to valid value (e.g., 150px) and verify column resizes
+- [x] 5.2 Test clearing column width via clear button and verify column auto-sizes
+- [x] 5.3 Test entering value below minimum (e.g., 10) and verify it clamps to 25
+- [x] 5.4 Test entering value above maximum (e.g., 2000) and verify it clamps to 1000
+- [x] 5.5 Test entering invalid input (e.g., "abc") and verify it's ignored
+- [x] 5.6 Test with colspan cell (colspan=3) and verify only first column width is set
+- [x] 5.7 Test that width persists after save/reload of document
+- [x] 5.8 Test that clear button visibility toggles correctly (hidden when empty, shown when value exists)
+- [x] 5.9 Test that placeholder "Auto" displays when input is empty
+- [x] 5.10 Test keyboard navigation and accessibility of number input control
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/README.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/README.md
new file mode 100644
index 0000000000..4bcf59afdc
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/README.md
@@ -0,0 +1,84 @@
+# Normalize Empty Content
+
+**Status:** Proposed
+**Type:** Bug Fix
+**Impact:** Breaking (Validation behavior)
+**Effort:** ~3.5 hours
+
+## Quick Summary
+
+Fix the rich text editor to save empty content as `""` instead of ``, ensuring correct validation and consistent data representation.
+
+## Problem
+
+When users delete all content, the editor currently saves `` to the database. This breaks:
+
+- Required field validation (incorrectly passes)
+- Empty checks (`if (value)` returns true)
+- Data consistency (semantically empty ≠ empty string)
+
+## Solution
+
+Use Tiptap's `editor.isEmpty` property to return `""` when content is empty, and normalize comparison logic to treat `""` and `` as equivalent.
+
+## Files
+
+- [`proposal.md`](./proposal.md) - Problem statement, scope, and impact
+- [`design.md`](./design.md) - Technical design, decisions, and architecture
+- [`tasks.md`](./tasks.md) - Implementation checklist and effort estimate
+
+## Key Decisions
+
+1. **Trust Tiptap's isEmpty** - Use built-in check, no custom logic
+2. **Normalize at persistence** - Convert to `""` when saving, not loading
+3. **Lazy migration** - Existing `` records normalize on next edit
+4. **No version bump** - This Tiptap version is unreleased
+
+## Changes Required
+
+### Editor.tsx (line 237-240)
+
+```typescript
+// Before:
+const html = editor.getHTML();
+
+// After:
+const html = editor.isEmpty ? "" : editor.getHTML();
+```
+
+### EditorWrapper.tsx (line 48-60)
+
+```typescript
+// Add normalization in comparison:
+const normalizeEmpty = (val?: string) => (!val || val === "" ? "" : val);
+
+const current = normalizeEmpty(stringAttribute.value);
+const incoming = normalizeEmpty(html);
+if (current !== incoming) {
+ stringAttribute.setValue(incoming);
+}
+```
+
+## Testing
+
+- ✓ Unit tests for empty content behavior
+- ✓ E2E test for persistence
+- ✓ Manual validation testing
+
+## Breaking Change
+
+**Required field validation will now correctly reject empty content.**
+
+Previously: `` passed validation ❌
+After fix: `""` fails validation ✓
+
+This is desired behavior (bug fix), but may affect existing forms.
+
+## Next Steps
+
+1. Review proposal and design
+2. Approve or request changes
+3. Implement following tasks.md
+4. Test thoroughly
+5. Update CHANGELOG
+6. Merge to main branch
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/design.md
new file mode 100644
index 0000000000..fd696c3c70
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/design.md
@@ -0,0 +1,374 @@
+## Design Overview
+
+The fix normalizes empty editor content at the persistence boundary by checking Tiptap's `isEmpty` property and returning an empty string instead of ``. A secondary normalization in the comparison layer prevents unnecessary updates when values are semantically equivalent.
+
+## Architecture
+
+```
+User Deletes Content
+ ↓
+Tiptap Editor (internal state: )
+ ↓
+onUpdate Event Fires
+ ↓
+┌─────────────────────────────────────────┐
+│ Editor.tsx (PRIMARY FIX) │
+│ │
+│ onUpdate: ({ editor }) => { │
+│ const html = editor.isEmpty │
+│ ? "" │ ← Use isEmpty check
+│ : editor.getHTML(); │
+│ onUpdate?.(html); │
+│ } │
+└─────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────┐
+│ EditorWrapper.tsx (OPTIMIZATION) │
+│ │
+│ const current = stringAttribute.value || ""; │
+│ const incoming = html || ""; │ ← Normalize comparison
+│ if (current !== incoming) { │
+│ stringAttribute.setValue(incoming); │
+│ } │
+└─────────────────────────────────────────┘
+ ↓
+Mendix EditableValue.setValue("")
+ ↓
+Database: RichTextField = "" ✓
+```
+
+## Key Design Decisions
+
+### 1. Use Tiptap's `isEmpty` Property
+
+**Decision:** Trust Tiptap's built-in `editor.isEmpty` check rather than implementing custom logic.
+
+**Rationale:**
+
+- Tiptap's `isEmpty` is battle-tested and handles edge cases correctly
+- Considers semantic emptiness (ignores empty marks, formatting-only nodes)
+- No need to reinvent the wheel with regex or string parsing
+- Future-proof as Tiptap evolves
+
+**Edge Cases Handled by isEmpty:**
+
+- `` → true
+- `
` → true (empty marks)
+- `
` → Implementation depends on Tiptap's definition
+- `
` → false (has content - soft break)
+- `
` → false (has structure)
+
+### 2. Normalize at Persistence Boundary
+
+**Decision:** Convert `` to `""` in the `onUpdate` handler, not on load.
+
+**Rationale:**
+
+- Centralizes normalization at the point where data leaves the editor
+- Tiptap can maintain whatever internal state it needs
+- Rendering is unaffected (Tiptap will render `` internally regardless)
+- Single source of truth for what gets persisted
+
+**Rejected Alternative:** Normalize on load (`defaultValue`)
+
+- Would require changing initialization logic
+- Adds complexity without user-visible benefit
+- Lazy migration is sufficient
+
+### 3. Comparison Normalization
+
+**Decision:** Treat `undefined`, `""`, and `` as equivalent in comparison logic.
+
+**Why:**
+
+```javascript
+// Without normalization:
+stringAttribute.value = "";
+html = "";
+"" !== "" // → triggers setValue() unnecessarily
+
+// With normalization:
+current = "" || "" = "";
+incoming = "" || "" = ""; // Wait, this doesn't work!
+
+// Correct normalization:
+const normalize = (val?: string) => val || "";
+current = normalize(stringAttribute.value); // "" or "" → ""
+incoming = normalize(html); // "" or "" → ""
+```
+
+**Actually, we need:**
+
+```javascript
+const normalize = (val?: string) => {
+ if (!val || val === "") return "";
+ return val;
+};
+```
+
+**Rationale:**
+
+- Prevents duplicate setValue() calls during transition period
+- Reduces unnecessary database writes
+- Handles both `""` and `` gracefully
+
+### 4. Lazy Migration
+
+**Decision:** Don't proactively migrate existing `` records.
+
+**Rationale:**
+
+- Records with `` load and render fine
+- They normalize to `""` on next edit
+- No risk of data corruption
+- No need for migration script
+- Gradual, safe transition
+
+## Implementation Details
+
+### File: `src/components/Editor.tsx`
+
+**Current Code (Line 237-240):**
+
+```typescript
+onUpdate: ({ editor }) => {
+ const html = editor.getHTML();
+ onUpdate?.(html);
+};
+```
+
+**New Code:**
+
+```typescript
+onUpdate: ({ editor }) => {
+ const html = editor.isEmpty ? "" : editor.getHTML();
+ onUpdate?.(html);
+};
+```
+
+**Impact:**
+
+- Every content change triggers this check
+- Performance: Negligible (boolean property access)
+- Testing: Unit test can mock editor.isEmpty
+
+### File: `src/components/EditorWrapper.tsx`
+
+**Current Code (Line 48-60):**
+
+```typescript
+const [setAttributeValueDebounce] = useDebounceWithStatus(
+ (html?: string) => {
+ if (stringAttribute.value !== html) {
+ stringAttribute.setValue(html);
+ if (onChangeType === "onDataChange") {
+ executeAction(onChange);
+ }
+ }
+ },
+ 200,
+ false
+);
+```
+
+**New Code:**
+
+```typescript
+const normalizeEmpty = (val?: string): string => {
+ if (!val || val === "") return "";
+ return val;
+};
+
+const [setAttributeValueDebounce] = useDebounceWithStatus(
+ (html?: string) => {
+ const current = normalizeEmpty(stringAttribute.value);
+ const incoming = normalizeEmpty(html);
+
+ if (current !== incoming) {
+ stringAttribute.setValue(incoming);
+ if (onChangeType === "onDataChange") {
+ executeAction(onChange);
+ }
+ }
+ },
+ 200,
+ false
+);
+```
+
+**Impact:**
+
+- Prevents spurious updates when switching representations
+- Helper function can be tested independently
+- Encapsulates normalization logic
+
+## Testing Strategy
+
+### Unit Tests
+
+**File:** `src/__tests__/RichText.spec.tsx`
+
+New test cases:
+
+```typescript
+describe("Empty content handling", () => {
+ it("returns empty string when editor is empty", () => {
+ const onUpdate = jest.fn();
+ const editor = createMockEditor({ isEmpty: true, getHTML: () => "" });
+
+ // Trigger onUpdate
+ editor.onUpdate({ editor });
+
+ expect(onUpdate).toHaveBeenCalledWith("");
+ });
+
+ it("returns HTML when editor has content", () => {
+ const onUpdate = jest.fn();
+ const editor = createMockEditor({
+ isEmpty: false,
+ getHTML: () => "
");
+ });
+
+ it("normalizes in comparison", () => {
+ const setValue = jest.fn();
+ const stringAttribute = { value: "", setValue };
+
+ // Trigger update with empty string
+ handleUpdate("");
+
+ // Should NOT call setValue (semantically equivalent)
+ expect(setValue).not.toHaveBeenCalled();
+ });
+});
+```
+
+### E2E Tests
+
+**File:** `e2e/RichText.spec.js`
+
+New test:
+
+```javascript
+test("empty content persists as empty string", async ({ page }) => {
+ await page.goto("/p/rich-text-test");
+ await waitForMendixApp(page);
+
+ // Type content
+ const editor = page.locator(".tiptap-editor");
+ await editor.click();
+ await page.keyboard.type("Hello World");
+
+ // Delete all content
+ await page.keyboard.press("Control+A");
+ await page.keyboard.press("Backspace");
+
+ // Blur to trigger save
+ await page.keyboard.press("Tab");
+
+ // Verify the attribute value is empty string
+ const isEmpty = await page.evaluate(() => {
+ // Access Mendix test helper
+ const context = window.mx.data.get(/* richTextEntity */);
+ const value = context.get("RichTextField");
+ return value === "" || value === null;
+ });
+
+ expect(isEmpty).toBe(true);
+});
+```
+
+## Risks and Mitigations
+
+### Risk 1: Validation Breaking Change
+
+**Risk:** Forms with required RichText that accepted `` will now fail validation.
+
+**Mitigation:**
+
+- This is the desired behavior (bug fix)
+- Document as breaking change in CHANGELOG
+- Note: Since this is unreleased Tiptap version, no version bump needed
+
+**Verdict:** Acceptable - improves correctness
+
+### Risk 2: Tiptap's isEmpty Edge Cases
+
+**Risk:** Tiptap's `isEmpty` might have edge cases we don't anticipate.
+
+**Mitigation:**
+
+- Trust Tiptap's well-tested implementation
+- Add unit tests for known edge cases
+- Monitor issue reports after release
+
+**Verdict:** Low risk - Tiptap is mature
+
+### Risk 3: Performance
+
+**Risk:** Extra checks on every keystroke.
+
+**Analysis:**
+
+- `editor.isEmpty` is a boolean property (O(1))
+- Normalization is string comparison (O(n), but n is small)
+- Already debounced (200ms)
+
+**Verdict:** Negligible impact
+
+## Alternative Approaches Considered
+
+### Alternative 1: Normalize on Load
+
+```typescript
+const editor = useEditor({
+ content: normalizeEmpty(defaultValue),
+ ...
+});
+```
+
+**Rejected because:**
+
+- Doesn't prevent `` from being saved in the first place
+- Adds complexity to initialization
+- No user-visible benefit
+
+### Alternative 2: Custom isEmpty Logic
+
+```typescript
+const isEmpty = (html: string) => {
+ return !html || html === "" || html.trim() === "";
+};
+```
+
+**Rejected because:**
+
+- Fragile (what about `
`, `
`, etc?)
+- Reinvents Tiptap's tested logic
+- Harder to maintain
+
+### Alternative 3: Database Migration
+
+Run a script to convert all `` → `""` in database.
+
+**Rejected because:**
+
+- Unnecessary risk
+- Lazy migration is safer
+- No user-visible benefit
+
+## Conclusion
+
+This design provides a minimal, safe fix for the empty content bug by leveraging Tiptap's built-in `isEmpty` property and normalizing at the persistence boundary. The secondary comparison normalization prevents unnecessary updates during the transition period.
+
+**Key Benefits:**
+
+- ✓ Simple implementation (two small changes)
+- ✓ Low risk (uses proven Tiptap API)
+- ✓ Testable (unit + E2E coverage)
+- ✓ Backward compatible (lazy migration)
+- ✓ Fixes validation correctness
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/proposal.md
new file mode 100644
index 0000000000..5b8eb44b30
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/proposal.md
@@ -0,0 +1,58 @@
+## Why
+
+When users delete all content from the rich text editor, it currently saves as `` instead of an empty string. This creates several problems:
+
+1. **Validation issues**: Required fields incorrectly pass validation with `` as the value
+2. **Comparison inconsistencies**: Checking `if (value === "")` or `if (!value)` fails because `` is truthy
+3. **Data semantics**: Semantically empty content should be represented as an empty string, not HTML markup
+4. **Developer confusion**: Backend logic must special-case `` when checking for empty content
+
+This is a bug in the current implementation where Tiptap's `getHTML()` returns `` for an empty editor, but we should normalize this to an empty string before persisting to the database.
+
+## What Changes
+
+- Modify `onUpdate` handler in `Editor.tsx` to return empty string (`""`) when editor is empty using Tiptap's built-in `isEmpty` property
+- Normalize value comparison in `EditorWrapper.tsx` to treat `undefined`, `""`, and `` as equivalent, preventing unnecessary database updates
+- Add unit tests for empty content handling
+- Add E2E test to verify empty content persists as empty string
+
+## Capabilities
+
+### New Capabilities
+
+
+
+### Modified Capabilities
+
+- `rich-text-persistence`: Empty editor content now correctly saves as empty string (`""`) instead of ``, ensuring proper validation and consistent data representation
+
+## Impact
+
+**Affected Files:**
+
+- `src/components/Editor.tsx` - Modify `onUpdate` handler to check `editor.isEmpty` and return `""` when true
+- `src/components/EditorWrapper.tsx` - Normalize value comparison to prevent spurious updates
+- `src/__tests__/RichText.spec.tsx` - Add tests for empty content handling
+- `e2e/RichText.spec.js` - Add E2E test for empty content persistence
+
+**User Impact:**
+
+- **Validation improvement** (Breaking): Required RichText fields will now correctly reject empty content. Previously, `` incorrectly passed validation.
+- **Data consistency**: Empty content is consistently represented as `""` across all scenarios
+- **Backward compatibility**: Existing records with `` will load normally and get normalized to `""` on next edit (lazy migration)
+
+**Technical Impact:**
+
+- Leverages Tiptap's built-in `isEmpty` property (battle-tested, no custom logic needed)
+- Minimal performance impact (one boolean property check per update)
+- No changes to Tiptap configuration or extensions
+- No version bump needed (Tiptap version is unreleased)
+
+## Migration
+
+**Lazy Migration Strategy:**
+
+- Existing records with `` in database remain unchanged
+- Values normalize to `""` when user next edits the content
+- No database migration script required
+- Safe, gradual transition
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/tasks.md
new file mode 100644
index 0000000000..b3ab006932
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/tasks.md
@@ -0,0 +1,107 @@
+## Implementation Tasks
+
+### Core Implementation
+
+- [ ] **Modify Editor.tsx onUpdate handler**
+ - File: `src/components/Editor.tsx`
+ - Line: 237-240
+ - Change: Replace `const html = editor.getHTML();` with `const html = editor.isEmpty ? "" : editor.getHTML();`
+ - Validation: Verify editor.isEmpty property is accessible via TypeScript types
+
+- [ ] **Add normalizeEmpty helper in EditorWrapper.tsx**
+ - File: `src/components/EditorWrapper.tsx`
+ - Add helper function above component:
+ ```typescript
+ function normalizeEmpty(value?: string): string {
+ if (!value || value === "") return "";
+ return value;
+ }
+ ```
+ - Update comparison logic in setAttributeValueDebounce (line 48-60)
+ - Use `normalizeEmpty()` for both current and incoming values
+
+### Testing
+
+- [ ] **Add unit tests for empty content handling**
+ - File: `src/__tests__/RichText.spec.tsx`
+ - Test cases:
+ - Empty editor returns empty string
+ - Editor with content returns HTML
+ - Normalization treats "" and "" as equivalent
+ - Edge case: whitespace-only content behavior
+
+- [ ] **Add E2E test for empty content persistence**
+ - File: `e2e/RichText.spec.js`
+ - Test scenario:
+ - Type content in editor
+ - Delete all content
+ - Blur to trigger save
+ - Verify attribute value is empty string (not "")
+
+- [ ] **Manual testing checklist**
+ - [ ] Create new record with required RichText field
+ - [ ] Try to save with empty content → validation should fail
+ - [ ] Add content and save → should succeed
+ - [ ] Delete all content and save → validation should fail
+ - [ ] Verify database stores "" not ""
+ - [ ] Load existing record with "" → should render correctly
+ - [ ] Edit and save → should normalize to ""
+ - [ ] Test undo/redo with empty content
+ - [ ] Test code view → code view toggle with empty content
+
+### Documentation
+
+- [ ] **Update CHANGELOG.md**
+ - Add entry under "Fixed" section:
+ - "Empty editor content now correctly saves as empty string instead of ``, fixing validation and comparison issues"
+ - Note breaking change: Required field validation now correctly rejects empty content
+
+- [ ] **Update README.md (if needed)**
+ - Check if empty content behavior is documented
+ - Add note about empty value representation if relevant
+
+### Code Review Checklist
+
+- [ ] Verify isEmpty property is correctly used
+- [ ] Confirm no TypeScript errors
+- [ ] Check that normalization logic handles all cases (undefined, "", "")
+- [ ] Verify debounce behavior is maintained
+- [ ] Ensure onChange events still fire correctly
+- [ ] Confirm no regression in existing functionality
+- [ ] Review test coverage for edge cases
+
+### Pre-Merge Verification
+
+- [ ] All unit tests pass: `pnpm test`
+- [ ] All E2E tests pass: `pnpm e2e`
+- [ ] Linting passes: `pnpm lint`
+- [ ] Format check passes: `pnpm format`
+- [ ] Build succeeds: `pnpm build`
+- [ ] Manual testing completed
+- [ ] CHANGELOG updated
+- [ ] No console errors in browser during testing
+
+## Estimated Effort
+
+- Core implementation: **30 minutes**
+- Unit tests: **1 hour**
+- E2E tests: **1 hour**
+- Manual testing: **30 minutes**
+- Documentation: **15 minutes**
+- Code review and fixes: **30 minutes**
+
+**Total: ~3.5 hours**
+
+## Dependencies
+
+None - this is a self-contained fix within the RichText widget.
+
+## Success Criteria
+
+✓ Empty editor persists as `""` instead of ``
+✓ Required field validation correctly rejects empty content
+✓ Existing `` records load and normalize on next edit
+✓ No regression in content editing functionality
+✓ All tests pass (unit + E2E)
+✓ No TypeScript errors
+✓ Code review approved
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/.openspec.yaml
new file mode 100644
index 0000000000..8803b473ec
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-12
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/design.md
new file mode 100644
index 0000000000..1076cbebc3
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/design.md
@@ -0,0 +1,176 @@
+## Context
+
+Rich text widget renders ordered (``) and unordered (`
`) lists using Tiptap's StarterKit extensions. Current SCSS (`RichText.scss` lines 158-162) only sets basic padding and margin:
+
+```scss
+ul,
+ol {
+ padding-left: 1.5em;
+ margin: 0.5em 0;
+}
+```
+
+All nesting levels use browser defaults:
+
+- Ordered lists: `list-style-type: decimal` (1, 2, 3) at all levels
+- Unordered lists: `list-style-type: disc` (●) at all levels
+
+Standard word processors (Word, Google Docs, Notion) auto-cycle list styles by depth for visual hierarchy. Users coming from these tools expect similar behavior.
+
+Recent change `fix-list-tab-indent` added Tab key support for nesting lists, making this enhancement more valuable.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Auto-cycle ordered list styles: decimal → lower-alpha → lower-roman (repeat)
+- Auto-cycle unordered list styles: disc → circle → square (repeat)
+- CSS-only implementation (zero JavaScript)
+- Preserve inline style overrides (user-set `style="list-style-type: ..."`)
+- Support 6+ nesting levels
+
+**Non-Goals:**
+
+- User controls for changing list style (Phase 2 scope)
+- Toolbar UI changes
+- Extending Tiptap list extensions
+- Data model changes (no new attributes)
+- RTL-specific list markers
+
+## Decisions
+
+### Decision 1: Pure CSS nested selectors
+
+**Choice**: Use CSS descendant combinators (`ol ol { ... }`) to target nested lists.
+
+**Rationale**:
+
+- Zero JavaScript overhead
+- Standard CSS, widely supported
+- Works immediately with existing HTML structure
+- No Tiptap extension modifications needed
+- Inline styles naturally override via CSS specificity
+
+**Alternatives considered**:
+
+- Data attributes (`data-list-level="2"`) → Requires Tiptap extension to inject attributes, adds complexity
+- JavaScript depth calculation → Performance overhead, unnecessary for static styling
+- CSS `:nth-child()` or counters → Incorrect semantic (targets item position, not nesting depth)
+
+### Decision 2: Cycle sequence matches Word
+
+**Choice**:
+
+- Ordered: decimal (1, 2, 3) → lower-alpha (a, b, c) → lower-roman (i, ii, iii)
+- Unordered: disc (●) → circle (○) → square (■)
+
+**Rationale**:
+
+- Industry standard (Microsoft Word, Google Docs use same sequence)
+- User familiarity reduces cognitive load
+- Lower-case styles preferred for nested content (less visual weight than uppercase)
+
+**Alternatives considered**:
+
+- Upper-case roman/alpha at level 1 → Too visually heavy for running text
+- Custom sequence → No benefit, breaks user expectations
+
+### Decision 3: Define 6 nesting levels explicitly
+
+**Choice**: Write CSS rules for 6 levels of nesting (2 full cycles).
+
+**Rationale**:
+
+- Covers 95%+ of real-world use cases
+- Cost is ~30 lines of CSS (cheap)
+- Deeper nesting (7+) falls back to browser defaults (acceptable edge case)
+
+**Alternatives considered**:
+
+- 3 levels only → Breaks at 4th level (incomplete cycle)
+- 9 levels → Unnecessary (deeply nested lists are rare, unreadable)
+- Infinite via preprocessor loops → Adds build complexity for marginal gain
+
+### Decision 4: Preserve inline style precedence
+
+**Choice**: CSS specificity naturally prioritizes inline styles. No `!important` needed for base rules.
+
+**Rationale**:
+
+- Users who manually set `style="list-style-type: upper-roman;"` keep their choice
+- Supports future Phase 2 (user overrides via toolbar)
+- Standard CSS behavior, no surprises
+
+**CSS specificity**:
+
+- Inline style: 1000 points (highest)
+- `ol ol ol`: 3 points
+- Inline wins automatically
+
+## Risks / Trade-offs
+
+**[Risk]** Users with existing nested lists see visual change after upgrade → **Mitigation**: Document in CHANGELOG as enhancement. Not breaking (only affects appearance, not functionality).
+
+**[Risk]** Deeply nested lists (7+ levels) don't cycle correctly → **Mitigation**: Define extra levels if needed (cheap). Fallback to browser default (decimal/disc) is acceptable.
+
+**[Risk]** User expectations from non-Word apps → **Mitigation**: Word/Google Docs are dominant, setting de facto standard. Other tools vary, but decimal→alpha→roman is most common.
+
+**[Trade-off]** No per-list customization in Phase 1 → Accepted. Phase 2 adds toolbar controls for manual override.
+
+**[Trade-off]** CSS file size increases ~40 lines → Negligible (gzipped impact <0.5KB).
+
+## Implementation
+
+**File modified**: `packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss`
+
+**Change location**: Lines 158-162 (existing `ul, ol` rules)
+
+**Strategy**: Replace simple `ul, ol` selector with nested rules:
+
+```scss
+// Ordered list auto-cycling (6 levels = 2 full cycles)
+ol {
+ list-style-type: decimal;
+ ol {
+ list-style-type: lower-alpha;
+ ol {
+ list-style-type: lower-roman;
+ ol {
+ list-style-type: decimal; // Cycle repeats
+ ol {
+ list-style-type: lower-alpha;
+ ol {
+ list-style-type: lower-roman;
+ }
+ }
+ }
+ }
+ }
+}
+
+// Unordered list auto-cycling (6 levels = 2 full cycles)
+ul {
+ list-style-type: disc;
+ ul {
+ list-style-type: circle;
+ ul {
+ list-style-type: square;
+ ul {
+ list-style-type: disc; // Cycle repeats
+ ul {
+ list-style-type: circle;
+ ul {
+ list-style-type: square;
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Keep existing padding/margin rules intact.
+
+## Open Questions
+
+None. Design is complete and straightforward.
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/proposal.md
new file mode 100644
index 0000000000..1d9e132d06
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/proposal.md
@@ -0,0 +1,48 @@
+## Why
+
+Nested lists in rich text widget currently use same numbering style at all levels (1, 2, 3 everywhere). Standard word processors (Word, Google Docs) automatically cycle through different styles for visual hierarchy (decimal → lower-alpha → lower-roman). This improves readability of complex nested lists.
+
+## What Changes
+
+- Ordered lists (``) will auto-cycle numbering styles based on nesting depth:
+ - Level 1: decimal (1, 2, 3)
+ - Level 2: lower-alpha (a, b, c)
+ - Level 3: lower-roman (i, ii, iii)
+ - Level 4+: cycle repeats (decimal → lower-alpha → lower-roman)
+- Unordered lists (`
`) will auto-cycle bullet styles based on nesting depth:
+ - Level 1: disc (●)
+ - Level 2: circle (○)
+ - Level 3: square (■)
+ - Level 4+: cycle repeats (disc → circle → square)
+- CSS-only implementation (no JavaScript changes)
+- No toolbar changes or user override controls (Phase 1 scope)
+
+## Capabilities
+
+### New Capabilities
+
+- `list-style-auto-cycle`: Nested ordered and unordered lists automatically cycle through numbering/bullet styles based on depth
+
+### Modified Capabilities
+
+
+
+## Impact
+
+**Files affected**:
+
+- `packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss` — Add nested selector rules for `ol` and `ul`
+
+**User-facing changes**:
+
+- Nested lists will display different numbering/bullet styles automatically
+- Matches standard word processor behavior
+- Existing lists with manual `style="list-style-type: ..."` inline styles unchanged (inline styles override CSS)
+- No breaking changes — pure visual enhancement
+
+**Testing scope**:
+
+- Visual verification of 6 nesting levels for ordered lists
+- Visual verification of 4 nesting levels for unordered lists
+- Mixed list types (ordered inside unordered, vice versa)
+- Task lists (`ul[data-type="taskList"]`) should remain unaffected
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/specs/list-style-auto-cycle/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/specs/list-style-auto-cycle/spec.md
new file mode 100644
index 0000000000..2cf6cdef03
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/specs/list-style-auto-cycle/spec.md
@@ -0,0 +1,96 @@
+## ADDED Requirements
+
+### Requirement: Ordered lists cycle through numbering styles by nesting depth
+
+Ordered lists (``) SHALL automatically cycle through numbering styles based on nesting level using CSS descendant selectors.
+
+#### Scenario: Level 1 ordered list uses decimal
+
+- **WHEN** an ordered list is at the top level (not nested)
+- **THEN** list items display with decimal numbering (1, 2, 3)
+
+#### Scenario: Level 2 ordered list uses lower-alpha
+
+- **WHEN** an ordered list is nested one level deep inside another ordered list
+- **THEN** list items display with lower-alpha numbering (a, b, c)
+
+#### Scenario: Level 3 ordered list uses lower-roman
+
+- **WHEN** an ordered list is nested two levels deep
+- **THEN** list items display with lower-roman numbering (i, ii, iii)
+
+#### Scenario: Level 4 ordered list cycles back to decimal
+
+- **WHEN** an ordered list is nested three levels deep
+- **THEN** list items display with decimal numbering (1, 2, 3)
+
+#### Scenario: Level 5 ordered list cycles to lower-alpha
+
+- **WHEN** an ordered list is nested four levels deep
+- **THEN** list items display with lower-alpha numbering (a, b, c)
+
+#### Scenario: Level 6 ordered list cycles to lower-roman
+
+- **WHEN** an ordered list is nested five levels deep
+- **THEN** list items display with lower-roman numbering (i, ii, iii)
+
+### Requirement: Unordered lists cycle through bullet styles by nesting depth
+
+Unordered lists (`
`) SHALL automatically cycle through bullet styles based on nesting level using CSS descendant selectors.
+
+#### Scenario: Level 1 unordered list uses disc
+
+- **WHEN** an unordered list is at the top level (not nested)
+- **THEN** list items display with disc bullets (●)
+
+#### Scenario: Level 2 unordered list uses circle
+
+- **WHEN** an unordered list is nested one level deep inside another unordered list
+- **THEN** list items display with circle bullets (○)
+
+#### Scenario: Level 3 unordered list uses square
+
+- **WHEN** an unordered list is nested two levels deep
+- **THEN** list items display with square bullets (■)
+
+#### Scenario: Level 4 unordered list cycles back to disc
+
+- **WHEN** an unordered list is nested three levels deep
+- **THEN** list items display with disc bullets (●)
+
+### Requirement: Inline styles override CSS defaults
+
+When a list element has an inline `style="list-style-type: ..."` attribute, that style SHALL take precedence over CSS auto-cycle rules.
+
+#### Scenario: Manually styled list preserves inline style
+
+- **WHEN** an ordered list has `style="list-style-type: upper-roman;"`
+- **THEN** list displays with upper-roman (I, II, III) regardless of nesting depth
+
+#### Scenario: Nested list without inline style uses auto-cycle
+
+- **WHEN** parent list has inline style but nested child list has no inline style
+- **THEN** child list uses CSS auto-cycle rules based on its depth
+
+### Requirement: Task lists remain unstyled
+
+Task lists with `data-type="taskList"` SHALL remain unstyled with `list-style: none`, unaffected by auto-cycle rules.
+
+#### Scenario: Task list ignores auto-cycle
+
+- **WHEN** a list has `data-type="taskList"` attribute
+- **THEN** list displays with no bullet/number markers (checkboxes only)
+
+### Requirement: Mixed list types cycle independently
+
+When ordered and unordered lists are mixed (e.g., `
` inside ``), each list type SHALL follow its own cycle sequence.
+
+#### Scenario: Ordered list inside unordered list starts at decimal
+
+- **WHEN** an `` is nested inside a `
`
+- **THEN** ordered list uses decimal (1, 2, 3) regardless of parent's depth
+
+#### Scenario: Unordered list inside ordered list starts at disc
+
+- **WHEN** a `
` is nested inside an ``
+- **THEN** unordered list uses disc (●) regardless of parent's depth
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/tasks.md
new file mode 100644
index 0000000000..89ed5d5756
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/tasks.md
@@ -0,0 +1,17 @@
+## 1. Update SCSS for list style cycling
+
+- [x] 1.1 Add nested selector rules for ordered lists (ol) with 6 levels: decimal → lower-alpha → lower-roman → decimal → lower-alpha → lower-roman
+- [x] 1.2 Add nested selector rules for unordered lists (ul) with 6 levels: disc → circle → square → disc → circle → square
+- [x] 1.3 Preserve existing padding-left and margin rules for ul/ol
+
+## 2. Manual testing
+
+- [x] 2.1 Test ordered list nesting: verify 6 levels show correct cycle (1 → a → i → 1 → a → i)
+- [x] 2.2 Test unordered list nesting: verify 4 levels show correct cycle (● → ○ → ■ → ●)
+- [x] 2.3 Test mixed list types: ordered inside unordered starts at decimal, unordered inside ordered starts at disc
+- [x] 2.4 Test inline style override: list with style="list-style-type: upper-roman;" displays upper-roman at any depth
+- [x] 2.5 Test task lists remain unaffected: ul[data-type="taskList"] shows no bullets/numbers
+
+## 3. Documentation
+
+- [x] 3.1 Add entry to CHANGELOG.md describing auto-cycle enhancement
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/.openspec.yaml
new file mode 100644
index 0000000000..eb5fa80e61
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-10
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/design.md
new file mode 100644
index 0000000000..420b25f323
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/design.md
@@ -0,0 +1,109 @@
+## Context
+
+Rich text widget uses Tiptap editor with custom `Indent` extension (`src/extensions/Indent.ts`). Current implementation:
+
+- Targets `["paragraph", "heading", "blockquote"]` node types
+- Tab handler intercepts Tab key when focus is inside editor
+- Calls `increaseIndent()`/`decreaseIndent()` commands
+- Applies margin-based indentation via `margin-left` style or `indent-*` class
+
+Tiptap's StarterKit includes list extensions with built-in commands:
+
+- `sinkListItem(typeOrName)` — increases list nesting (wraps in sublist)
+- `liftListItem(typeOrName)` — decreases list nesting
+- `listItem` node type for both ordered and unordered lists
+
+Problem: Tab handler doesn't detect list item context, applies margin-based indent to paragraph inside list item instead of structural nesting.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Tab/Shift+Tab nest/unnest list items structurally
+- Preserve existing Tab behavior for paragraphs, headings, blockquotes
+- Maintain focus handling (only intercept Tab inside editor content)
+- Work for both ordered and unordered lists
+
+**Non-Goals:**
+
+- Modifying toolbar indent buttons behavior (still call `increaseIndent`/`decreaseIndent`)
+- Adding list item support to `updateIndentLevel()` function
+- Changing maximum nesting depth limits
+- Supporting mixed content selection (list + paragraph)
+
+## Decisions
+
+### Decision 1: Modify Tab handler, not indent commands
+
+**Choice**: Add list detection to `handleKeyDown` in `addProseMirrorPlugins()`, call Tiptap's built-in list commands directly.
+
+**Rationale**:
+
+- Simplest change — Tab handler already exists and checks focus
+- Avoids modifying `increaseIndent()`/`decreaseIndent()` commands
+- Toolbar buttons unchanged (acceptable limitation for v1)
+- Leverages Tiptap's proven list nesting logic
+
+**Alternatives considered**:
+
+- Add `listItem` to `types` array and implement list logic in `updateIndentLevel()` → More complex, mixes structural changes with style changes, would affect toolbar buttons (out of scope)
+- Create separate list indent commands → Unnecessary duplication of Tiptap functionality
+
+### Decision 2: Use editor.isActive('listItem') for detection
+
+**Choice**: Check `editor.isActive('listItem')` to determine if cursor is in list.
+
+**Rationale**:
+
+- Standard Tiptap API for node detection
+- Works regardless of cursor position within list item
+- Single check covers both ordered and unordered lists
+
+**Alternatives considered**:
+
+- Check node type directly via ProseMirror state → More verbose, no benefit
+- Check parent node chain → Unnecessary complexity
+
+### Decision 3: Single typeOrName parameter 'listItem'
+
+**Choice**: Pass `'listItem'` to `sinkListItem()` and `liftListItem()` commands.
+
+**Rationale**:
+
+- `listItem` is the node type for both ordered and unordered lists in Tiptap
+- Commands handle list type conversion automatically
+- Simpler than checking `bulletList` vs `orderedList`
+
+**Alternatives considered**:
+
+- Detect list type and pass `bulletList` or `orderedList` → Unnecessary, commands work on `listItem` directly
+
+### Decision 4: Short-circuit return when in list
+
+**Choice**: When `isActive('listItem')` returns true, call list command and return result immediately (don't fall through to indent commands).
+
+**Rationale**:
+
+- List and paragraph indent are mutually exclusive behaviors
+- Prevents calling both list command and indent command
+- Clear control flow
+
+## Risks / Trade-offs
+
+**[Risk]** Toolbar indent buttons won't work on lists → **Mitigation**: Acceptable for v1, document as known limitation. Can be addressed in future by extending indent commands.
+
+**[Risk]** User muscle memory for existing Tab behavior in lists → **Mitigation**: New behavior matches industry standard (Google Docs, Word, Notion). Empty paragraph below list can still be indented.
+
+**[Risk]** Selection spanning multiple list items may behave unexpectedly → **Mitigation**: Tiptap's `sinkListItem`/`liftListItem` handle ranges. If issues arise, can add selection check.
+
+**[Trade-off]** Two separate indent systems (structure-based for lists, margin-based for paragraphs) → Accepted trade-off for simplicity. Unifying would require significant refactor.
+
+## Implementation Plan
+
+1. Modify `handleKeyDown` in `Indent.ts` `addProseMirrorPlugins()` section
+2. After Tab key check, before focus check, add list detection
+3. If `editor.isActive('listItem')`, call `sinkListItem('listItem')` or `liftListItem('listItem')`
+4. Return result immediately (don't fall through)
+5. Existing logic remains for non-list content
+
+Code location: `packages/pluggableWidgets/rich-text-web/src/extensions/Indent.ts`, lines 125-153 (handleKeyDown function).
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/proposal.md
new file mode 100644
index 0000000000..9e02a67b28
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/proposal.md
@@ -0,0 +1,38 @@
+## Why
+
+Rich text widget users cannot nest list items using Tab key. When cursor is in ordered/unordered list and Tab is pressed, content inside list item gets indented (margin added to paragraph) instead of nesting the list item structurally. Expected behavior: Tab should increase list nesting level (convert flat list to nested sublists).
+
+## What Changes
+
+- Tab key in list items will nest the list item structurally (using Tiptap's `sinkListItem` command)
+- Shift+Tab in list items will outdent/lift the list item (using Tiptap's `liftListItem` command)
+- Tab key in non-list content (paragraphs, headings) continues to work as before (margin-based indentation)
+- Toolbar indent buttons unchanged (still call `increaseIndent`/`decreaseIndent` commands)
+
+## Capabilities
+
+### New Capabilities
+
+- `list-tab-indent`: Tab/Shift+Tab keys nest and unnest list items in ordered and unordered lists
+
+### Modified Capabilities
+
+
+
+## Impact
+
+**Files affected**:
+
+- `packages/pluggableWidgets/rich-text-web/src/extensions/Indent.ts` — Tab handler logic modified
+
+**User-facing changes**:
+
+- Tab key behavior in lists now matches standard rich text editor expectations (Google Docs, Word, Notion)
+- No breaking changes — adds missing functionality
+
+**Testing scope**:
+
+- Keyboard navigation in ordered lists
+- Keyboard navigation in unordered lists
+- Mixed list types (switching between ordered/unordered)
+- Tab key in non-list content (should not regress)
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/specs/list-tab-indent/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/specs/list-tab-indent/spec.md
new file mode 100644
index 0000000000..92e1fbdddd
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/specs/list-tab-indent/spec.md
@@ -0,0 +1,77 @@
+## ADDED Requirements
+
+### Requirement: Tab key increases list item nesting
+
+When cursor is inside a list item (ordered or unordered), pressing Tab key SHALL nest the list item one level deeper by calling Tiptap's `sinkListItem` command.
+
+#### Scenario: Tab in ordered list item
+
+- **WHEN** cursor is in an ordered list item and user presses Tab
+- **THEN** list item is nested one level deeper (becomes a sublist)
+
+#### Scenario: Tab in unordered list item
+
+- **WHEN** cursor is in an unordered list item and user presses Tab
+- **THEN** list item is nested one level deeper (becomes a sublist)
+
+#### Scenario: Tab in nested list item
+
+- **WHEN** cursor is in an already-nested list item and user presses Tab
+- **THEN** list item is nested one additional level deeper
+
+#### Scenario: Tab with cursor at any position in list line
+
+- **WHEN** cursor is anywhere in the list item text (start, middle, end) and user presses Tab
+- **THEN** entire list item is nested (not just the text)
+
+### Requirement: Shift+Tab decreases list item nesting
+
+When cursor is inside a nested list item, pressing Shift+Tab SHALL lift the list item one level up by calling Tiptap's `liftListItem` command.
+
+#### Scenario: Shift+Tab in nested ordered list
+
+- **WHEN** cursor is in a nested ordered list item and user presses Shift+Tab
+- **THEN** list item is lifted one level up (reduced nesting)
+
+#### Scenario: Shift+Tab in nested unordered list
+
+- **WHEN** cursor is in a nested unordered list item and user presses Shift+Tab
+- **THEN** list item is lifted one level up (reduced nesting)
+
+#### Scenario: Shift+Tab at top level has no effect
+
+- **WHEN** cursor is in a top-level list item (not nested) and user presses Shift+Tab
+- **THEN** no change occurs (cannot lift beyond list boundary)
+
+### Requirement: Tab in non-list content uses existing indent behavior
+
+When cursor is in paragraph, heading, or blockquote (non-list content), pressing Tab SHALL apply margin-based indentation as before.
+
+#### Scenario: Tab in paragraph
+
+- **WHEN** cursor is in a paragraph and user presses Tab
+- **THEN** paragraph receives increased margin-left indentation
+
+#### Scenario: Tab in heading
+
+- **WHEN** cursor is in a heading and user presses Tab
+- **THEN** heading receives increased margin-left indentation
+
+#### Scenario: Tab in blockquote
+
+- **WHEN** cursor is in a blockquote and user presses Tab
+- **THEN** blockquote receives increased margin-left indentation
+
+### Requirement: Tab only captures when focus is inside editor content
+
+Tab key SHALL only be intercepted for indentation when document focus is inside the editor content area, not when focus is on toolbar buttons or other UI elements.
+
+#### Scenario: Tab on toolbar button
+
+- **WHEN** focus is on a toolbar button and user presses Tab
+- **THEN** focus moves to next focusable element (standard browser Tab behavior)
+
+#### Scenario: Tab in editor content
+
+- **WHEN** focus is inside editor content area and user presses Tab
+- **THEN** Tab is intercepted for indentation (does not move focus)
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/tasks.md
new file mode 100644
index 0000000000..8134f7d6b8
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-fix-list-tab-indent/tasks.md
@@ -0,0 +1,22 @@
+## 1. Modify Tab handler in Indent extension
+
+- [x] 1.1 Add list item detection in handleKeyDown (check `editor.isActive('listItem')`)
+- [x] 1.2 When in list item and Shift+Tab pressed, call `editor.commands.liftListItem('listItem')` and return result
+- [x] 1.3 When in list item and Tab pressed, call `editor.commands.sinkListItem('listItem')` and return result
+- [x] 1.4 Ensure existing non-list Tab behavior preserved (paragraphs, headings, blockquotes)
+
+## 2. Manual testing
+
+- [x] 2.1 Test Tab in ordered list (1, 2, 3) creates nested sublist
+- [x] 2.2 Test Tab in unordered list (bullets) creates nested sublist
+- [x] 2.3 Test Shift+Tab in nested list item lifts it one level
+- [x] 2.4 Test Shift+Tab at top-level list item has no effect
+- [x] 2.5 Test Tab with cursor at start, middle, and end of list line
+- [x] 2.6 Test Tab in paragraph still applies margin indentation
+- [x] 2.7 Test Tab on toolbar button moves focus (not intercepted)
+- [x] 2.8 Test multiple Tab presses nest multiple levels
+- [x] 2.9 Test switching between ordered and unordered lists preserves nesting
+
+## 3. Documentation
+
+- [x] 3.1 Add entry to CHANGELOG.md describing new Tab behavior in lists
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/.openspec.yaml
new file mode 100644
index 0000000000..8803b473ec
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-12
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/design.md
new file mode 100644
index 0000000000..f7507e65de
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/design.md
@@ -0,0 +1,250 @@
+## Context
+
+Rich text widget uses Tiptap editor with StarterKit's `OrderedList` extension. Recent Phase 1 (`auto-cycle-list-styles`) added CSS-based automatic style cycling:
+
+- Level 1: decimal → Level 2: lower-alpha → Level 3: lower-roman (repeats)
+
+Current limitations:
+
+- No way for users to manually override list style
+- All top-level lists must be decimal
+- Cannot start document with lower-alpha or lower-roman lists
+
+Widget supports two output modes via `styleDataFormat` prop:
+
+- `inline`: Outputs `style="..."` attributes
+- `class`: Outputs `data-*` attributes + CSS classes (CSP-compliant)
+
+Existing toolbar has "Numbered List" button (toggles ordered list on/off, no style control).
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Add toolbar dropdown for manual list style selection (lower-alpha, lower-roman, decimal)
+- Store style choice in `listStyleType` attribute on `` element
+- Support both inline and class-based output modes
+- Cycle offset behavior — manual styles continue cycle for nested children
+- Dropdown visible only when cursor in ordered list
+- Convert entire `` when style selected
+
+**Non-Goals:**
+
+- Upper-alpha / upper-roman styles (future Phase 3)
+- Unordered list style overrides (disc/circle/square remain auto-only)
+- Per-item styling (apply per `
` instead of per ``)
+- Split-button UI (icon + chevron in single button)
+- Keyboard shortcuts for style cycling
+- Preview of styles before selection
+
+## Decisions
+
+### Decision 1: Extend OrderedList vs create wrapper
+
+**Choice**: Create `OrderedListStyled` that extends Tiptap's `OrderedList`, add `listStyleType` attribute.
+
+**Rationale**:
+
+- Inherits all existing functionality (toggle, keyboard shortcuts, paste handling)
+- Adds single attribute without duplicating logic
+- Standard Tiptap extension pattern
+- Easy to maintain when StarterKit updates
+
+**Alternatives considered**:
+
+- Wrap OrderedList in higher-order component → More complex, loses access to extension internals
+- Modify StarterKit directly → Breaks future updates, not maintainable
+
+### Decision 2: Separate dropdown button vs split button
+
+**Choice**: Add new dropdown button "Numbering Style" next to existing "Numbered List" button. Dropdown only visible when in ordered list (`canExecute` check).
+
+**Rationale**:
+
+- Uses existing `action: "dropdown"` pattern (matches font family, font size)
+- Simpler implementation (no new component needed)
+- Better accessibility (single focus target per button)
+- Conditional visibility keeps toolbar clean when not applicable
+
+**Alternatives considered**:
+
+- Split button (icon | chevron) → Requires new component, harder accessibility, complex focus management
+- Always-visible dropdown → Clutters toolbar, confusing when not in list
+
+### Decision 3: Cycle offset for nested lists
+
+**Choice**: Manual `listStyleType` acts as "cycle position marker". Children without attribute continue cycle from that position.
+
+Example:
+
+- Parent has `listStyleType="lower-alpha"` (position 2) → children start at lower-roman (position 3)
+- Parent has `listStyleType="lower-roman"` (position 3) → children start at decimal (position 4)
+
+**CSS Implementation**:
+
+```scss
+ol[data-list-style="lower-alpha"] {
+ list-style-type: lower-alpha !important;
+
+ > li > ol:not([data-list-style]) {
+ list-style-type: lower-roman; // Continue from position 3
+ }
+}
+```
+
+**Rationale**:
+
+- Intuitive for users — "a, b, c" nests to "i, ii, iii"
+- Consistent with auto-cycle mental model
+- Predictable across nesting depths
+
+**Alternatives considered**:
+
+- Reset children to decimal → Breaks visual hierarchy, confusing
+- All children inherit same style → No nesting distinction, defeats purpose
+- No cycle offset (children use auto-cycle from level 1) → "a" → "1" → weird jump
+
+### Decision 4: Dropdown options
+
+**Choice**: 3 options:
+
+1. "1, 2, 3" (decimal) — removes attribute, uses auto-cycle
+2. "a, b, c" (lower-alpha) — sets `listStyleType="lower-alpha"`
+3. "i, ii, iii" (lower-roman) — sets `listStyleType="lower-roman"`
+
+Option 1 acts as "reset to auto" (removes attribute).
+
+**Rationale**:
+
+- Covers 95% of use cases (legal docs, formal outlines)
+- Keeps UI simple (3 choices, not overwhelming)
+- Decimal option allows intentional override (force decimal at level 2+)
+
+**Alternatives considered**:
+
+- 5 options (add upper-alpha, upper-roman) → Too many, clutters dropdown, rare use case
+- 2 options (only lower-alpha, lower-roman) → No way to reset to auto-cycle
+- No decimal option → Can't intentionally set decimal at non-top levels
+
+### Decision 5: Attribute storage format
+
+**Choice**: Use `listStyleType` attribute name (matches CSS property). Output format depends on `styleDataFormat`:
+
+- **Inline mode**: ``
+- **Class mode**: ``
+
+**parseHTML** accepts both formats (backward compat).
+
+**Rationale**:
+
+- Inline mode: Direct CSS, works immediately without stylesheet changes
+- Class mode: CSP-compliant, separates content from presentation
+- Dual parsing supports mixed content (e.g., pasted from other editors)
+
+**Alternatives considered**:
+
+- Always use inline style → Fails strict CSP policies
+- Always use class → Harder debugging, requires CSS class definitions
+- Custom attribute name (`data-mx-list-style`) → Diverges from web standards
+
+## Risks / Trade-offs
+
+**[Risk]** Users create lower-alpha at top level, expect children to be lower-roman, get decimal instead → **Mitigation**: Cycle offset CSS ensures lower-alpha (pos 2) → lower-roman (pos 3). Only issue if user expects "b" → "ii" (which would be wrong).
+
+**[Risk]** CSS specificity conflicts with existing auto-cycle rules → **Mitigation**: Use `!important` on manual overrides (`ol[data-list-style]` rules). Specificity: attribute selector (11 points) beats nested selectors (3 points).
+
+**[Risk]** Copy-paste from Word might have incompatible `list-style-type` values → **Mitigation**: `parseHTML` filters to allowed values (decimal, lower-alpha, lower-roman). Unknown values ignored, fall back to auto-cycle.
+
+**[Risk]** Users forget they set manual style, confused why nesting behaves differently → **Mitigation**: Dropdown checkmark shows current style. Decimal option allows reset.
+
+**[Trade-off]** Adding `OrderedListStyled` increases bundle size (~2KB) → Acceptable for functionality gain.
+
+**[Trade-off]** Toolbar gets more crowded with new dropdown → Mitigated by conditional visibility (only shows when in ordered list).
+
+**[Trade-off]** Two buttons for ordered lists ("Numbered List" + "Numbering Style") might confuse users → Acceptable, matches other dual-button patterns (e.g., "Bold" + "Font Family").
+
+## Implementation
+
+### Component Architecture
+
+```
+Editor.tsx
+ ├─ StarterKit (without OrderedList)
+ ├─ OrderedListStyled (replaces OrderedList)
+ │ ├─ Inherits: toggle, keyboard, paste
+ │ ├─ Adds: listStyleType attribute
+ │ ├─ Commands: setOrderedListStyle()
+ │ └─ parseHTML/renderHTML: dual-mode support
+ └─ Other extensions...
+
+ToolbarConfig.ts
+ ├─ orderedList button (existing)
+ └─ orderedListStyle dropdown (NEW)
+ ├─ canExecute: editor.isActive('orderedList')
+ ├─ getCurrentValue: reads listStyleType attribute
+ └─ dropdownOptions: decimal/lower-alpha/lower-roman
+```
+
+### Data Flow
+
+1. User clicks dropdown option "a, b, c"
+2. Calls `editor.commands.setOrderedListStyle('lower-alpha')`
+3. Command uses `updateAttributes('orderedList', { listStyleType: 'lower-alpha' })`
+4. `renderHTML` hook outputs:
+ - Inline mode: ``
+ - Class mode: ``
+5. CSS rule `ol[data-list-style="lower-alpha"]` applies `list-style-type: lower-alpha !important`
+6. Nested children match `ol[data-list-style="lower-alpha"] > li > ol:not([data-list-style])` → get `list-style-type: lower-roman`
+
+### CSS Structure
+
+```scss
+// Base auto-cycle rules (existing, from Phase 1)
+ol {
+ list-style-type: decimal;
+}
+ol ol {
+ list-style-type: lower-alpha;
+}
+ol ol ol {
+ list-style-type: lower-roman;
+}
+
+// Manual override rules (NEW)
+ol[data-list-style="lower-alpha"] {
+ list-style-type: lower-alpha !important;
+
+ > li > ol:not([data-list-style]) {
+ list-style-type: lower-roman; // Cycle offset
+ > li > ol:not([data-list-style]) {
+ list-style-type: decimal;
+ }
+ }
+}
+
+ol[data-list-style="lower-roman"] {
+ list-style-type: lower-roman !important;
+
+ > li > ol:not([data-list-style]) {
+ list-style-type: decimal; // Cycle offset
+ > li > ol:not([data-list-style]) {
+ list-style-type: lower-alpha;
+ }
+ }
+}
+
+// Class mode classes (NEW)
+.list-style-lower-alpha {
+ list-style-type: lower-alpha !important;
+}
+.list-style-lower-roman {
+ list-style-type: lower-roman !important;
+}
+.list-style-decimal {
+ list-style-type: decimal !important;
+}
+```
+
+## Open Questions
+
+None. Design is complete and validated through exploration phase.
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/proposal.md
new file mode 100644
index 0000000000..2268e9c311
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/proposal.md
@@ -0,0 +1,60 @@
+## Why
+
+Phase 1 auto-cycle provides good defaults, but users sometimes need specific numbering styles for documents with formal conventions (legal docs use lower-roman, outlines use lower-alpha at specific levels). Adding manual override gives users control while preserving automatic cycling where not overridden.
+
+## What Changes
+
+- New `OrderedListStyled` Tiptap extension replaces default `OrderedList` from StarterKit
+ - Adds `listStyleType` attribute to store manual style choice (lower-alpha, lower-roman, or null for auto-cycle)
+ - Supports both inline (`style="list-style-type: ..."`) and class-based (`data-list-style="..."` + class) modes for CSP compliance
+- New toolbar dropdown button "Numbering Style" visible only when cursor is in ordered list
+ - 3 options: "1, 2, 3" (decimal/auto-cycle), "a, b, c" (lower-alpha), "i, ii, iii" (lower-roman)
+ - Checkmark shows current style
+ - Selecting option converts entire `` to that style
+- CSS cycle offset behavior: manually styled lists continue cycle from their position
+ - lower-alpha (level 2 in cycle) → children start at lower-roman (level 3)
+ - lower-roman (level 3 in cycle) → children start at decimal (level 4)
+ - Selecting decimal removes override, reverts to auto-cycle
+- Regular "Numbered List" button unchanged — creates decimal list with no attribute (uses auto-cycle)
+
+## Capabilities
+
+### New Capabilities
+
+- `manual-list-style`: Users can manually override ordered list numbering style via toolbar dropdown (lower-alpha and lower-roman)
+
+### Modified Capabilities
+
+
+
+## Impact
+
+**Files affected**:
+
+- `packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts` — NEW extension extending OrderedList
+- `packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx` — Replace OrderedList import with OrderedListStyled
+- `packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts` — Add dropdown button config
+- `packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss` — Add CSS override rules for manual styles (~60 lines)
+
+**Dependencies**:
+
+- No new npm packages
+- Extends existing Tiptap `OrderedList` extension
+- Uses existing dropdown component pattern
+
+**User-facing changes**:
+
+- New toolbar button appears when in ordered list
+- Users can create/convert lists to lower-alpha or lower-roman styles
+- Manual styles persist in HTML as `data-list-style` attribute (class mode) or inline style
+- Backward compatible — existing lists without attribute use auto-cycle
+
+**Testing scope**:
+
+- Create list with manual style from dropdown
+- Convert existing decimal list to lower-alpha/lower-roman
+- Verify cycle offset (nested children continue from parent's position)
+- Dropdown state (checkmark on active style)
+- Class vs inline mode output
+- Copy-paste preserves manual style
+- Inline style overrides (user-edited HTML)
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/specs/manual-list-style/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/specs/manual-list-style/spec.md
new file mode 100644
index 0000000000..6ce7af6219
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/specs/manual-list-style/spec.md
@@ -0,0 +1,129 @@
+## ADDED Requirements
+
+### Requirement: Users can manually set ordered list style via toolbar dropdown
+
+When cursor is in an ordered list, a toolbar dropdown SHALL appear with options to set the list's numbering style to decimal (auto-cycle), lower-alpha, or lower-roman.
+
+#### Scenario: Dropdown visible when in ordered list
+
+- **WHEN** cursor is inside an ordered list
+- **THEN** "Numbering Style" dropdown button is visible in toolbar
+
+#### Scenario: Dropdown hidden when not in ordered list
+
+- **WHEN** cursor is in paragraph, unordered list, or task list
+- **THEN** "Numbering Style" dropdown button is hidden
+
+#### Scenario: Dropdown shows three style options
+
+- **WHEN** user clicks "Numbering Style" dropdown
+- **THEN** dropdown displays options: "1, 2, 3" (decimal), "a, b, c" (lower-alpha), "i, ii, iii" (lower-roman)
+
+#### Scenario: Dropdown shows checkmark on current style
+
+- **WHEN** ordered list has no `listStyleType` attribute
+- **THEN** dropdown shows checkmark next to "1, 2, 3" (decimal/auto-cycle default)
+
+#### Scenario: Dropdown shows checkmark on manual style
+
+- **WHEN** ordered list has `listStyleType="lower-alpha"` attribute
+- **THEN** dropdown shows checkmark next to "a, b, c" (lower-alpha)
+
+### Requirement: Users can convert ordered list to lower-alpha via dropdown
+
+Selecting "a, b, c" from dropdown SHALL convert entire ordered list to lower-alpha numbering style.
+
+#### Scenario: Convert decimal list to lower-alpha
+
+- **WHEN** cursor is in decimal list (1, 2, 3) and user selects "a, b, c" from dropdown
+- **THEN** entire list converts to lower-alpha numbering (a, b, c)
+
+#### Scenario: Lower-alpha attribute stored in HTML
+
+- **WHEN** list is converted to lower-alpha
+- **THEN** `` element has `listStyleType="lower-alpha"` attribute stored as `data-list-style="lower-alpha"` (class mode) or inline style (inline mode)
+
+### Requirement: Users can convert ordered list to lower-roman via dropdown
+
+Selecting "i, ii, iii" from dropdown SHALL convert entire ordered list to lower-roman numbering style.
+
+#### Scenario: Convert decimal list to lower-roman
+
+- **WHEN** cursor is in decimal list (1, 2, 3) and user selects "i, ii, iii" from dropdown
+- **THEN** entire list converts to lower-roman numbering (i, ii, iii)
+
+#### Scenario: Lower-roman attribute stored in HTML
+
+- **WHEN** list is converted to lower-roman
+- **THEN** `` element has `listStyleType="lower-roman"` attribute stored as `data-list-style="lower-roman"` (class mode) or inline style (inline mode)
+
+### Requirement: Users can revert manual style to auto-cycle via dropdown
+
+Selecting "1, 2, 3" from dropdown SHALL remove manual style override and revert list to auto-cycle behavior.
+
+#### Scenario: Revert lower-alpha to auto-cycle
+
+- **WHEN** cursor is in lower-alpha list and user selects "1, 2, 3" from dropdown
+- **THEN** `listStyleType` attribute is removed and list uses auto-cycle (decimal at top level)
+
+#### Scenario: Auto-cycle applies after revert
+
+- **WHEN** manual style is removed from nested list at level 2
+- **THEN** list displays with lower-alpha (auto-cycle for level 2)
+
+### Requirement: Manual styles use cycle offset for nested lists
+
+When an ordered list has manual `listStyleType` attribute, nested children SHALL continue cycle from parent's position in sequence.
+
+#### Scenario: Lower-alpha nested children start at lower-roman
+
+- **WHEN** ordered list has `listStyleType="lower-alpha"` (cycle position 2) and contains nested ordered list without attribute
+- **THEN** nested list displays with lower-roman (cycle position 3)
+
+#### Scenario: Lower-roman nested children start at decimal
+
+- **WHEN** ordered list has `listStyleType="lower-roman"` (cycle position 3) and contains nested ordered list without attribute
+- **THEN** nested list displays with decimal (cycle position 4, cycle repeats)
+
+#### Scenario: Decimal nested children start at lower-alpha
+
+- **WHEN** ordered list has explicit `listStyleType="decimal"` (cycle position 1) and contains nested ordered list without attribute
+- **THEN** nested list displays with lower-alpha (cycle position 2)
+
+### Requirement: Manual styles support both inline and class-based modes
+
+OrderedListStyled extension SHALL output manual styles as inline `style="list-style-type: ..."` or class-based `data-list-style="..." class="list-style-..."` depending on widget configuration.
+
+#### Scenario: Inline mode outputs inline style
+
+- **WHEN** widget configured with `styleDataFormat="inline"` and list has `listStyleType="lower-alpha"`
+- **THEN** HTML output is ``
+
+#### Scenario: Class mode outputs data attribute and class
+
+- **WHEN** widget configured with `styleDataFormat="class"` and list has `listStyleType="lower-alpha"`
+- **THEN** HTML output is ``
+
+#### Scenario: Parse inline style on load
+
+- **WHEN** HTML contains ``
+- **THEN** extension parses and stores `listStyleType="lower-roman"` attribute
+
+#### Scenario: Parse data attribute on load
+
+- **WHEN** HTML contains ``
+- **THEN** extension parses and stores `listStyleType="lower-alpha"` attribute
+
+### Requirement: Regular numbered list button creates decimal list without attribute
+
+The existing "Numbered List" toolbar button SHALL continue creating ordered lists with no `listStyleType` attribute (uses auto-cycle).
+
+#### Scenario: Numbered list button creates decimal list
+
+- **WHEN** cursor is in paragraph and user clicks "Numbered List" button
+- **THEN** ordered list is created with decimal numbering (1, 2, 3) and no `listStyleType` attribute
+
+#### Scenario: Dropdown shows decimal as active for attribute-less list
+
+- **WHEN** ordered list has no `listStyleType` attribute
+- **THEN** dropdown displays checkmark next to "1, 2, 3" (decimal)
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/tasks.md
new file mode 100644
index 0000000000..d5c3a4e146
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-manual-list-style-override/tasks.md
@@ -0,0 +1,48 @@
+## 1. Create OrderedListStyled extension
+
+- [x] 1.1 Create new file `src/extensions/OrderedListStyled.ts`
+- [x] 1.2 Extend Tiptap's OrderedList with listStyleType attribute (default: null)
+- [x] 1.3 Implement parseHTML to read from inline style or data-list-style attribute
+- [x] 1.4 Implement renderHTML to output based on styleDataFormat option (inline vs class mode)
+- [x] 1.5 Add setOrderedListStyle command that calls updateAttributes with styleType parameter
+
+## 2. Update Editor to use OrderedListStyled
+
+- [x] 2.1 Import OrderedListStyled extension in Editor.tsx
+- [x] 2.2 Configure StarterKit to disable built-in OrderedList extension
+- [x] 2.3 Add OrderedListStyled to extensions array with styleDataFormat option from props
+- [x] 2.4 Verify ordered list toggle still works (inherited behavior)
+
+## 3. Add toolbar dropdown button
+
+- [x] 3.1 Add orderedListStyle dropdown config to ToolbarConfig.ts in list group
+- [x] 3.2 Configure 3 dropdown options: "1, 2, 3" (null), "a, b, c" (lower-alpha), "i, ii, iii" (lower-roman)
+- [x] 3.3 Implement getCurrentValue function to read listStyleType from editor.getAttributes('orderedList')
+- [x] 3.4 Add canExecute check: only show when editor.isActive('orderedList')
+- [x] 3.5 Map dropdown selections to setOrderedListStyle command with correct styleType
+
+## 4. Add CSS override rules
+
+- [x] 4.1 Add manual override rule for ol[data-list-style="lower-alpha"] with cycle offset (children start at lower-roman)
+- [x] 4.2 Add manual override rule for ol[data-list-style="lower-roman"] with cycle offset (children start at decimal)
+- [x] 4.3 Add manual override rule for ol[data-list-style="decimal"] (optional, for explicit decimal at any level)
+- [x] 4.4 Add class mode CSS: .list-style-lower-alpha, .list-style-lower-roman, .list-style-decimal
+- [x] 4.5 Verify !important priority works (manual overrides beat auto-cycle rules)
+
+## 5. Manual testing
+
+- [x] 5.1 Test dropdown appears when cursor in ordered list, hidden otherwise
+- [x] 5.2 Test create lower-alpha list via dropdown, verify a, b, c numbering
+- [x] 5.3 Test create lower-roman list via dropdown, verify i, ii, iii numbering
+- [x] 5.4 Test convert decimal list to lower-alpha via dropdown
+- [x] 5.5 Test convert lower-alpha list back to decimal (revert to auto-cycle)
+- [x] 5.6 Test cycle offset: lower-alpha parent → lower-roman children → decimal grandchildren
+- [x] 5.7 Test cycle offset: lower-roman parent → decimal children → lower-alpha grandchildren
+- [x] 5.8 Test dropdown checkmark shows on correct option based on current listStyleType
+- [x] 5.9 Test inline mode outputs style="list-style-type: ..."
+- [x] 5.10 Test class mode outputs data-list-style + class
+- [x] 5.11 Test copy-paste preserves manual style attribute
+
+## 6. Documentation
+
+- [x] 6.1 Add entry to CHANGELOG.md describing manual list style override feature (Phase 2)
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/.openspec.yaml
new file mode 100644
index 0000000000..8803b473ec
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-12
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/design.md
new file mode 100644
index 0000000000..06759d9860
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/design.md
@@ -0,0 +1,465 @@
+# Design: Ordered List Split Button
+
+## Context
+
+Current toolbar implementation:
+
+- Separate `ToolbarButton` and `ToolbarDropdown` components
+- Button actions: `toggle`, `command`, `custom`, `dropdown`, `colorPicker`, `dialog`, `tableGrid`, `codeView`, `configurationDropdown`
+- List group has 4 buttons: bulletList, orderedList, taskList, orderedListStyle
+- `orderedListStyle` dropdown only enabled when OL active (`canExecute: editor => editor.isActive("orderedList")`)
+- Icons exist in RichTextIcons.scss: `List-numbers`, `List-lower-alpha`, `List-roman`
+- Toolbar config driven by `TOOLBAR_GROUPS` array in `ToolbarConfig.ts`
+- Factory pattern in `Toolbar.tsx` renders buttons based on action type
+
+Current limitation:
+
+- No composite button pattern (split button, segmented control)
+- No per-button state tracking (style preference)
+- Dropdown options only support text labels, no icons
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Create reusable `ToolbarSplitButton` component for composite button patterns
+- Merge OL toggle + style picker into single UI element
+- Dynamic icon based on active/last-used style
+- Sticky state for style preference across toggles
+- Keyboard navigation following ARIA authoring practices
+- Zero breaking changes to existing toolbar buttons
+
+**Non-Goals:**
+
+- Bullet list split button (future work)
+- Per-editor instance state isolation (module-level acceptable for MVP)
+- LocalStorage persistence (session-scoped sufficient)
+- Refactor existing button components (reuse where possible)
+
+## Decisions
+
+### 1. Component architecture: New vs Composite
+
+**Decision**: Create new `ToolbarSplitButton` component, not composite wrapper.
+
+**Rationale**:
+
+- Split button has distinct interaction model (two focus targets, shared active state)
+- Keyboard nav differs from separate buttons (arrow keys move between parts)
+- ARIA requirements specific to split button pattern (role="group", coordinated aria-pressed/expanded)
+- Reusability for future split buttons (bullet styles, etc)
+
+**Alternative considered**: Wrap existing `ToolbarButton` + `ToolbarDropdown` in container
+
+- **Rejected**: Focus management and keyboard nav would be complex to coordinate externally. Interaction logic belongs in component.
+
+### 2. State management: Where to store lastOrderedListStyle
+
+**Decision**: Module-level variable in component file.
+
+```typescript
+// ToolbarSplitButton.tsx
+let lastOrderedListStyle: "decimal" | "lower-alpha" | "lower-roman" = "decimal";
+```
+
+**Rationale**:
+
+- Simplest implementation, no extra infrastructure
+- Multiple editor instances on same page is rare edge case
+- Easy to migrate to per-editor storage later if needed
+
+**Alternatives considered**:
+
+| Approach | Pro | Con | Verdict |
+| -------------- | ------------------------ | ------------------------------------------ | ---------------- |
+| Editor.storage | Per-editor isolation | Need to update OrderedListStyled extension | Overkill for MVP |
+| React Context | React-idiomatic | More boilerplate, context provider changes | Overengineered |
+| LocalStorage | Persists across sessions | Unnecessary complexity, sync issues | Out of scope |
+
+### 3. Icon update mechanism: Pull vs Push
+
+**Decision**: Component pulls icon on render via `getCurrentIcon()` helper.
+
+```typescript
+const getCurrentIcon = (): string => {
+ if (editor?.isActive("orderedList")) {
+ const attrs = editor.getAttributes("orderedList");
+ const style = attrs.listStyleType || "decimal";
+ return STYLE_ICON_MAP[style];
+ }
+ return STYLE_ICON_MAP[lastOrderedListStyle];
+};
+```
+
+**Rationale**:
+
+- Consistent with existing button patterns (re-render on editor.on("selectionUpdate"))
+- No need to track icon state separately
+- Single source of truth: editor attributes when active, sticky state when inactive
+
+**Alternative considered**: Push updates via editor events
+
+- **Rejected**: Would require tracking previous style, detecting changes, syncing with component state. Pull is simpler.
+
+### 4. Dropdown behavior when OL inactive
+
+**Decision**: Dropdown always enabled, selecting option enables OL + applies style.
+
+**Rationale**:
+
+- Matches Word/Office pattern (dropdown accessible before list enabled)
+- Improves discoverability (user can preview style options)
+- Eliminates confusion of disabled dropdown
+
+**Alternative considered**: Dropdown disabled when inactive (current behavior)
+
+- **Rejected**: Forces two-step workflow (enable OL, then pick style). Poor UX.
+
+### 5. Command strategy: New vs Compose
+
+**Decision**: Compose existing commands in component handlers, don't add new editor command.
+
+```typescript
+// Main button click
+editor.chain().focus().toggleOrderedList().updateAttributes("orderedList", { listStyleType: style }).run();
+
+// Dropdown option click
+if (isActive) {
+ editor.chain().focus().setOrderedListStyle(style).run();
+} else {
+ editor.chain().focus().toggleOrderedList().updateAttributes("orderedList", { listStyleType: style }).run();
+}
+```
+
+**Rationale**:
+
+- Existing commands cover all operations
+- Logic belongs in component (UI concern, not editor operation)
+- Easier to test (no editor extension changes)
+
+**Alternative considered**: Add `toggleOrderedListWithStyle` command to OrderedListStyled extension
+
+- **Rejected**: Mixes UI state (sticky preference) with editor logic. Commands should be stateless.
+
+### 6. Keyboard navigation model
+
+**Decision**: Two-stop tab model with arrow key navigation between parts.
+
+```
+Tab → Focus whole split button (outline both parts)
+Enter/Space → Execute focused part's action
+ArrowRight → Move focus from main to dropdown
+ArrowLeft → Move focus from dropdown to main
+ArrowDown → Open dropdown (from either part)
+```
+
+**Rationale**:
+
+- Follows WCAG ARIA authoring practices for split button
+- Efficient keyboard workflow (Tab once, arrows to navigate parts)
+- Matches Office/Windows split button behavior
+
+**Alternative considered**: Separate tab stops for main/dropdown
+
+- **Rejected**: Doubles tab stops in toolbar, slows keyboard navigation. Split button should behave as single control.
+
+### 7. ToolbarConfig changes: Extend vs Replace
+
+**Decision**: Add `icon` field to `ToolbarDropdownOption`, add `"splitButton"` action type, keep existing config structure.
+
+```typescript
+export interface ToolbarDropdownOption {
+ label: string;
+ value: string;
+ command: string;
+ attrs?: Record;
+ icon?: string; // NEW
+}
+
+export type ToolbarActionType =
+ | "toggle"
+ | "command"
+ | "custom"
+ | "heading"
+ | "dropdown"
+ | "splitButton" // NEW
+ | "tableGrid"
+ | "colorPicker"
+ | "dialog"
+ | "codeView"
+ | "configurationDropdown";
+```
+
+**Rationale**:
+
+- Backward compatible (icon optional, splitButton only used by OL)
+- Follows existing action type pattern
+- Dropdown options reusable by ToolbarDropdown (icon rendering conditional)
+
+### 8. SCSS structure: BEM vs Nested
+
+**Decision**: Nested selectors under `.split-button` parent class.
+
+```scss
+.split-button {
+ .split-button-main {
+ /* ... */
+ }
+ .split-button-dropdown {
+ /* ... */
+ }
+ &.is-active {
+ /* ... */
+ }
+}
+```
+
+**Rationale**:
+
+- Matches existing toolbar SCSS structure (`.toolbar-group`, `.toolbar-dropdown-button`)
+- Scoping prevents class name collisions
+- Active state applies to container, styling inherited by parts
+
+## Component API
+
+### ToolbarSplitButton Props
+
+```typescript
+interface ToolbarSplitButtonProps {
+ config: ToolbarButtonConfig; // Reuse existing config type
+}
+
+// Config shape for split button:
+{
+ name: "orderedList",
+ title: "Numbered List",
+ icon: "List-numbers", // Default icon (overridden by getCurrentIcon)
+ action: "splitButton",
+ command: "toggleOrderedList", // Main button command
+ isActive: (editor) => editor.isActive("orderedList"),
+ dropdownOptions: [
+ {
+ label: "1, 2, 3",
+ value: "decimal",
+ command: "setOrderedListStyle",
+ attrs: { styleType: null },
+ icon: "List-numbers"
+ },
+ // ... more options
+ ],
+ getCurrentValue: (editor) => {
+ const attrs = editor.getAttributes("orderedList");
+ return attrs.listStyleType || "decimal";
+ }
+}
+```
+
+### Internal State
+
+```typescript
+const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+const [focusedPart, setFocusedPart] = useState<"main" | "dropdown">("main");
+const mainButtonRef = useRef(null);
+const dropdownButtonRef = useRef(null);
+```
+
+### Event Handlers
+
+```typescript
+handleMainClick(): void
+ // Toggle OL with sticky style when inactive
+ // Toggle OL off when active
+
+handleDropdownClick(): void
+ // Toggle dropdown open/closed
+
+handleOptionSelect(option: ToolbarDropdownOption): void
+ // Update sticky state
+ // Apply style + enable OL if inactive
+ // Just apply style if active
+ // Close dropdown
+
+handleKeyDown(e: KeyboardEvent, part: "main" | "dropdown"): void
+ // ArrowRight/Left: move focus between parts
+ // ArrowDown: open dropdown
+ // Enter/Space: execute focused part action
+```
+
+## Accessibility
+
+### ARIA attributes
+
+```tsx
+
+
+
+
+
;
+
+{
+ isDropdownOpen && (
+
+
+
+ );
+}
+```
+
+### Focus management
+
+- Split button container has visual focus indicator when either part focused
+- Focus outline on active part (`:focus-visible`)
+- Arrow keys move focus without triggering actions
+- Dropdown opens with first option pre-highlighted (via `aria-activedescendant`)
+
+### Screen reader announcements
+
+- Main button: "Toggle numbered list, button, pressed" (when active)
+- Dropdown button: "Numbering style options, button, collapsed/expanded"
+- Menu item: "1, 2, 3, menu item, selected" (when active style)
+
+## File changes
+
+### New files
+
+- `src/components/toolbars/components/ToolbarSplitButton.tsx` - Split button component
+- `src/components/toolbars/helpers/listHelpers.ts` - Icon mapping utilities
+
+### Modified files
+
+- `src/components/toolbars/ToolbarConfig.ts`
+ - Add `icon?: string` to `ToolbarDropdownOption`
+ - Add `"splitButton"` to `ToolbarActionType`
+ - Update `orderedList` button config in `TOOLBAR_GROUPS`
+ - Remove `orderedListStyle` standalone button
+ - Add icon to dropdown options
+
+- `src/components/toolbars/Toolbar.tsx`
+ - Import `ToolbarSplitButton`
+ - Add `case "splitButton"` to `ToolbarButtonFactory`
+
+- `src/components/toolbars/Toolbar.scss`
+ - Add `.split-button` styles
+ - Update `.toolbar-dropdown-item` for icon + text layout
+
+- `src/components/toolbars/components/ToolbarDropdown.tsx` (optional enhancement)
+ - Conditionally render option icons if present
+
+## Risks / Trade-offs
+
+### Risk: Multi-editor instance state collision
+
+**Scenario**: Page has two RichText widgets, user selects lower-alpha in widget A, then clicks OL in widget B. Widget B gets lower-alpha instead of decimal.
+
+**Mitigation**:
+
+- Acceptable for MVP (rare use case)
+- If reported, migrate to `editor.storage.orderedListStyled.lastUsedStyle`
+
+**Trade-off**: Simple implementation now vs perfect isolation
+
+---
+
+### Risk: Touch target size on mobile
+
+**Scenario**: Split button parts too small for touch (iOS Safari needs 44x44pt minimum)
+
+**Mitigation**:
+
+- Combined split button width: 56px (adequate)
+- Main button: 32px min-width
+- Dropdown: 24px min-width
+- Vertical height: 32px (matches other toolbar buttons)
+
+**Verification**: Test on iPhone during QA
+
+**Trade-off**: Desktop compactness vs mobile usability
+
+---
+
+### Risk: Keyboard nav complexity
+
+**Scenario**: Users confused by arrow key behavior (moves focus instead of navigating toolbar)
+
+**Mitigation**:
+
+- Standard split button pattern (Word, Office, Windows)
+- Arrow keys only active when split button focused
+- Tab continues to next toolbar button (normal flow)
+
+**Trade-off**: Richer interaction vs learning curve
+
+---
+
+### Risk: Icon not updating after style change
+
+**Scenario**: Component doesn't re-render after dropdown selection
+
+**Mitigation**:
+
+- `useEffect` on editor "selectionUpdate" and "transaction" events (same as existing buttons)
+- `getCurrentIcon()` recalculates on every render
+- Force re-render with state trigger: `setUpdateTrigger(prev => prev + 1)`
+
+**Trade-off**: Extra re-renders vs guaranteed sync
+
+---
+
+### Risk: Dropdown positioning with Floating UI
+
+**Scenario**: Dropdown anchored to whole split button, not dropdown part
+
+**Mitigation**:
+
+- Pass `dropdownButtonRef.current` to `useDropdown` hook (not container ref)
+- Floating UI anchors to dropdown part
+
+**Verification**: Test with scrolling, overflow containers
+
+## Migration Plan
+
+No user-facing migration needed (UI change only).
+
+### Deployment steps
+
+1. Deploy changes to dev environment
+2. Manual testing:
+ - Verify icon changes (decimal → alpha → roman)
+ - Verify sticky state across toggles
+ - Keyboard nav (Tab, arrows, Enter, ArrowDown)
+ - Touch on mobile (iPad/iPhone)
+ - Multiple editor instances (if possible)
+3. Run unit tests + E2E test
+4. Deploy to staging
+5. Notify QA team for UX verification
+6. Deploy to production
+
+### Rollback strategy
+
+If critical issue found:
+
+1. Revert PR (single PR includes all changes)
+2. Split button reverts to separate buttons
+3. No data loss (OrderedListStyled extension unchanged)
+
+## Open Questions
+
+None. Design ready for implementation.
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/proposal.md
new file mode 100644
index 0000000000..7f8e0dbe87
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/proposal.md
@@ -0,0 +1,160 @@
+# Proposal: Ordered List Split Button
+
+## Problem
+
+Current toolbar has separate buttons for ordered list toggle and style picker:
+
+```
+[Bullets] [Numbers] [Checklist] [▼ Style]
+```
+
+Issues:
+
+- Style dropdown separated from list toggle (poor spatial grouping)
+- Style dropdown disabled when no OL active (discovery problem)
+- No visual feedback of current style when OL inactive
+- Doesn't match Word/Office UX pattern users expect
+
+## Solution
+
+Merge ordered list toggle and style picker into single split button:
+
+```
+[Bullets ▼] [Numbers ▼] [Checklist]
+```
+
+Split button behavior:
+
+- **Main button**: Toggle OL on/off
+- **Dropdown arrow**: Open style picker
+- **Icon**: Dynamic based on active/last-used style (1, a, i)
+- **Sticky state**: Remember last used style across toggles
+
+### Word-like interaction flow
+
+**When OL inactive:**
+
+- Main click → Enable OL with last-used style (default: decimal)
+- Dropdown click → Show styles, selecting applies style + enables OL
+- Icon shows last-used style
+
+**When OL active:**
+
+- Main click → Disable OL
+- Dropdown click → Show styles, selecting changes style
+- Icon shows current active style
+
+**Dropdown options (icon + text):**
+
+- [#] 1, 2, 3 (decimal)
+- [a] a, b, c (lower-alpha)
+- [i] i, ii, iii (lower-roman)
+
+## Benefits
+
+- **Spatial proximity**: Style control attached to list trigger
+- **Discoverability**: Dropdown always visible, not hidden when inactive
+- **Visual feedback**: Icon changes to reflect style (1 → a → i)
+- **Familiar UX**: Matches Word/Office split button pattern
+- **Less toolbar clutter**: 4 buttons → 3 buttons
+
+## Implementation approach
+
+### 1. New component
+
+Create `ToolbarSplitButton.tsx`:
+
+- Two buttons in group (main + dropdown)
+- Independent click handlers
+- Shared active state styling
+- Keyboard nav: arrows move between buttons, ArrowDown opens menu
+- ARIA: `role="group"`, `aria-pressed`, `aria-expanded`
+
+### 2. Config changes
+
+Update `ToolbarConfig.ts`:
+
+- Add `"splitButton"` action type
+- Add `icon` field to `ToolbarDropdownOption` interface
+- Update `orderedList` button config with dropdown options
+- Remove standalone `orderedListStyle` button
+- Add icon mapping helper
+
+### 3. State management
+
+Module-level sticky state:
+
+```typescript
+let lastOrderedListStyle: "decimal" | "lower-alpha" | "lower-roman" = "decimal";
+```
+
+Updated on style selection, used when toggling OL while inactive.
+
+### 4. Icon mapping
+
+```typescript
+const STYLE_ICON_MAP = {
+ decimal: "List-numbers",
+ "lower-alpha": "List-lower-alpha",
+ "lower-roman": "List-roman"
+};
+```
+
+Icons already exist in `RichTextIcons.scss`.
+
+### 5. Dropdown options
+
+Add icons to dropdown items:
+
+```tsx
+
+```
+
+### 6. SCSS
+
+`.split-button` wrapper with:
+
+- `.split-button-main` (left side, border-right)
+- `.split-button-dropdown` (right side, chevron)
+- `&.is-active` highlighting
+- Focus indicators on both buttons
+
+## Out of scope
+
+- Bullet list split button (future enhancement for bullet styles)
+- Multi-instance isolation (module-level state acceptable for MVP)
+- Persistent storage (session-scoped sticky state sufficient)
+
+## Risks
+
+- **Multi-editor instances**: Shared module state across instances on same page. Mitigation: Rare use case, can enhance later with per-editor storage.
+- **Touch targets**: 56px combined width adequate for touch, verify on mobile.
+- **Keyboard complexity**: Split button nav more complex than single button. Mitigation: Follow ARIA authoring practices.
+
+## Testing
+
+Unit tests:
+
+- Icon changes based on active style
+- Sticky state persists across toggles
+- Main button toggles OL with correct style
+- Dropdown options apply style + toggle when inactive
+- Dropdown options change style only when active
+- Keyboard navigation
+
+E2E test:
+
+- Full workflow: select style via dropdown, toggle off/on, verify sticky
+
+## Success criteria
+
+- [ ] Ordered list button merged with style picker
+- [ ] Icon updates dynamically (List-numbers, List-lower-alpha, List-roman)
+- [ ] Last-used style remembered across toggles
+- [ ] Dropdown accessible when OL inactive
+- [ ] Keyboard navigation works
+- [ ] Tests pass
+- [ ] Visual match to Word split button pattern
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/ordered-list-interaction/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/ordered-list-interaction/spec.md
new file mode 100644
index 0000000000..99186f1508
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/ordered-list-interaction/spec.md
@@ -0,0 +1,139 @@
+# Ordered List Interaction Specification
+
+## ADDED Requirements
+
+### Requirement: Sticky state tracks last-used list style
+
+The system SHALL remember the last-used ordered list style across toggle operations.
+
+#### Scenario: Default style is decimal
+
+- **WHEN** user first interacts with ordered list
+- **THEN** sticky state defaults to `"decimal"`
+
+#### Scenario: Sticky state updates on style selection
+
+- **WHEN** user selects lower-alpha from dropdown
+- **THEN** sticky state updates to `"lower-alpha"`
+
+#### Scenario: Sticky state persists after toggle off
+
+- **WHEN** user has lower-alpha active
+- **WHEN** user toggles ordered list off
+- **WHEN** user toggles ordered list on again
+- **THEN** ordered list uses lower-alpha style
+
+### Requirement: Main button toggles OL with sticky style
+
+Clicking the main button SHALL toggle ordered list on/off, using sticky style when enabling.
+
+#### Scenario: Enable OL with sticky style when inactive
+
+- **WHEN** ordered list is inactive
+- **WHEN** sticky state is lower-alpha
+- **WHEN** user clicks main button
+- **THEN** ordered list enables with lower-alpha style
+
+#### Scenario: Enable OL with decimal on first use
+
+- **WHEN** ordered list is inactive
+- **WHEN** sticky state is default (decimal)
+- **WHEN** user clicks main button
+- **THEN** ordered list enables with decimal style (null attribute)
+
+#### Scenario: Disable OL when active
+
+- **WHEN** ordered list is active
+- **WHEN** user clicks main button
+- **THEN** ordered list disables
+- **THEN** sticky state remains unchanged
+
+### Requirement: Dropdown selection applies style and enables OL
+
+Selecting a style from dropdown SHALL apply that style and enable OL if inactive.
+
+#### Scenario: Apply style when OL inactive
+
+- **WHEN** ordered list is inactive
+- **WHEN** user selects lower-roman from dropdown
+- **THEN** ordered list enables with lower-roman style
+- **THEN** sticky state updates to lower-roman
+
+#### Scenario: Change style when OL active
+
+- **WHEN** ordered list is active with decimal style
+- **WHEN** user selects lower-alpha from dropdown
+- **THEN** ordered list style changes to lower-alpha
+- **THEN** ordered list remains active
+- **THEN** sticky state updates to lower-alpha
+
+### Requirement: Icon reflects current or sticky state
+
+The split button icon SHALL dynamically change to reflect the active style or sticky state.
+
+#### Scenario: Icon shows active style
+
+- **WHEN** ordered list is active with lower-alpha
+- **THEN** icon displays List-lower-alpha
+
+#### Scenario: Icon shows sticky state when inactive
+
+- **WHEN** ordered list is inactive
+- **WHEN** sticky state is lower-roman
+- **THEN** icon displays List-roman
+
+#### Scenario: Icon updates after style change
+
+- **WHEN** user changes style from decimal to lower-alpha
+- **THEN** icon updates from List-numbers to List-lower-alpha
+
+### Requirement: Dropdown is accessible when OL inactive
+
+The dropdown button SHALL be enabled and functional even when ordered list is inactive.
+
+#### Scenario: Dropdown button is not disabled
+
+- **WHEN** ordered list is inactive
+- **THEN** dropdown button is enabled
+- **THEN** user can open dropdown menu
+
+#### Scenario: Style options are selectable when inactive
+
+- **WHEN** ordered list is inactive
+- **WHEN** dropdown menu is open
+- **THEN** all style options are clickable
+- **THEN** selecting option enables OL with that style
+
+### Requirement: Active style is visually indicated in dropdown
+
+The dropdown menu SHALL highlight the currently active or sticky style.
+
+#### Scenario: Active style is highlighted
+
+- **WHEN** ordered list is active with lower-alpha
+- **WHEN** dropdown menu is open
+- **THEN** lower-alpha option has `is-active` class
+
+#### Scenario: Sticky style is highlighted when inactive
+
+- **WHEN** ordered list is inactive
+- **WHEN** sticky state is lower-roman
+- **WHEN** dropdown menu is open
+- **THEN** lower-roman option has `is-active` class
+
+### Requirement: Sticky state is module-scoped
+
+The sticky state SHALL be stored at module level and shared across editor instances.
+
+#### Scenario: State persists during editor session
+
+- **WHEN** user selects lower-alpha
+- **WHEN** user navigates to different content area
+- **WHEN** user returns and toggles OL
+- **THEN** ordered list uses lower-alpha style
+
+#### Scenario: State resets on page reload
+
+- **WHEN** user selects lower-roman
+- **WHEN** page reloads
+- **THEN** sticky state resets to default (decimal)
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/split-button-component/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/split-button-component/spec.md
new file mode 100644
index 0000000000..f9094a7d88
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/split-button-component/spec.md
@@ -0,0 +1,185 @@
+# Split Button Component Specification
+
+## ADDED Requirements
+
+### Requirement: Split button renders main and dropdown parts
+
+The component SHALL render two interactive button elements grouped together: a main action button and a dropdown trigger button.
+
+#### Scenario: Component renders with both parts
+
+- **WHEN** split button is mounted in toolbar
+- **THEN** main button displays configured icon
+- **THEN** dropdown button displays chevron-down icon
+- **THEN** both buttons are grouped in single container with `role="group"`
+
+#### Scenario: Container has accessible label
+
+- **WHEN** split button is rendered
+- **THEN** container has `aria-label` matching button title
+
+### Requirement: Main button executes primary action
+
+The main button SHALL execute the configured command when clicked.
+
+#### Scenario: Main button click triggers command
+
+- **WHEN** user clicks main button
+- **THEN** configured command executes via editor chain
+- **THEN** focus returns to editor
+
+#### Scenario: Main button respects active state
+
+- **WHEN** configured `isActive` function returns true
+- **THEN** main button has `aria-pressed="true"`
+- **THEN** split button container has `is-active` class
+
+### Requirement: Dropdown button opens style menu
+
+The dropdown button SHALL toggle the dropdown menu when clicked.
+
+#### Scenario: Dropdown button opens menu
+
+- **WHEN** user clicks dropdown button
+- **THEN** dropdown menu becomes visible
+- **THEN** dropdown button has `aria-expanded="true"`
+
+#### Scenario: Dropdown button closes menu
+
+- **WHEN** dropdown menu is open
+- **WHEN** user clicks dropdown button again
+- **THEN** dropdown menu becomes hidden
+- **THEN** dropdown button has `aria-expanded="false"`
+
+### Requirement: Dropdown options display icon and text
+
+Each dropdown option SHALL render both an icon and text label.
+
+#### Scenario: Option renders with icon
+
+- **WHEN** dropdown menu is open
+- **THEN** each option displays icon from config `icon` field
+- **THEN** each option displays text from config `label` field
+
+#### Scenario: Active option is highlighted
+
+- **WHEN** dropdown menu is open
+- **WHEN** current value matches option value
+- **THEN** option has `is-active` class
+
+### Requirement: Selecting dropdown option updates state
+
+When user selects a dropdown option, the component SHALL execute the option's command and close the menu.
+
+#### Scenario: Option selection executes command
+
+- **WHEN** user clicks dropdown option
+- **THEN** option's command executes with configured attrs
+- **THEN** dropdown menu closes
+- **THEN** focus returns to editor
+
+### Requirement: Icon updates dynamically
+
+The main button icon SHALL change based on editor state and last-used value.
+
+#### Scenario: Icon reflects active state
+
+- **WHEN** editor has active state matching button config
+- **THEN** icon updates to reflect current state value
+
+#### Scenario: Icon reflects last-used value when inactive
+
+- **WHEN** editor does not have active state
+- **THEN** icon shows last-used value from sticky state
+
+### Requirement: Keyboard navigation between parts
+
+The component SHALL support arrow key navigation between main and dropdown buttons.
+
+#### Scenario: ArrowRight moves focus to dropdown
+
+- **WHEN** main button has focus
+- **WHEN** user presses ArrowRight
+- **THEN** focus moves to dropdown button
+
+#### Scenario: ArrowLeft moves focus to main
+
+- **WHEN** dropdown button has focus
+- **WHEN** user presses ArrowLeft
+- **THEN** focus moves to main button
+
+#### Scenario: ArrowDown opens dropdown
+
+- **WHEN** either button has focus
+- **WHEN** user presses ArrowDown
+- **THEN** dropdown menu opens
+
+### Requirement: Tab navigation treats as single control
+
+The split button SHALL act as single tab stop in toolbar navigation.
+
+#### Scenario: Tab focuses split button once
+
+- **WHEN** user tabs through toolbar
+- **THEN** split button receives focus as single stop
+- **THEN** next tab moves to next toolbar control
+
+#### Scenario: Shift+Tab navigates backward
+
+- **WHEN** split button has focus
+- **WHEN** user presses Shift+Tab
+- **THEN** focus moves to previous toolbar control
+
+### Requirement: Enter and Space execute focused part
+
+The component SHALL execute the action of whichever part has focus when Enter or Space is pressed.
+
+#### Scenario: Enter on main button executes command
+
+- **WHEN** main button has focus
+- **WHEN** user presses Enter
+- **THEN** main button command executes
+
+#### Scenario: Space on dropdown button opens menu
+
+- **WHEN** dropdown button has focus
+- **WHEN** user presses Space
+- **THEN** dropdown menu opens
+
+### Requirement: Click outside closes dropdown
+
+The dropdown menu SHALL close when user clicks outside the component.
+
+#### Scenario: Outside click closes menu
+
+- **WHEN** dropdown menu is open
+- **WHEN** user clicks outside split button
+- **THEN** dropdown menu closes
+
+### Requirement: Focus indicators are visible
+
+Both button parts SHALL display visible focus indicators for keyboard navigation.
+
+#### Scenario: Focus outline on main button
+
+- **WHEN** main button has focus
+- **THEN** main button displays focus outline
+
+#### Scenario: Focus outline on dropdown button
+
+- **WHEN** dropdown button has focus
+- **THEN** dropdown button displays focus outline
+
+### Requirement: Component updates on editor events
+
+The component SHALL re-render when editor state changes to keep icon and active state synchronized.
+
+#### Scenario: Update on selection change
+
+- **WHEN** editor `selectionUpdate` event fires
+- **THEN** component re-renders with current state
+
+#### Scenario: Update on content change
+
+- **WHEN** editor `transaction` event fires
+- **THEN** component re-renders with current state
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/toolbar-configuration/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/toolbar-configuration/spec.md
new file mode 100644
index 0000000000..a256e2b4bc
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/specs/toolbar-configuration/spec.md
@@ -0,0 +1,91 @@
+# Toolbar Configuration Specification
+
+## ADDED Requirements
+
+### Requirement: Toolbar config supports splitButton action type
+
+The toolbar configuration SHALL support `"splitButton"` as a valid action type for button definitions.
+
+#### Scenario: Split button action type is recognized
+
+- **WHEN** button config has `action: "splitButton"`
+- **THEN** toolbar factory renders ToolbarSplitButton component
+
+#### Scenario: Split button config includes dropdown options
+
+- **WHEN** button config has `action: "splitButton"`
+- **THEN** button config includes `dropdownOptions` array
+- **THEN** each option includes `label`, `value`, `command`, `attrs`, and `icon` fields
+
+### Requirement: Dropdown options support icon field
+
+The ToolbarDropdownOption interface SHALL include an optional `icon` field for visual representation.
+
+#### Scenario: Icon field is optional
+
+- **WHEN** dropdown option is defined
+- **THEN** `icon` field may be omitted without error
+
+#### Scenario: Icon field is used when present
+
+- **WHEN** dropdown option includes `icon` field
+- **THEN** rendered option displays icon before label
+
+### Requirement: Ordered list button uses split button configuration
+
+The ordered list toolbar button SHALL be configured as a split button with style options.
+
+#### Scenario: Ordered list button has split button action
+
+- **WHEN** toolbar groups are loaded
+- **THEN** orderedList button has `action: "splitButton"`
+
+#### Scenario: Ordered list dropdown includes style options
+
+- **WHEN** orderedList button config is accessed
+- **THEN** dropdownOptions includes decimal, lower-alpha, and lower-roman
+- **THEN** each option has corresponding icon (List-numbers, List-lower-alpha, List-roman)
+
+#### Scenario: Decimal option uses null styleType
+
+- **WHEN** decimal option is selected
+- **THEN** `attrs.styleType` is `null` (browser default)
+
+#### Scenario: Lower-alpha option uses styleType attribute
+
+- **WHEN** lower-alpha option is selected
+- **THEN** `attrs.styleType` is `"lower-alpha"`
+
+#### Scenario: Lower-roman option uses styleType attribute
+
+- **WHEN** lower-roman option is selected
+- **THEN** `attrs.styleType` is `"lower-roman"`
+
+### Requirement: Standalone orderedListStyle button is removed
+
+The toolbar configuration SHALL NOT include standalone `orderedListStyle` dropdown button.
+
+#### Scenario: List group has three buttons
+
+- **WHEN** list toolbar group is rendered
+- **THEN** group includes bulletList, orderedList (split), and taskList
+- **THEN** group does NOT include standalone orderedListStyle button
+
+### Requirement: Icon mapping helper is available
+
+The toolbar configuration SHALL provide helper function to map list style values to icon names.
+
+#### Scenario: Decimal maps to List-numbers
+
+- **WHEN** helper function receives `"decimal"` or `null`
+- **THEN** function returns `"List-numbers"`
+
+#### Scenario: Lower-alpha maps to List-lower-alpha
+
+- **WHEN** helper function receives `"lower-alpha"`
+- **THEN** function returns `"List-lower-alpha"`
+
+#### Scenario: Lower-roman maps to List-roman
+
+- **WHEN** helper function receives `"lower-roman"`
+- **THEN** function returns `"List-roman"`
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/tasks.md
new file mode 100644
index 0000000000..8c380f7c91
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-ordered-list-split-button/tasks.md
@@ -0,0 +1,97 @@
+# Implementation Tasks
+
+## 1. Type and Config Updates
+
+- [x] 1.1 Add `icon?: string` field to `ToolbarDropdownOption` interface in `ToolbarConfig.ts`
+- [x] 1.2 Add `"splitButton"` to `ToolbarActionType` union type in `ToolbarConfig.ts`
+- [x] 1.3 Create `listHelpers.ts` with `getIconForOrderedListStyle()` function and `STYLE_ICON_MAP` constant
+
+## 2. Toolbar Configuration Changes
+
+- [x] 2.1 Update `orderedList` button config in `TOOLBAR_GROUPS` to use `action: "splitButton"`
+- [x] 2.2 Add `dropdownOptions` array to `orderedList` config with decimal, lower-alpha, lower-roman options
+- [x] 2.3 Add `icon` field to each dropdown option (List-numbers, List-lower-alpha, List-roman)
+- [x] 2.4 Add `getCurrentValue` function to `orderedList` config to return active style
+- [x] 2.5 Remove standalone `orderedListStyle` button from list group buttons array
+
+## 3. ToolbarSplitButton Component
+
+- [x] 3.1 Create `ToolbarSplitButton.tsx` component file with props interface
+- [x] 3.2 Add module-level `lastOrderedListStyle` sticky state variable
+- [x] 3.3 Implement `getCurrentIcon()` helper function using active/sticky state
+- [x] 3.4 Add component state: `isDropdownOpen`, `focusedPart` with refs for both buttons
+- [x] 3.5 Implement `useDropdown` hook integration for floating menu positioning
+- [x] 3.6 Add `useEffect` to re-render on editor `selectionUpdate` and `transaction` events
+
+## 4. Split Button Event Handlers
+
+- [x] 4.1 Implement `handleMainClick()` to toggle OL with sticky style when inactive, disable when active
+- [x] 4.2 Implement `handleDropdownClick()` to toggle dropdown open/closed state
+- [x] 4.3 Implement `handleOptionSelect()` to update sticky state, apply style, enable OL if needed, close dropdown
+- [x] 4.4 Implement `handleKeyDown()` with ArrowRight/Left focus movement between buttons
+- [x] 4.5 Add ArrowDown handling to open dropdown from either button
+- [x] 4.6 Add Enter/Space handling to execute focused part's action
+
+## 5. Split Button Render
+
+- [x] 5.1 Render container div with `role="group"` and `aria-label`
+- [x] 5.2 Render main button with dynamic icon, `aria-pressed`, and click/keydown handlers
+- [x] 5.3 Render dropdown button with chevron icon, `aria-expanded`, `aria-haspopup="menu"`
+- [x] 5.4 Conditionally render dropdown menu with Floating UI positioning when open
+- [x] 5.5 Render dropdown options with icon + text, `role="menuitem"`, active class
+- [x] 5.6 Apply `is-active` class to split button container when OL active
+
+## 6. Toolbar Factory Integration
+
+- [x] 6.1 Import `ToolbarSplitButton` component in `Toolbar.tsx`
+- [x] 6.2 Add `case "splitButton"` to `ToolbarButtonFactory` switch statement
+- [x] 6.3 Return `` in split button case
+
+## 7. Dropdown Option Icon Support
+
+- [x] 7.1 Update `ToolbarDropdown.tsx` to conditionally render icon if `option.icon` present
+- [x] 7.2 Update dropdown item layout to support icon + text (flex with gap)
+
+## 8. SCSS Styling
+
+- [x] 8.1 Add `.split-button` base styles with inline-flex layout in `Toolbar.scss`
+- [x] 8.2 Add `.split-button-main` styles with border-right separator
+- [x] 8.3 Add `.split-button-dropdown` styles with chevron rotation on `aria-expanded="true"`
+- [x] 8.4 Add `.split-button.is-active` styles for active state highlighting
+- [x] 8.5 Add focus indicator styles for both button parts (`:focus-visible`)
+- [x] 8.6 Update `.toolbar-dropdown-item` to support icon + text layout (flex with gap)
+- [x] 8.7 Add hover states for main and dropdown buttons
+
+## 9. Unit Tests
+
+- [ ] 9.1 Create `orderedListSplitButton.spec.tsx` test file
+- [ ] 9.2 Test: Split button renders with both parts and correct icons
+- [ ] 9.3 Test: Main button click toggles OL with sticky style when inactive
+- [ ] 9.4 Test: Main button click disables OL when active
+- [ ] 9.5 Test: Dropdown button opens/closes menu
+- [ ] 9.6 Test: Dropdown option selection updates sticky state and applies style
+- [ ] 9.7 Test: Dropdown option enables OL + applies style when inactive
+- [ ] 9.8 Test: Dropdown option only changes style when active
+- [ ] 9.9 Test: Icon updates based on active style
+- [ ] 9.10 Test: Icon shows sticky state when inactive
+- [ ] 9.11 Test: Sticky state persists after toggle off/on
+- [ ] 9.12 Test: Keyboard ArrowRight/Left moves focus between buttons
+- [ ] 9.13 Test: Keyboard ArrowDown opens dropdown
+- [ ] 9.14 Test: ARIA attributes (role, aria-pressed, aria-expanded, aria-haspopup)
+
+## 10. E2E Test
+
+- [ ] 10.1 Create `orderedListSplitButton.spec.js` E2E test file
+- [ ] 10.2 Test: Click dropdown, select lower-alpha, verify OL enabled with a,b,c style
+- [ ] 10.3 Test: Toggle off via main button, verify OL disabled
+- [ ] 10.4 Test: Click main button, verify OL re-enabled with lower-alpha (sticky)
+- [ ] 10.5 Test: Open dropdown, select lower-roman, verify icon updates
+- [ ] 10.6 Test: Verify dropdown accessible when OL inactive
+
+## 11. Manual Verification
+
+- [ ] 11.1 Test on desktop: Click workflow, keyboard navigation, icon updates
+- [ ] 11.2 Test on mobile/tablet: Touch targets adequate (56px combined width)
+- [ ] 11.3 Test screen reader: ARIA announcements for button states
+- [ ] 11.4 Test with multiple editor instances (if available): Verify sticky state behavior
+- [ ] 11.5 Verify visual match to Word split button pattern
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/.openspec.yaml
new file mode 100644
index 0000000000..b119b63505
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-13
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/design.md
new file mode 100644
index 0000000000..b55298d9aa
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/design.md
@@ -0,0 +1,89 @@
+## Context
+
+The rich text widget (`@mendix/rich-text-web`) runs tiptap v3. Links come from StarterKit's link mark, configured with `openOnClick: false` so clicking a link places the caret instead of navigating. Link insertion today goes through the toolbar's `DialogToolbarButton` → `LinkDialog`, which reads `getAttributes("link")` and already renders an "Edit Link" variant. There is no affordance to edit or remove an _existing_ link without reselecting it from the toolbar.
+
+`@tiptap/react/menus` exposes `BubbleMenu` (backed by `@tiptap/extension-bubble-menu@3.27.1`, already resolved transitively). `BubbleMenu` manages its own floating position via a ProseMirror plugin and accepts `editor`, `shouldShow`, `pluginKey`, `updateDelay`, plus standard div attributes. `shouldShow` receives `{ editor, element, view, state, from, to }`.
+
+Reference: tiptap menus docs — https://tiptap.dev/docs/examples/advanced/menus
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Contextual Edit/Remove menu that appears on any link in an editable editor.
+- Reuse the existing `LinkDialog` for editing (prefilled, in-place update).
+- Correct behavior from a bare caret inside a link (no selection required).
+
+**Non-Goals:**
+
+- No change to toolbar link insertion.
+- No new bubble menus for other marks (bold, image, etc.) — link only.
+- No new npm dependency.
+
+## Decisions
+
+### Render `LinkBubbleMenu` inside `EditorInner`
+
+The bubble menu lives as a sibling to `EditorContent`, within the existing `EditorContextProvider`, so it can read `editor` via `useCurrentEditor()`. Buttons reuse `ToolbarDefaultButton`.
+
+```tsx
+
+
+
+;
+{
+ isEditing && ;
+}
+```
+
+### `shouldShow` = active link, editable, not editing
+
+```ts
+shouldShow = ({ editor }) => editor.isEditable && editor.isActive("link") && !isEditing;
+```
+
+Covers three spec requirements at once: appears on link, hidden off-link, hidden when read-only, and suppressed while the dialog is open (single floating layer).
+
+_Alternative considered:_ require a non-empty selection over the link. Rejected — less discoverable; the caret-inside case is the common one.
+
+### Full-range selection before Edit and Remove
+
+Both actions first run `editor.chain().focus().extendMarkRange("link")`. This is the crux fix:
+
+- **Edit:** `LinkDialog`'s submit branches on `selectedText`. With a bare caret, `selectedText === ""` → it takes the "insert new text" branch and **duplicates** the link. Selecting the whole link range first populates `selectedText` → correct "apply to existing selection" branch.
+- **Remove:** without `extendMarkRange`, `unsetLink()` only clears from the caret; the rest of the link survives. Extending first strips the entire link.
+
+```ts
+removeLink = () => editor.chain().focus().extendMarkRange("link").unsetLink().run();
+openEdit = () => {
+ editor.chain().focus().extendMarkRange("link").run();
+ setLinkEl(resolveLinkEl());
+ setIsEditing(true);
+};
+```
+
+### Anchor the dialog to the link DOM element
+
+`LinkDialog` takes a `referenceElement`. Anchoring to the actual ``/`.tiptap-link` element (resolved from `editor.view.domAtPos(selection.from)`, walking up to the nearest anchor) keeps the dialog positioned even after the bubble menu hides on focus loss, and reads as "editing _this_ link."
+
+_Alternative considered:_ anchor to the bubble menu container ref. Rejected — the bubble hides when the editor blurs (dialog input steals focus), orphaning the dialog.
+
+## Risks / Trade-offs
+
+- **Icon names unknown** → the icon font names for edit/trash must be confirmed against the existing icon set (config uses names like `Text-bold`). Resolve during implementation; pick the closest existing glyph.
+- **Focus loss hides the bubble mid-interaction** → mitigated by anchoring the dialog to the link element (not the bubble) and by `!isEditing` in `shouldShow`, so the dialog owns the interaction once open.
+- **KeyboardNavigation extension** intercepts wrapper/toolbar keys → verify Tab/Escape inside the bubble buttons and dialog don't conflict; the bubble buttons are plain `ToolbarDefaultButton`s so Escape/click-outside close is handled by `LinkDialog`'s `useDropdown`.
+- **`extendMarkRange` changes the user's selection** → acceptable and expected; the caret ends on the whole link, which is the intent for both edit and remove.
+
+## Migration Plan
+
+1. Add `LinkBubbleMenu.tsx` (menu + edit/remove + link-element resolution + dialog).
+2. Render it in `EditorInner` inside `tiptap-wrapper`.
+3. Add `.link-bubble-menu` container styling; reuse toolbar button styles.
+4. Unit-test the edit/remove commands and `shouldShow` gating; manual check in Studio Pro.
+
+Rollback: remove the render in `EditorInner`; the new file becomes dead and can be deleted.
+
+## Open Questions
+
+- Exact icon glyph names for Edit and Remove — resolve against the shipped icon font during implementation.
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/proposal.md
new file mode 100644
index 0000000000..6bd926ad0d
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/proposal.md
@@ -0,0 +1,32 @@
+## Why
+
+Today a user can insert a link via the toolbar, but there is no quick way to edit or remove an _existing_ link — they must reselect the text and reopen the toolbar dialog, and the toolbar's link dialog even duplicates the link when invoked on a bare cursor inside a link. A contextual bubble menu that appears on any link gives an in-place Edit/Remove affordance, matching the standard rich text editor UX.
+
+## What Changes
+
+- Add a `LinkBubbleMenu` component using tiptap's `BubbleMenu` from `@tiptap/react/menus` (already resolved transitively — no new dependency).
+- The menu appears whenever the caret is inside or a selection covers a link (`editor.isActive("link")`) and the editor is editable.
+- Menu contains two `ToolbarDefaultButton` controls:
+ - **Edit** — selects the whole link (`extendMarkRange("link")`) then opens the existing `LinkDialog`, prefilled, anchored to the link's DOM element.
+ - **Remove** — `extendMarkRange("link").unsetLink()` to strip the link across its full range, turning it back into normal text.
+- While the dialog is open, the bubble menu is suppressed (`shouldShow` returns false during editing) so only one floating layer shows at a time.
+- Render `LinkBubbleMenu` inside `EditorInner`, as a sibling to `EditorContent`, within the existing `EditorContextProvider`.
+
+## Capabilities
+
+### New Capabilities
+
+- `rich-text-link-bubble-menu`: Contextual bubble menu shown on links, offering in-place Edit and Remove actions, including the full-link selection behavior that keeps editing and removal correct regardless of caret position.
+
+### Modified Capabilities
+
+
+
+## Impact
+
+- Package: `@mendix/rich-text-web`
+- New file: `src/components/LinkBubbleMenu.tsx`
+- Modified: `src/components/Editor.tsx` (`EditorInner` renders the bubble menu)
+- Reuses: `LinkDialog` (existing edit path), `ToolbarDefaultButton`, `useCurrentEditor`.
+- Styling: new `.link-bubble-menu` container class; reuses existing toolbar button styles.
+- No XML, no runtime API change, no new dependency.
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/specs/rich-text-link-bubble-menu/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/specs/rich-text-link-bubble-menu/spec.md
new file mode 100644
index 0000000000..598d67d1a8
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/specs/rich-text-link-bubble-menu/spec.md
@@ -0,0 +1,58 @@
+## ADDED Requirements
+
+### Requirement: Contextual link bubble menu
+
+The rich text editor SHALL display a floating bubble menu whenever the caret is inside a link or a selection covers a link, and the editor is editable. The menu SHALL contain an Edit action and a Remove action.
+
+#### Scenario: Menu appears on a link
+
+- **WHEN** the caret is placed inside link text in an editable editor
+- **THEN** a bubble menu with Edit and Remove buttons appears anchored to the link
+
+#### Scenario: Menu hidden away from links
+
+- **WHEN** the caret is in text that is not part of a link
+- **THEN** the bubble menu is not shown
+
+#### Scenario: Menu suppressed when not editable
+
+- **WHEN** the editor is read-only
+- **THEN** the bubble menu is not shown even if the caret is inside a link
+
+### Requirement: Edit an existing link
+
+The Edit action SHALL select the entire link range before opening the link dialog, so the dialog is prefilled with the link's current attributes and updates the existing link in place rather than inserting a duplicate.
+
+#### Scenario: Edit from a bare caret inside a link
+
+- **WHEN** the caret is inside a link with no text selected and the user activates Edit
+- **THEN** the full link range is selected and the link dialog opens prefilled with the current URL, text, title, and target
+- **AND** submitting the dialog updates the existing link without duplicating its text
+
+#### Scenario: Dialog anchored to the link
+
+- **WHEN** the Edit action opens the link dialog
+- **THEN** the dialog is positioned relative to the link's DOM element and remains positioned there while editing
+
+### Requirement: Remove a link
+
+The Remove action SHALL strip the link mark across its entire range, converting the linked text back into normal text.
+
+#### Scenario: Remove from a bare caret inside a link
+
+- **WHEN** the caret is inside a link with no text selected and the user activates Remove
+- **THEN** the link mark is removed from the whole link range and the text remains as plain text
+
+### Requirement: Single floating layer while editing
+
+While the link dialog is open, the bubble menu SHALL be suppressed so that only one floating layer is visible at a time.
+
+#### Scenario: Bubble hides during edit
+
+- **WHEN** the link dialog is open via the Edit action
+- **THEN** the bubble menu is not shown
+
+#### Scenario: Bubble returns after editing
+
+- **WHEN** the link dialog is closed and the caret is still inside a link
+- **THEN** the bubble menu reappears
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/tasks.md
new file mode 100644
index 0000000000..d19c8cc7b8
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-add-link-bubble-menu/tasks.md
@@ -0,0 +1,30 @@
+## 1. Create the bubble menu component
+
+- [x] 1.1 Add `src/components/LinkBubbleMenu.tsx` importing `BubbleMenu` from `@tiptap/react/menus`, reading `editor` via `useCurrentEditor()`
+- [x] 1.2 Add local `isEditing` state and a `linkEl` state for the resolved link DOM element
+- [x] 1.3 Implement `shouldShow = ({ editor }) => editor.isEditable && editor.isActive("link") && !isEditing`
+- [x] 1.4 Render `` wrapping two `ToolbarDefaultButton`s (Edit, Remove) in a `.link-bubble-menu` container
+
+## 2. Wire the actions
+
+- [x] 2.1 `removeLink`: `editor.chain().focus().extendMarkRange("link").unsetLink().run()`
+- [x] 2.2 `resolveLinkEl`: from `editor.view.domAtPos(editor.state.selection.from)`, walk up to nearest `` / `.tiptap-link` element
+- [x] 2.3 `openEdit`: run `extendMarkRange("link")`, set `linkEl` from `resolveLinkEl`, set `isEditing = true`
+- [x] 2.4 Render `{isEditing && setIsEditing(false)} />}`
+- [x] 2.5 Choose icons from the shipped font (`src/ui/RichTextIcons.scss`) — no pencil glyph exists; use `Hyperlink` for Edit and `Erase` (or `Delete`) for Remove, or add a new glyph if a dedicated edit icon is wanted
+
+## 3. Integrate into the editor
+
+- [x] 3.1 Render `` inside `EditorInner` in `Editor.tsx`, as a sibling to `EditorContent` within `tiptap-wrapper`
+- [x] 3.2 Confirm it only mounts when not in code view (bubble is irrelevant in the HTML code editor)
+
+## 4. Styling
+
+- [x] 4.1 Add `.link-bubble-menu` container style (inline-flex, padding, background, shadow); reuse existing toolbar `.icon-button` styles for the buttons
+
+## 5. Verify
+
+- [x] 5.1 Unit test: `shouldShow` returns true on active link + editable, false off-link, false when read-only, false while editing
+- [x] 5.2 Unit test: Remove strips the whole link from a bare caret; Edit selects the full range so `LinkDialog` prefills and updates in place (no duplicate)
+- [x] 5.3 Run package unit tests (Jest + RTL) — all pass
+- [ ] 5.4 Manual check in Studio Pro (`pnpm start` with `MX_PROJECT_PATH`): click a link → bubble shows → Edit prefills + updates, Remove unlinks; bubble hidden while dialog open and in read-only mode
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-consolidate-toolbar-button/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-consolidate-toolbar-button/.openspec.yaml
new file mode 100644
index 0000000000..8803b473ec
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-consolidate-toolbar-button/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-12
diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-consolidate-toolbar-button/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-consolidate-toolbar-button/design.md
new file mode 100644
index 0000000000..6c7971da89
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-13-consolidate-toolbar-button/design.md
@@ -0,0 +1,93 @@
+## Context
+
+The rich text toolbar (`@mendix/rich-text-web`) renders every control through `ToolbarButtonFactory` in `Toolbar.tsx`, which dispatches to 7 components. Eight `