Skip to content

feat(editor): inspector#3107

Draft
gabrielmfern wants to merge 33 commits intocanaryfrom
feat/global-inspector
Draft

feat(editor): inspector#3107
gabrielmfern wants to merge 33 commits intocanaryfrom
feat/global-inspector

Conversation

@gabrielmfern
Copy link
Copy Markdown
Member

@gabrielmfern gabrielmfern commented Mar 24, 2026


Summary by cubic

Adds a theme-aware Inspector to edit document, text, and node styles with unit-aware controls and document color presets. Exposes a simple, render‑props API and ships minimal CSS plus a new example.

  • New Features

    • Inspector API: Inspector.Provider, Inspector.Document, and render‑props Inspector.Breadcrumb with pathFromRoot; exported from @react-email/editor/ui.
    • Document inspector: grouped sections for background, text, size, padding, and border; number inputs with drag-to-change; native color picker with auto-extracted document colors.
    • Rich controls: per-side padding/border pickers, border-radius editor, attributes, link, and social links sections; minimal layout CSS in @react-email/editor/styles/inspector.css (also imported by the default theme).
    • Examples: adds examples/document-inspector.tsx to the examples app.
  • Refactors

    • Event bus: added useEvent(eventName, handler, options) for React subscriptions.
    • Types: consolidated event map via module augmentation; core types re-exported from @react-email/editor/core; removed legacy utils types.
    • Email theming: body background color now applies directly to the editor DOM; internal theming types and exports cleaned up.

Written for commit ca59250. Summary will update on new commits.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 24, 2026

⚠️ No Changeset found

Latest commit: ca59250

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Mar 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-email Ready Ready Preview, Comment Mar 30, 2026 9:10pm
react-email-demo Ready Ready Preview, Comment Mar 30, 2026 9:10pm
react-email-examples Ready Ready Preview, Comment Mar 30, 2026 9:10pm

Request Review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 24, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@react-email/editor@3107

commit: dbfccbe

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 issues found across 59 files

Confidence score: 3/5

  • There is concrete regression risk in packages/editor/src/ui/inspector/sections/devtools.tsx: selecting the Template item can throw on JSON.parse, which can break the devtools panel in normal use.
  • Input/state handling issues in packages/editor/src/ui/inspector/components/use-drag-to-change.ts and packages/editor/src/ui/inspector/sections/fallback.tsx can leave stale drag UI state or dispatch fallback updates with mismatched keys during variableId changes.
  • Several medium-severity value-parsing/unit issues (packages/editor/src/ui/inspector/components/use-numeric-input.ts, packages/editor/src/ui/inspector/inspector/utils/resolve-theme-defaults.ts, packages/editor/src/ui/inspector/inspector/utils/parse-attributes.ts) may silently alter edited CSS values, so this is not fully low-risk yet.
  • Pay close attention to packages/editor/src/ui/inspector/sections/devtools.tsx, packages/editor/src/ui/inspector/components/use-drag-to-change.ts, and packages/editor/src/ui/inspector/sections/fallback.tsx - they carry the highest user-facing breakage risk and state consistency concerns.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/editor/src/ui/inspector/components/use-numeric-input.ts">

<violation number="1" location="packages/editor/src/ui/inspector/components/use-numeric-input.ts:106">
P2: `Number(displayValue) || fallbackValue` treats 0 as empty, so ArrowUp/Down will jump from 0 to the fallback value. Use an explicit empty/NaN check so 0 stays 0.</violation>
</file>

<file name="packages/editor/src/ui/inspector/components/border-radius-picker.tsx">

<violation number="1" location="packages/editor/src/ui/inspector/components/border-radius-picker.tsx:256">
P2: Use `parseFloat` instead of `parseInt` so fractional border-radius values are preserved.</violation>
</file>

<file name="packages/editor/src/ui/inspector/components/border-picker.tsx">

<violation number="1" location="packages/editor/src/ui/inspector/components/border-picker.tsx:73">
P2: `expanded` is derived from `styleObject` only on mount, so changes to `styleObject` won’t update the UI mode. Sync this state when `styleObject` changes or derive it directly to keep the inspector consistent when switching elements.</violation>
</file>

