diff --git a/DECISION-LOGS.md b/DECISION-LOGS.md index 0a7acbd25..a6e414eaf 100644 --- a/DECISION-LOGS.md +++ b/DECISION-LOGS.md @@ -24,6 +24,7 @@ - _07.08.2026_: [Keep the postcss box-sizing plugin over lint-based or selector-based alternatives](./packages/ui/postcss-box-sizing.decision-log.md) - _24.08.2026_: [`incomplete` as a third terminal state, distinct from `failed` and from a stall](./packages/execution-core/terminal-states.decision-log.md) - _31.08.2026_: [Ship common font faces inline and the rest as assets](./packages/ui/font-assets.decision-log.md) -- _07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint), 17.09.2026 (ports), 21.09.2026 (outcome)_: [Decision request as versioned data on a node](./apps/backend/decision-request.decision-log.md) +- _07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint), 17.09.2026 (ports), 21.09.2026 (outcome), 29.09.2026 (failure policy, edit shape)_: [Decision request as versioned data on a node](./apps/backend/decision-request.decision-log.md) - _07.09.2026 (revised 14.09.2026, 15.09.2026 and 16.09.2026)_: [Derive the ConnectableItem width from the real container insets](./packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item-width.decision-log.md) - _08.09.2026 (pause), 21.09.2026 (outcome)_: [Durable pause, the Temporal side of the human-in-the-loop seam](./packages/temporal/src/workflow/durable-pause.decision-log.md) +- _01.10.2026_: [One model for the built-in interface: state, actions, builtInControls, …Content, …MenuItem](./packages/sdk/src/features/ui-extensions/ui-extensions.decision-log.md) diff --git a/apps/demo/CLAUDE.md b/apps/demo/CLAUDE.md index 60cdddbf9..b58dc10b2 100644 --- a/apps/demo/CLAUDE.md +++ b/apps/demo/CLAUDE.md @@ -27,14 +27,16 @@ Nodes are registered in `src/app/data/palette.ts` (flat array of `PaletteItem`). Plugins live in `src/app/plugins//` and register via SDK decorators inside `/plugin-exports.ts`: -| Decorator | Purpose | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `registerComponentDecorator(name, opts)` | Inject UI or hooks into a known slot (`OptionalHooks`, `OptionalAppChildren`, `OptionalNodeContent`, `OptionalAppBarTools`, …) | -| `registerFunctionDecorator(name, opts)` | Intercept a registered function (e.g. `getPaletteData`, `getTemplates`) before/after | -| `registerPluginTranslation(resource)` | Add i18next strings under the `plugins.` namespace | +| Decorator | Purpose | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `registerComponentDecorator(name, opts)` | Inject UI or hooks into a known slot (`OptionalHooks`, `OptionalAppChildren`, `OptionalNodeContent`, `OptionalEdgeProperties`, …) | +| `registerFunctionDecorator(name, opts)` | Intercept a registered function (e.g. `getPaletteData`, `getTemplates`) before/after | +| `registerPluginTranslation(resource)` | Add i18next strings under the `plugins.` namespace | Plugin signature is `() => void` and the function runs once on first mount of `` (invoked synchronously inside a `useRef`-guarded first-render branch; deduplicated by `name` on strict-mode double-invoke). +To add UI to the app bar, the palette or the properties panel, prefer the `…Content` / `…MenuItem` components and `builtInControls` over a decorator - see the SDK's [Built-in interface: hide, move, add](https://www.workflowbuilder.io/docs/guides/build-a-plugin/#built-in-interface-hide-move-add). The slots `OptionalAppBarTools`, `OptionalAppBarControls` and `OptionalFooterContent`, and the `getControlsDotsItems` function decorator, still work but are deprecated in favor of that model. + Reference plugins: - `flow-runner/` — in-browser execution via callable logic registry (Zustand) diff --git a/apps/demo/src/app/plugins/README.md b/apps/demo/src/app/plugins/README.md index bf4804a7b..2af8fae6d 100644 --- a/apps/demo/src/app/plugins/README.md +++ b/apps/demo/src/app/plugins/README.md @@ -24,23 +24,53 @@ You can compare it with your local version. If you want to see how the plugin logic works in a smaller example, we have prepared a showcase repository: https://github.com/synergycodes/optional-plugins-demo +> **Note:** `OptionalAppBarTools`, `OptionalAppBarControls` and `OptionalFooterContent` (used as example slots in some of the sections below) are deprecated in favor of the SDK's `…Content` components (`AppBarToolsContent`, `AppBarControlsContent`, `PaletteFooterContent`, …) and `builtInControls`. They keep working. See [Built-in interface: hide, move, add](https://www.workflowbuilder.io/docs/guides/build-a-plugin/#built-in-interface-hide-move-add) for the current way to add UI to the app bar, the palette or the properties panel. + ## How to add plugin? 1. Create your plugin directory, for example: `plugins/example`. -2. Add your `` to `plugins/example/components/example-component.tsx`. -3. In `plugins/example/plugin-exports` import dependencies and add: +2. Add your `` to `plugins/example/components/example-component.tsx`: + +```tsx +export function ExampleComponent() { + return ; +} +``` + +3. Add `` to `plugins/example/components/example-controls/example-controls.tsx`, placing `ExampleComponent` where you want it to show up: + +```tsx +import { PaletteFooterContent } from '@workflowbuilder/sdk'; + +import { ExampleComponent } from '../example-component'; + +export function ExampleControls() { + return ( + + + + ); +} +``` + +4. In `plugins/example/plugin-exports.ts`, mount it through `OptionalAppChildren` (a plugin file is `.ts`, so the JSX itself lives in the `.tsx` components above): ```ts -registerComponentDecorator('OptionalFooterContent', { - content: ExampleComponent, - place: 'after', -}); +import { registerComponentDecorator } from '@workflowbuilder/sdk'; + +import { ExampleControls } from './components/example-controls/example-controls'; + +export function plugin(): void { + registerComponentDecorator('OptionalAppChildren', { + content: ExampleControls, + }); +} ``` -4. Import your plugin to `apps/demo/src/app/features/plugins-core/index.ts` with line `import '@/plugins/example/plugin-exports';` -5. Refresh the page. +5. Import your plugin into `apps/demo/src/app/app.tsx` and add it to the `plugins` array passed to ``, the same way the existing plugins there are wired in: `import { plugin as examplePlugin } from './plugins/example/plugin-exports';`, then add `examplePlugin` to the `plugins={[...]}` list. +6. Refresh the page. -Your component should now be displayed in the left sidebar (palette) footer. +Your component should now be displayed in the left sidebar (palette) footer, before the built-in Templates button. ### How to remove plugin? @@ -48,7 +78,7 @@ Simply remove the folder of your plugin and restart the application. It will sti #### How to remove "Fallback used for missing plugin..."? -Go to `apps/demo/src/app/features/plugins-core/index.ts` and remove the import there. +Go to `apps/demo/src/app/app.tsx` and remove the plugin's import and its entry from the `plugins` array. ### How do I change the position of a plugin? @@ -135,4 +165,4 @@ if (hasRegisteredComponentDecorator('OptionalAppBarControls', 'TreeButton')) { } ``` -The order in which plugins are imported in @features/plugins-core/index is important. +The order in which plugins are listed in `app.tsx`'s `plugins` array is important. diff --git a/apps/demo/src/app/plugins/help/README.md b/apps/demo/src/app/plugins/help/README.md index 0adf94dd0..e265fc0f5 100644 --- a/apps/demo/src/app/plugins/help/README.md +++ b/apps/demo/src/app/plugins/help/README.md @@ -6,4 +6,4 @@ Removal of this plugin will result in an application without the watermark in th The application can run without this folder by using stub files. -If you want to remove references to the stubs, you can also delete the line where it was imported from: `apps/demo/src/app/features/plugins-core/index.ts` +If you want to remove references to the stubs, you can also delete the line where it is registered: `helpPlugin` in the `plugins` array of `apps/demo/src/app/app.tsx`, together with its import. diff --git a/apps/docs/src/content/docs/guides/build-a-plugin.md b/apps/docs/src/content/docs/guides/build-a-plugin.md index 0722cf192..a173913e6 100644 --- a/apps/docs/src/content/docs/guides/build-a-plugin.md +++ b/apps/docs/src/content/docs/guides/build-a-plugin.md @@ -21,8 +21,8 @@ Most plugins combine multiple registrations. Wrap them in a function and pass vi ```tsx const myPlugin: WorkflowBuilderPlugin = () => { - registerComponentDecorator('OptionalAppBarControls', { - content: MyButton, + registerComponentDecorator('OptionalAppChildren', { + content: MyWidget, name: 'my-plugin', }); registerFunctionDecorator('trackFutureChange', { @@ -72,30 +72,43 @@ type ComponentDecoratorOptions

= ### Available slots -| Slot name | Where it renders | -| ------------------------ | --------------------------------------- | -| `OptionalAppBarControls` | App bar — control buttons area | -| `OptionalAppBarTools` | App bar — toolbar area | -| `OptionalAppChildren` | App-level children (portals, providers) | -| `OptionalEdgeProperties` | Edge properties panel | -| `OptionalFooterContent` | Footer area | -| `OptionalHooks` | Invisible provider/hook slot | -| `OptionalNodeContent` | Inside nodes (receives `nodeId` prop) | +| Slot name | Where it renders | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `OptionalAppBarControls` | **Deprecated.** App bar, control buttons area. Use [`AppBarControlsContent`](#built-in-interface-hide-move-add). | +| `OptionalAppBarTools` | **Deprecated.** App bar, toolbar area. Use [`AppBarToolsContent`](#built-in-interface-hide-move-add). | +| `OptionalAppChildren` | App-level children (portals, providers) | +| `OptionalEdgeProperties` | Edge properties panel | +| `OptionalFooterContent` | **Deprecated.** Palette footer. Use [`PaletteFooterContent`](#built-in-interface-hide-move-add). | +| `OptionalHooks` | Invisible provider/hook slot | +| `OptionalNodeContent` | Inside nodes (receives `nodeId` prop) | + +Deprecated slots keep working; removal comes in a later major. The same goes for `PropertiesPanelFooter`, the 3.0.0 name of `PropertiesPanelFooterContent`. See [Built-in interface: hide, move, add](#built-in-interface-hide-move-add) for the app bar, the palette and the properties panel. ### Example ```tsx -import { registerComponentDecorator } from '@workflowbuilder/sdk'; +import { AppBarControlsContent, registerComponentDecorator } from '@workflowbuilder/sdk'; +import { Button } from '@workflowbuilder/ui'; -import { MyCustomButton } from './my-custom-button'; - -registerComponentDecorator('OptionalAppBarControls', { +registerComponentDecorator('OptionalAppChildren', { content: MyCustomButton, name: 'MyPlugin', priority: 10, // shown before decorators with lower priority }); + +function MyCustomButton() { + return ( + + + + ); +} ``` +This decorator just mounts `MyCustomButton` once, as the model's ["A plugin that adds UI"](#a-plugin-that-adds-ui) section below describes; `AppBarControlsContent` inside it is what actually places the button in the app bar. + ### Typing `modifyProps` Pass the slot's props type as the type parameter so `modifyProps` is checked against the actual host's prop shape. Slots that target a built-in component export a matching `*Props` type from the SDK barrel — for example, `DiagramContainerProps` for the `'DiagramContainer'` slot, [`ProjectSelectionProps`](/api/components/projectselectionprops/) for `'ProjectSelection'`, and [`PropertiesBarProps`](/api/components/propertiesbarprops/) for `'PropertiesBar'`. @@ -116,6 +129,91 @@ registerComponentDecorator('DiagramContainer', { For a custom slot you control, type the parameter with your component's own props instead — `registerComponentDecorator('MyCustomSlot', { … })`. +## Built-in interface: hide, move, add + +Your own UI in the app bar, the palette and the properties panel does **not** go through `registerComponentDecorator`; it uses a separate, simpler model (the `PropertiesBar` and `ProjectSelection` slots still take a props decorator, for the panel's `tabs`, labels and `onDeleteClick`, and that keeps working): read state with `useWorkflowBuilderState()`, run a command with `useWorkflowBuilderActions()`, hide a built-in control with a key in `builtInControls` on ``, and add your own UI with a `…Content` or `…MenuItem` component rendered under ``. A plugin function has no JSX of its own, so it still reaches this model indirectly: it mounts one component through the unrelated, still-current `OptionalAppChildren` slot, and that component uses `…Content`/`…MenuItem` inside it (see ["A plugin that adds UI"](#a-plugin-that-adds-ui) below). + +| You want to… | Use | +| ----------------------------------- | ---------------------------------------------------------------------------- | +| read state | `useWorkflowBuilderState()`, e.g. `isReadOnly`, `isPaletteOpen`, `theme`, … | +| run a command from your own control | an action from `useWorkflowBuilderActions()`, e.g. `openTemplates()` | +| hide a built-in control | a key in `builtInControls` on `` | +| add your own UI | a component rendered under ``: `…Content`, `…MenuItem` | + +Six `…Content` components cover the app bar, palette and properties-panel areas: `AppBarToolsContent`, `AppBarControlsContent`, `PaletteHeaderContent`, `PaletteFooterContent`, `PropertiesPanelHeaderContent`, `PropertiesPanelFooterContent`. Three `…MenuItem` components add an item to a built-in menu behind a separator: `ProjectMenuItem`, `AppBarMenuItem`, `PropertiesPanelMenuItem`. Render any of them anywhere under ``: in the app tree, in a plugin component, or inside a node's properties form. + +### Hide a control and rebuild it in your own order + +Built-in controls cannot be reordered: hide the ones you want to move, then render your own in their place. + +```tsx +import { MoonStars, Sun } from '@phosphor-icons/react'; +import { + AppBarControlsContent, + WorkflowBuilder, + useWorkflowBuilderActions, + useWorkflowBuilderState, +} from '@workflowbuilder/sdk'; +import { IconSwitch } from '@workflowbuilder/ui'; + + + + + + + + +; + +function ThemeSwitch() { + const { theme } = useWorkflowBuilderState(); + const { toggleDarkMode } = useWorkflowBuilderActions(); + return } IconChecked={} />; +} +``` + +This renders, left to right: `ReadOnlySwitch`, `ShareButton`, the rebuilt `ThemeSwitch` (own content, in JSX order), then the built-in language selector (not hidden here), then the app bar's kebab-menu trigger (shown because Export and Import are still visible). Own content always renders before whatever built-ins remain; it does not reorder the built-ins among themselves. + +### A plugin that adds UI + +A plugin is a `() => void` function with no JSX of its own, so it mounts one component through the (still current) `OptionalAppChildren` slot, and that component uses the same `…Content`/`…MenuItem` components as the app tree: + +```tsx +export const publishPlugin: WorkflowBuilderPlugin = () => { + registerComponentDecorator('OptionalAppChildren', { name: 'publish', content: PublishControls }); +}; + +function PublishControls() { + return ( + <> + + + + + + ); +} +``` + +### Boundaries + +These are deliberate limits of the model, not bugs: + +- **Order is mount order.** A component rendered conditionally (`{canPublish && }`) moves to the end of its area or menu when it remounts. +- **Own content stands before the built-in controls** of its area. In the app bar and the footers that means immediately before them; in the sidebar headers it renders as a full-width row under the title, wide enough for a search field. +- **The area sets the outer layout.** The app bar lays its content out in a line; the footers lay it out in a column, so two buttons side by side there need a container of their own. Your content adapts to the area it is in; it does not change how the area itself lays out. +- **Hiding is not blocking.** `builtInControls={{ delete: false }}` removes the button; the Delete key and `deleteSelection()` keep working. Only read-only mode blocks deletion as a whole. `reactFlowProps={{ deleteKeyCode: null }}` disables the key alone: `deleteSelection()` and a decorator's `onDeleteClick` still delete through the same confirmation. +- **Hiding is app-wide, not per-node.** `builtInControls` hides a control for the whole app; it cannot protect one specific node from deletion. For that, set `deletable: false` on the node itself (a React Flow property), which also blocks the Delete key for that node. +- **Hiding `paletteToggle` or `propertiesPanelToggle` leaves no way back for the user.** The palette starts collapsed, so hiding `paletteToggle` means it never opens at all unless the app calls `setPaletteOpen(true)` itself. Either sidebar, once collapsed, has only its own expand button (also hidden) to reopen it; an app that hides either toggle must give the user another way in, e.g. a button calling `setPaletteOpen(true)` or `setPropertiesPanelOpen(true)`. +- **Read-only mode blocks the actions that change the persisted model**: `openTemplates`, `renameDocument`, `deleteSelection`, `setLayoutDirection`, `toggleLayoutDirection`. Every other action, including `save`, `openExport`, the theme and palette/panel toggles, the palette filter and the language switch, always runs. Two exceptions are not guarded today: `openImport` replaces the whole diagram and `openSettings` lets you edit the persisted global variables, and neither checks `isReadOnly`. Hide them explicitly where that matters: `builtInControls={{ import: canEdit, settings: canEdit }}`. A custom button that changes the diagram checks `isReadOnly` itself; the SDK only guards its own actions. +- **Content from a node's properties form lives with the form.** It mounts only while the panel is expanded and that tab is active, and unmounts with the form; content meant to be always present belongs in the app tree instead. +- **An empty area, or a menu with no items, disappears**, and a collapsed sidebar shows only its title and the expand button; its own header content and menu disappear with the rest. +- **Two `…Content` components in the same area both render; nothing merges them.** Two buttons added from two different places both show up, in mount order. +- **A `…MenuItem` may repeat a built-in label.** Every registered row is keyed by its own identity, so a second "Export" next to the built-in one shows as a separate row rather than colliding with it. Whether two rows with the same text make sense is the app's call. +- **A canvas node template is the wrong place** for any of these components: it renders once per node on the canvas, not once for the app. + ## `registerFunctionDecorator` Intercept a decorable function before/after its execution. @@ -135,12 +233,12 @@ type CallbackAfter = (args: { params: unknown[]; returnValue: unknown }) => void A non-exhaustive list (grep `withOptionalFunctionPlugins` in the source for the complete set): -| Function name | What it does | -| ---------------------- | ------------------------------------------ | -| `getPaletteData` | Builds the palette data structure. | -| `getTemplates` | Builds the template list. | -| `trackFutureChange` | Records an upcoming diagram change. | -| `getControlsDotsItems` | Builds the dots-menu items in the app bar. | +| Function name | What it does | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getPaletteData` | Builds the palette data structure. | +| `getTemplates` | Builds the template list. | +| `trackFutureChange` | Records an upcoming diagram change. | +| `getControlsDotsItems` | **Deprecated**, still runs. Builds the app bar's kebab-menu items. Use [`AppBarMenuItem`](#built-in-interface-hide-move-add), and hide `export` / `import` through `builtInControls`. | ### Return conventions diff --git a/apps/docs/src/content/docs/guides/custom-jsonforms-control.md b/apps/docs/src/content/docs/guides/custom-jsonforms-control.md index 0558e2a73..088193379 100644 --- a/apps/docs/src/content/docs/guides/custom-jsonforms-control.md +++ b/apps/docs/src/content/docs/guides/custom-jsonforms-control.md @@ -59,6 +59,37 @@ function App() { Any node whose `uischema` contains `{ type: 'ColorPicker', scope: '...' }` will now render with your `ColorPicker` component. +## Actions for a control + +A control's own buttons and menu items belong with its form, not with the panel chrome: render them from inside the control with [`PropertiesPanelFooterContent`](/api/components/propertiespanelfootercontent/) (for footer actions, such as Approve / Reject) and [`PropertiesPanelMenuItem`](/api/components/propertiespanelmenuitem/) (for an item in the panel's kebab menu). Both mount only while the control's tab is active and unmount with the form, so they never outlive the node they belong to. + +```tsx +import { PropertiesPanelFooterContent, PropertiesPanelMenuItem } from '@workflowbuilder/sdk'; +import { Button } from '@workflowbuilder/ui'; + +function DecisionVerdict() { + return ( + <> + + + + {/* The footer lays its content out in a column, so two buttons side by side need a container of their own. */} +

+ + +
+ + + ); +} +``` + +See the full model, including hiding and reordering built-in controls, in [Built-in interface: hide, move, add](/guides/build-a-plugin/#built-in-interface-hide-move-add). + ## Cells `cells` work the same way — type the entry with `JsonFormsCellExtension` and wrap your component with `withJsonFormsCellProps` (also from `@workflowbuilder/sdk`) for list/array cell rendering. Built-in cells are passed through when consumer cells are absent; if you provide any, yours are used as-is (no merging with built-ins for cells). diff --git a/packages/sdk/src/features/plugins-core/README.md b/packages/sdk/src/features/plugins-core/README.md index a4e3a663d..b9dd19916 100644 --- a/packages/sdk/src/features/plugins-core/README.md +++ b/packages/sdk/src/features/plugins-core/README.md @@ -14,7 +14,7 @@ registerComponentDecorator('OptionalHooks', { }); ``` -And import it in `apps/demo/src/app/features/plugins-core/index.ts`. +Wrap the call in an exported `plugin(): void` function (your `plugin-exports.ts`), then add that function to the `plugins` array passed to `` in `apps/demo/src/app/app.tsx`. ## Adding button before @@ -30,4 +30,6 @@ registerComponentDecorator('OptionalAppBarTools', { }); ``` -And import it in `apps/demo/src/app/features/plugins-core/index.ts`. +The same way: wrap it in a `plugin(): void` function and add that function to `app.tsx`'s `plugins` array. + +`OptionalAppBarTools` is deprecated in favor of `AppBarToolsContent` and `builtInControls`; it keeps working. See `packages/sdk/README.md` for the current built-in interface model. diff --git a/packages/sdk/src/features/ui-extensions/ui-extensions.decision-log.md b/packages/sdk/src/features/ui-extensions/ui-extensions.decision-log.md new file mode 100644 index 000000000..df26a8cf5 --- /dev/null +++ b/packages/sdk/src/features/ui-extensions/ui-extensions.decision-log.md @@ -0,0 +1,202 @@ +### Title: One model for the built-in interface: state, actions, builtInControls, …Content, …MenuItem + +### Proposed by: Jan Librowski + +### Date: 01.10.2026 + +## Context + +The app bar, the palette and the properties panel each extended differently: the app bar took +component decorators on named slots (`OptionalAppBarTools`, `OptionalAppBarControls`), the kebab +menu took a function decorator (`getControlsDotsItems`), and the project menu and the properties +panel header took one-off host callbacks (`onDuplicateClick`, `onMenuHeaderClick`). None of the +three let a consumer read the state behind a built-in toggle (read-only, theme, language, either +sidebar's open state), none let a consumer call the same command a built-in button calls, and +none let a consumer hide a built-in button cleanly: hiding meant wiring a slot to render nothing, +or, for Templates and Settings, nothing at all, because they were hard-coded. + +This log records the model that replaces that, and the implementation decisions a reader of this +code needs that are not obvious from the JSDoc alone. + +## Decision + +One model for the app bar, the palette and the properties panel, with four tools that work the +same way in all three: + +- **Read state** with `useWorkflowBuilderState()`: `isReadOnly`, `theme`, `language`, + `isPaletteOpen`, `isPropertiesPanelOpen`, `paletteFilter`, `layoutDirection`, `documentName`. +- **Run a command** with an action from `useWorkflowBuilderActions()`: the existing actions plus + `openTemplates`, `deleteSelection`, `togglePalette`/`setPaletteOpen`, + `togglePropertiesPanel`/`setPropertiesPanelOpen`, `setPaletteFilter`, `setLanguage`, + `renameDocument`. +- **Hide a built-in control** with a key in `builtInControls` on ``. Every + key defaults to `true`; `false` hides the control without touching what it does. +- **Add your own UI** with a component rendered under ``: six `…Content` + components for the app bar, palette and properties-panel areas, and three `…MenuItem` components + for the project menu, the app bar's kebab menu and the properties panel's menu. + +Design decisions a reader of the code should know: + +- **`builtInControls` is a React context, not a store field.** `` resets the + global zustand store to its initial state on every fresh mount (`resetWorkflowStore()` in + `workflow-builder-root.tsx`, preserving the documented "mount, save, unmount, mount next" + sequential-workflows contract). A store field would be wiped by that reset on every mount + regardless of the `builtInControls` prop's actual value, and keeping it in sync afterward would + need an effect that re-writes it right after the reset runs. A React context has no reset step + to race against: it simply reflects whatever value Root passed on this render. + `useIsBuiltInControlVisible` (`built-in-controls-context.tsx`) reads it. + +- **One UI-extension registry per mounted Root, read through `useSyncExternalStore`, not Root + `useState` and not a module-level holder** (`ui-extension-registry.ts`, + `ui-extension-context.tsx`). Root `useState` was rejected because every `…Content`/`…MenuItem` + mount, unmount, or prop change would call `setState` on Root, re-rendering the whole mounted + layout (the default layout or the app's own children) on every registration change instead of + only the one host that reads that area or menu. A module-level holder was rejected because it + would be shared between sequential Root mounts on the same page and would need an explicit reset + between them; a plain registry object created once per `UiExtensionProvider` mount (via + `useRef`) resets itself for free on every fresh Root, the same way the global store resets + itself on mount. Reading it through `useSyncExternalStore` gives every `…Content` and + `…MenuItem` component a consistent snapshot without re-rendering the whole subtree on every + registration change: only the host that reads that one area or menu re-renders. + +- **An empty area hides itself through CSS, not a content counter.** `AreaTarget` renders a real + DOM node with `display: contents` by default and `display: none` on `:empty` + (`area-target.module.css`); a producer's children portal straight into that node, so the node is + non-empty exactly when there is something to show. The properties panel and palette footers add + one more rule on top: `.footer-area:not(:has(> .footer > :not(:empty)))` hides the footer's + separator together with it, which only works because every footer piece (the `AreaTarget` and + the built-in Delete/Templates button alike) renders as a **direct child** of `.footer` + (`sidebar.module.css`). A host that wrapped its `AreaTarget` in an extra `
` would break this + rule silently, because `:has()` matches the DOM tree the selector is written against, not + whatever `display: contents` makes it look like. This was chosen over a counter kept in the + registry (`getAreaItemCount`-style): CSS already expresses "no visible content" correctly for + free, and a counter would still have to special-case `display: contents` to agree with it. + +- **Area targets stand before the deprecated decorator slots that wrap a built-in control.** The + toolbar area, the controls area and the palette footer (the hosts behind `AppBarToolsContent`, + `AppBarControlsContent` and `PaletteFooterContent`) all render `` first, then their + deprecated slot (`OptionalAppBarTools`, `OptionalAppBarControls`, `OptionalFooterContent`); inside + that slot, a `place: 'before'` decorator renders, then the slot's built-in control (Save; the + language/read-only/theme toggles; or the Templates button, each the slot's `children`), then a + `place: 'after'` decorator. A `place: 'wrapper'` decorator, or a `modifyProps` that drops + `children`, can therefore still wrap or remove the built-in control, but never the own-content + area standing ahead of the whole slot. The properties panel footer never had a decorator slot of + its own: it only ever rendered `` next to the Delete button. The properties panel + header's `` moved the other way: out of `PropertiesBarHeader` and into + `properties-bar.tsx`'s header fragment, so the "show only while expanded" check that gates it sits + next to the check that gates the rest of the header, instead of being duplicated between the two + files. + +- **The palette footer's deprecated slot sits in a `display: contents` element.** The Sidebar hides + a footer whose direct children are all `:empty`; bare text or an `` from the slot is not a + non-empty element, so a footer with such content vanished. The wrapper is non-empty whenever the + slot renders anything and adds no layout of its own. +- **`PropertiesPanelFooter` stays as a deprecated alias of `PropertiesPanelFooterContent`.** The old + name shipped in SDK 3.0.0 while this work was in review; dropping it would turn the rename into a + breaking removal in a minor. +- **The store field renames are a major changeset.** SDK 3.0.0 shipped on 02.10.2026, before this + landed, so the pending major becomes 4.0.0 unless the old names return as aliases and the + changeset drops to minor. That call stays with the release owner. +- **A save is refused until the initial load finished.** `handleIntegrationSave` returns `'error'` + while the saving status is still `'disabled'`, and Root resets that status on every mount. Auto-save + and the save on tab close now run in every layout, so without the guard a tab closed during a slow + `api` load would send the reset, empty store to the save endpoint. +- **The read-only guard moved into `openTemplateSelectorModal`, not only into the `openTemplates` + action.** `TemplateSelector` replaces the whole diagram through `setDiagramModel` without + checking read-only mode itself. Before this change the palette's Templates button had a + `disabled` prop, but the welcome template selector that an empty diagram opens on mount went + through the same modal without that check. Guarding the modal opener instead of only the action + closes that path for every caller, present and future, instead of trusting each one to remember. + Two other paths to a read-only model change are left unguarded by design: `openImportModal` has + no read-only guard (`ImportModal` replaces the whole diagram the same way `TemplateSelector` + does), and the Settings modal lets a read-only viewer edit global variables, which are part of + the persisted model. Closing them is a separate task (the design document's decision 24), not + part of this release. An app that needs read-only to be airtight today hides both explicitly: + `builtInControls={{ import: canEdit, settings: canEdit }}`. + +- **`EditorRuntime` is a render boundary, not inlined into `RootShell`.** `useAutoSave` subscribes + to the changes tracker, so it reruns on every diagram change; `useAutoSaveOnClose` just registers + a `beforeunload` listener on mount. Both previously lived in `SaveButton`. Calling either hook + directly in `RootShell` would re-run the whole default-layout-or-custom-children branch on every + diagram change, because of `useAutoSave`'s subscription. A null-rendering leaf component + (`features/editor-runtime/editor-runtime.tsx`) isolates that subscription so only the leaf + re-renders. Moving auto-save here also makes it a property of `` itself + rather than of ``, so a custom layout that omits the top bar now + auto-saves too. Language-change detection (`useDetectLanguageChange`) was already called in + `RootShell` before this change; it only moved into the same leaf for the same render-isolation + reason, not because it newly became Root-level. + +- **Placement warnings are not gated on a dev flag.** `usePlacementWarning` warns unconditionally + instead of checking `import.meta.env.DEV`. The SDK ships as a pre-built bundle; the build tool + that would strip a `DEV`-gated branch runs once, at SDK build time, against the SDK's own + environment, not again in each consumer's app build, so gating here would silently hide the + warning from every consumer rather than only from SDK-internal development. + +- **Two kinds of slots are named apart on purpose, and that naming is the rule for which one to + reach for.** A decorator slot (`OptionalAppBarTools`, `OptionalHooks`, …) mounts content that was + created outside the component tree it renders into: a plugin registered once, with no React + parent of its own. A `…Content` component (`AppBarToolsContent`, `PropertiesPanelFooterContent`, + …) portals content that is created inside the producer's own tree, so it keeps that tree's state + and context, such as a node's properties form or an app component with its own hooks. Reach for a + `…Content`/`…MenuItem` component for anything that needs to read state or close over + context; reach for a decorator only to intercept or modify a slot the SDK already renders + (`DiagramContainer`, `PropertiesBar`, the diagram-level `Optional*` slots), which `…Content` + components cannot do. + +## Alternatives + +- **Keep extending each area its own way** (slots for the app bar, a function decorator for the + kebab menu, host callbacks for the project menu and the panel header) was rejected: a consumer + had to learn which of three mechanisms applied to which pixel on screen, and none of the three + exposed state, so "is read-only mode on" had no supported answer outside `useStore` field names + that were never a documented contract. +- **Exporting the built-in controls as prop-less components** (a `tldraw`-style component map) was + deferred rather than rejected: it grows the public contract before it has been exercised by real + consumers, and the hide-and-rebuild pattern already covers the same need. +- **A module-level holder for `builtInControls` and for the UI-extension registry**, matching the + existing pattern for `logo` and `reactFlowProps`, was rejected for both, for different reasons. + `builtInControls` as a module holder: Root already rewrites `logo` and `reactFlowProps` into + their module holders on every render (not only on mount), so a `builtInControls` holder would + track the prop's latest value just as well; the real problem is that a plain assignment notifies + no reader, as Root's own comment about those two holders says (`workflow-builder-root.tsx:123-125`, + "they don't notify subscribers and don't trigger re-renders"). A control already mounted and + reading that holder would not learn the value changed until something else happened to re-render + it; a React context does not have this gap, because changing its value re-renders every consumer + directly. The registry as a module holder: it would be shared between sequential Root mounts on + the same page without a reset step, unlike a context value a fresh `UiExtensionProvider` creates + from scratch for each Root. +- **A content counter instead of CSS `:empty`/`:has()`** for hiding empty areas was rejected as + more code to keep in sync with what the DOM already shows for free. + +## Follow-ups + +A few findings from building this model are tracked as separate, smaller pieces of work rather +than folded into this release: + +- There is no dev-time warning when a decorator targets one of the three now-deprecated slot names + (`OptionalAppBarTools`, `OptionalAppBarControls`, `OptionalFooterContent`) or decorates the + deprecated `getControlsDotsItems` through `registerFunctionDecorator`. The three slot names are + bare strings with no symbol anywhere in the source to attach a tag to. + `getControlsDotsItems` does have a symbol (`get-controls-dots-items.tsx`'s exported const), but it + is internal, not part of the SDK barrel; a consumer names it by string too, so a `@deprecated` tag + on that internal symbol would not reach them either. Only `onDuplicateClick` and + `onMenuHeaderClick` carry the tag today, because both are properties of an exported public type + (follow-up: deprecated-slot-warning). +- The palette has no designed empty state for "every node was filtered out by `paletteFilter`"; it + currently just shows an empty list (follow-up: palette-empty-state). +- Public type names such as `PropertiesBar` vs. `PropertiesPanel` are inconsistent across the + surface this model adds to, and a naming pass across the whole public API would catch more than + just these (follow-up: public-type-naming). +- The built-in controls themselves are not individually exported as components, which would let a + consumer compose a custom row from built-ins and their own content instead of only hiding and + rebuilding from scratch (follow-up: built-in-control-components). +- A plugin is a side-effecting `() => void` function with no JSX of its own; letting a plugin be + authored as a component instead (mounted once, the way `OptionalAppChildren` content is today) + would let it use `…Content`/`…MenuItem` directly instead of mounting one indirection component + through `OptionalAppChildren` (follow-up: plugin-as-component). +- `openImportModal` and the Settings modal do not check read-only mode, unlike every other path + that changes the persisted model; closing that gap is the design document's decision 24, tracked + as its own task (follow-up: read-only-import-settings). +- Auto-save has no switch: with `EditorRuntime` owning it, a view that must never save (a run view, + a preview) can only pass an `onDataSave` that rejects. A prop on Root, or an `autoSave: false` on + the integration, would make that explicit (follow-up: auto-save-opt-out).