<file name="packages/editor/src/ui/inspector/sections/devtools.tsx">

<violation number="1" location="packages/editor/src/ui/inspector/sections/devtools.tsx:29">
P2: Guard the "empty" option before calling JSON.parse; otherwise selecting the Template item throws and breaks the devtools panel.</violation>
</file>

<file name="packages/editor/src/ui/inspector/sections/attributes.tsx">

<violation number="1" location="packages/editor/src/ui/inspector/sections/attributes.tsx:27">
P2: The Label uses htmlFor={input.prop} but none of the rendered controls set id={input.prop}, so the label isn’t associated with the input. Add matching ids (and update NumberInput to accept/pass one) so label click/AT works.</violation>
</file>

<file name="packages/editor/src/ui/inspector/USAGE.md">

<violation number="1" location="packages/editor/src/ui/inspector/USAGE.md:63">
P3: Close the JSX comment with `}` so the breadcrumb example is valid JSX.</violation>
</file>

<file name="packages/editor/src/ui/inspector/components/use-drag-to-change.ts">

<violation number="1" location="packages/editor/src/ui/inspector/components/use-drag-to-change.ts:68">
P2: Handle pointer cancellation as well as pointer up so drag state and global cursor/user-select styles are always reset.</violation>
</file>

<file name="packages/editor/src/ui/inspector/inspector/utils/resolve-theme-defaults.ts">

<violation number="1" location="packages/editor/src/ui/inspector/inspector/utils/resolve-theme-defaults.ts:51">
P2: Percentage values are returned even when the target unit is `px`, so percent-based padding/border styles will be misinterpreted as pixels. Return an empty string (or otherwise skip) when targetUnit is `px`.</violation>
</file>

<file name="packages/editor/src/ui/inspector/inspector/utils/parse-attributes.ts">

<violation number="1" location="packages/editor/src/ui/inspector/inspector/utils/parse-attributes.ts:106">
P2: Defaulting unitless numeric CSS values to "px" will rewrite unitless styles (like line-height/opacity) as pixel values when the user edits them. Preserve the absence of a unit so edits round-trip correctly.</violation>
</file>

<file name="packages/editor/src/ui/inspector/sections/fallback.tsx">

<violation number="1" location="packages/editor/src/ui/inspector/sections/fallback.tsx:64">
P2: The ref-update `useEffect` runs before the cleanup effect, so on `variableId` changes the cleanup dispatch can use the *new* variableKey/displayKey while comparing against the previous fallback. This can send fallback updates to the wrong variable. Move the ref update below the cleanup effect (or otherwise ensure cleanup reads the previous variable context).</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const current = Number(displayValue) || fallbackValue || 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Number(displayValue) || fallbackValue treats 0 as empty, so ArrowUp/Down will jump from 0 to the fallback value. Use an explicit empty/NaN check so 0 stays 0.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/components/use-numeric-input.ts, line 106:

<comment>`Number(displayValue) || fallbackValue` treats 0 as empty, so ArrowUp/Down will jump from 0 to the fallback value. Use an explicit empty/NaN check so 0 stays 0.</comment>

<file context>
@@ -0,0 +1,119 @@
+      if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
+        e.preventDefault();
+        const step = e.shiftKey ? 10 : 1;
+        const current = Number(displayValue) || fallbackValue || 0;
+        const next = Math.max(
+          min ?? -Infinity,
</file context>


const [topLeft, topRight, bottomRight, bottomLeft] = value
.split(' ')
.map((v) => parseInt(v, 10));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use parseFloat instead of parseInt so fractional border-radius values are preserved.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/components/border-radius-picker.tsx, line 256:

<comment>Use `parseFloat` instead of `parseInt` so fractional border-radius values are preserved.</comment>

<file context>
@@ -0,0 +1,301 @@
+
+  const [topLeft, topRight, bottomRight, bottomLeft] = value
+    .split(' ')
+    .map((v) => parseInt(v, 10));
+
+  return {
</file context>

onChange,
presetColors,
}: BorderPickerProps) {
const [expanded, setExpanded] = React.useState(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: expanded is derived from styleObject only on mount, so changes to styleObject won’t update the UI mode. Sync this state when styleObject changes or derive it directly to keep the inspector consistent when switching elements.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/components/border-picker.tsx, line 73:

<comment>`expanded` is derived from `styleObject` only on mount, so changes to `styleObject` won’t update the UI mode. Sync this state when `styleObject` changes or derive it directly to keep the inspector consistent when switching elements.</comment>

<file context>
@@ -0,0 +1,370 @@
+  onChange,
+  presetColors,
+}: BorderPickerProps) {
+  const [expanded, setExpanded] = React.useState(
+    () => !allSidesEqual(styleObject),
+  );
</file context>

// Defer to avoid flushSync during React render cycle
queueMicrotask(() => {
editor.commands.clearContent();
editor.commands.setContent(JSON.parse(newValue));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Guard the "empty" option before calling JSON.parse; otherwise selecting the Template item throws and breaks the devtools panel.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/sections/devtools.tsx, line 29:

<comment>Guard the "empty" option before calling JSON.parse; otherwise selecting the Template item throws and breaks the devtools panel.</comment>

<file context>
@@ -0,0 +1,49 @@
+            // Defer to avoid flushSync during React render cycle
+            queueMicrotask(() => {
+              editor.commands.clearContent();
+              editor.commands.setContent(JSON.parse(newValue));
+            });
+          }}
</file context>

<Section title="Attributes">
{inputs.map((input) => (
<PropRow key={input.prop}>
<Label title={input.label} className="truncate" htmlFor={input.prop}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Label uses htmlFor={input.prop} but none of the rendered controls set id={input.prop}, so the label isn’t associated with the input. Add matching ids (and update NumberInput to accept/pass one) so label click/AT works.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/sections/attributes.tsx, line 27:

<comment>The Label uses htmlFor={input.prop} but none of the rendered controls set id={input.prop}, so the label isn’t associated with the input. Add matching ids (and update NumberInput to accept/pass one) so label click/AT works.</comment>

<file context>
@@ -0,0 +1,94 @@
+    <Section title="Attributes">
+      {inputs.map((input) => (
+        <PropRow key={input.prop}>
+          <Label title={input.label} className="truncate" htmlFor={input.prop}>
+            {input.label}
+          </Label>
</file context>

dragProps: {
onPointerDown,
onPointerMove,
onPointerUp,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Handle pointer cancellation as well as pointer up so drag state and global cursor/user-select styles are always reset.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/components/use-drag-to-change.ts, line 68:

<comment>Handle pointer cancellation as well as pointer up so drag state and global cursor/user-select styles are always reset.</comment>

<file context>
@@ -0,0 +1,72 @@
+    dragProps: {
+      onPointerDown,
+      onPointerMove,
+      onPointerUp,
+      style: { cursor: 'ew-resize' } as React.CSSProperties,
+    },
</file context>

return Math.round(parsed.numeric * bodyFontSize);
}

if (parsed.unit === '%') {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Percentage values are returned even when the target unit is px, so percent-based padding/border styles will be misinterpreted as pixels. Return an empty string (or otherwise skip) when targetUnit is px.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/inspector/utils/resolve-theme-defaults.ts, line 51:

<comment>Percentage values are returned even when the target unit is `px`, so percent-based padding/border styles will be misinterpreted as pixels. Return an empty string (or otherwise skip) when targetUnit is `px`.</comment>

<file context>
@@ -0,0 +1,182 @@
+    return Math.round(parsed.numeric * bodyFontSize);
+  }
+
+  if (parsed.unit === '%') {
+    return targetUnit === '%' ? parsed.numeric : parsed.numeric;
+  }
</file context>

// Detect number with unit (e.g., "20px", "1.5em", "100%")
const numericMatch = strValue.match(/^(-?\d*\.?\d+)(px|%|em|rem|vh|vw)?$/);
if (numericMatch) {
const detectedUnit = numericMatch[2] || 'px';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Defaulting unitless numeric CSS values to "px" will rewrite unitless styles (like line-height/opacity) as pixel values when the user edits them. Preserve the absence of a unit so edits round-trip correctly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/inspector/utils/parse-attributes.ts, line 106:

<comment>Defaulting unitless numeric CSS values to "px" will rewrite unitless styles (like line-height/opacity) as pixel values when the user edits them. Preserve the absence of a unit so edits round-trip correctly.</comment>

<file context>
@@ -0,0 +1,124 @@
+  // Detect number with unit (e.g., "20px", "1.5em", "100%")
+  const numericMatch = strValue.match(/^(-?\d*\.?\d+)(px|%|em|rem|vh|vw)?$/);
+  if (numericMatch) {
+    const detectedUnit = numericMatch[2] || 'px';
+    return {
+      label: humanizeString(prop),
</file context>

(variableItem?.type as VariableType) ?? 'string';
const variableType = localType ?? serverType;

React.useEffect(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The ref-update useEffect runs before the cleanup effect, so on variableId changes the cleanup dispatch can use the new variableKey/displayKey while comparing against the previous fallback. This can send fallback updates to the wrong variable. Move the ref update below the cleanup effect (or otherwise ensure cleanup reads the previous variable context).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/sections/fallback.tsx, line 64:

<comment>The ref-update `useEffect` runs before the cleanup effect, so on `variableId` changes the cleanup dispatch can use the *new* variableKey/displayKey while comparing against the previous fallback. This can send fallback updates to the wrong variable. Move the ref update below the cleanup effect (or otherwise ensure cleanup reads the previous variable context).</comment>

<file context>
@@ -0,0 +1,268 @@
+    (variableItem?.type as VariableType) ?? 'string';
+  const variableType = localType ?? serverType;
+
+  React.useEffect(() => {
+    setLocalType(null);
+  }, [variableId]);
</file context>

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/editor/src/ui/inspector/USAGE.md">

<violation number="1" location="packages/editor/src/ui/inspector/USAGE.md:47">
P2: The docs example uses `<Inspector>` as a component, but the exported API only supports `Inspector.Root`, `Inspector.Panel`, `Inspector.Breadcrumb`, and `Inspector.DefaultField`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/editor/src/ui/inspector/explorations.md">

<violation number="1" location="packages/editor/src/ui/inspector/explorations.md:59">
P2: The example callback parameter was renamed to `context`, but the body still uses `target` as a standalone variable. Update the snippet to read `context.target` (or destructure `target`) so the documented API example is valid.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


```tsx
<Inspector>{(
context:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The example callback parameter was renamed to context, but the body still uses target as a standalone variable. Update the snippet to read context.target (or destructure target) so the documented API example is valid.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/ui/inspector/explorations.md, line 59:

<comment>The example callback parameter was renamed to `context`, but the body still uses `target` as a standalone variable. Update the snippet to read `context.target` (or destructure `target`) so the documented API example is valid.</comment>

<file context>
@@ -56,8 +56,18 @@ and also give a good DX:
 <Inspector>{(
-  target: 'doc' | 'text' | FocusedNode, 
-  setAttribute: (name: string, value: unknown) => void
+  context: 
+    | {
+      target: 'doc',
</file context>

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/editor/src/ui/inspector/document.tsx">

<violation number="1" location="packages/editor/src/ui/inspector/document.tsx:248">
P1: Missing fallback return causes this component to return `undefined` when `inspectorTarget !== 'doc'`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/editor/examples/src/examples/document-inspector.tsx">

<violation number="1" location="packages/editor/examples/src/examples/document-inspector.tsx:224">
P2: Using `label` as the input `id` creates invalid and non-unique DOM ids; generate a stable unique id per row instead of reusing display text.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@socket-security
Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedhtml-to-ast@​0.0.6831009980100
Addedhtml-to-text@​9.0.59910010082100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants