| name | babylon-js |
| description | name: porting-tools-to-fluent Use when this capability is needed. |
name: porting-tools-to-fluent
description: "Guide for porting Babylon.js tools from legacy shared-ui-components to Fluent UI using MakeModularTool. Use when: port to fluent, migrate to fluent, fluent migration, porting tool UI."
Porting Babylon.js Tools to Fluent UI
Guide for porting Babylon.js tools (NME, NGE, NPE, NRGE, Playground, etc.) from the legacy shared-ui-components to Fluent UI using the MakeModularTool framework from shared-ui-components/modularTool/.
Reference implementation: packages/tools/viewer-configurator/ (fully ported).
Overview
A Fluent port replaces four layers:
- Bootstrapping — from ad-hoc
createRoot + <App /> to MakeModularTool (provides theming, settings, shell layout)
- Layout — from hand-rolled split containers / panes to
IShellService (central content, side panes, toolbars)
- Components — from legacy shared-ui-components to
shared-ui-components/fluent/ primitives and HOCs
- Styling — from raw SCSS/CSS to
makeStyles from @fluentui/react-components
- Icons — from FontAwesome to
@fluentui/react-icons
1. Dependencies
Add
"@fluentui/react-components": "^9.x",
"@fluentui/react-icons": "^2.x"
Note: @dev/shared-ui-components should already be a dependency. It contains both the Fluent primitives (fluent/) and the ModularTool framework (modularTool/). No dependency on @dev/inspector is needed.
Remove
"@fortawesome/fontawesome-svg-core": "...",
"@fortawesome/free-solid-svg-icons": "...",
"@fortawesome/free-regular-svg-icons": "...",
"@fortawesome/react-fontawesome": "...",
"sass": "...",
"sass-loader": "..."
Vite config
Ensure the shared-ui-components alias is present:
commonDevViteConfiguration({
aliases: {
"shared-ui-components": path.resolve("../../dev/sharedUiComponents/src"),
},
});
tsconfig.json
Ensure the shared-ui-components path mapping is present (no inspector mapping needed):
"paths": {
"shared-ui-components/*": ["../../dev/sharedUiComponents/src/*"]
}
2. Bootstrapping with MakeModularTool
Replace the old entry point:
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
import { MakeModularTool } from "shared-ui-components/modularTool/modularTool";
MakeModularTool({
namespace: "MyToolName",
containerElement: document.getElementById("root")!,
serviceDefinitions: [
],
toolbarMode: "compact",
showThemeSelector: true,
});
MakeModularTool automatically provides:
FluentProvider + theme (light/dark)
SettingsStore (persisted user preferences)
ThemeService + optional ThemeSelectorService
ShellService (layout: central content, side panes, toolbars)
ToastProvider + IToastService for toast notifications (consume ToastServiceIdentity — do not roll your own container)
IDialogService (consume DialogServiceIdentity) for modal alert/confirm dialogs — replaces ad-hoc MessageDialog
Cross-window / popup hosting
MakeModularTool derives targetDocument from containerElement.ownerDocument. If your tool's entry function (e.g. Show(options)) hosts the editor in a popup window, just pass the popup body as containerElement — Fluent/Griffel/Theme plumb cross-window automatically.
- For a fully-Fluent popup, use
OpenPopupWindow from shared-ui-components/fluent/hoc/popupWindow.
- If part of your tool still ships traditional CSS/SCSS (e.g.
shared-ui-components/nodeGraphSystem/'s graph canvas), keep the legacy CreatePopup from shared-ui-components/popupHelper — it copies stylesheets into the popup. Fluent and CreatePopup coexist fine.
3. Service Architecture
Each tool should define its own services that populate the shell. A service is a ServiceDefinition<Produces, Consumes> with:
friendlyName — human-readable name for debugging
produces — array of service identity symbols this service provides
consumes — array of service identity symbols this service depends on
factory(…consumedServices) — returns an object satisfying the produced contracts + optional IDisposable
Defining a service identity and contract
export const MyServiceIdentity = Symbol("MyService");
export interface IMyService extends IService<typeof MyServiceIdentity> {
readonly someData: SomeType | undefined;
readonly onStateChanged: IReadonlyObservable<void>;
}
Service factory pattern
export const MyServiceDefinition: ServiceDefinition<[IMyService], [IShellService]> = {
friendlyName: "My Service",
produces: [MyServiceIdentity],
consumes: [ShellServiceIdentity],
factory: (shellService) => {
const onStateChanged = new Observable<void>();
let someData: SomeType | undefined;
const registration = shellService.addCentralContent({
key: "MyContent",
component: () => <MyComponent />,
});
return {
get someData() {
return someData;
},
onStateChanged,
dispose: () => {
onStateChanged.clear();
registration.dispose();
},
} satisfies IMyService & IDisposable;
},
};
Shell service APIs
shellService.addCentralContent({ key, component }) — main content area
shellService.addSidePane({ key, title, icon, horizontalLocation, verticalLocation, teachingMoment, content }) — side pane
shellService.addToolbarItem({ key, horizontalLocation, verticalLocation, teachingMoment, component }) — toolbar button
All return IDisposable — clean up in your service's dispose().
Reactive state with useObservableState
Use the useObservableState hook from shared-ui-components/modularTool/ to subscribe to service state in React components:
import { useObservableState } from "shared-ui-components/modularTool/hooks/observableHooks";
const myData = useObservableState(
() => myService.someData,
myService.onStateChanged
);
4. Component Mapping
Legacy → Fluent shared component mapping
| Legacy Component | Fluent Replacement | Import Path |
|---|
LineContainerComponent | AccordionSection | shared-ui-components/fluent/primitives/accordion |
| Side pane container | Accordion (or ExtensibleAccordion) | shared-ui-components/fluent/primitives/accordion |
CheckBoxLineComponent | Switch (primitive) or SwitchPropertyLine (with label) | shared-ui-components/fluent/primitives/switch or .../hoc/propertyLines/switchPropertyLine |
SliderLineComponent | SyncedSliderInput (primitive) or SyncedSliderPropertyLine (with label) | shared-ui-components/fluent/primitives/syncedSlider or .../hoc/propertyLines/syncedSliderPropertyLine |
OptionsLine | Dropdown (primitive) or StringDropdownPropertyLine (with label) | shared-ui-components/fluent/primitives/dropdown or .../hoc/propertyLines/dropdownPropertyLine |
ButtonLineComponent | Button (primitive) | shared-ui-components/fluent/primitives/button |
TextInputLineComponent (single-line) | TextInput (primitive) or TextInputPropertyLine (with label) | shared-ui-components/fluent/primitives/textInput or .../hoc/propertyLines/inputPropertyLine |
TextInputLineComponent (multiline) | Fluent Textarea + slot props | @fluentui/react-components |
MessageLineComponent | MessageBar | shared-ui-components/fluent/primitives/messageBar |
Color4LineComponent |
When to use PropertyLine vs primitives
PropertyLine — Use when a row has a label. Renders InfoLabel + child content in a standardized layout with hover border. Import from shared-ui-components/fluent/hoc/propertyLines/propertyLine.
LineContainer — Use for rows without a label. Simple wrapper with hover border. Same import path.
- Primitives directly — Use when you need custom layout, e.g. button groups or compound rows.
Composing controls within PropertyLine
PropertyLine constrains its children via an internal childWrapper div with overflow: hidden and whiteSpace: nowrap. For multi-element children, wrap them in a flex container:
const useStyles = makeStyles({
propertyContent: {
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: tokens.spacingHorizontalS,
width: "100%",
},
fillControl: { flex: 1, minWidth: 0 },
buttonGroup: { display: "flex", flexDirection: "row", alignItems: "center" },
});
<PropertyLine label="Model URL">
<div className={classes.propertyContent}>
<div className={classes.fillControl}>
<TextInput value={url} onChange={setUrl} className={classes.fullWidth} />
</div>
<div className={classes.buttonGroup}>
<Button icon= = />
;
5. Icons
Replace all FontAwesome icons with @fluentui/react-icons. For general icon conventions (unsized variants, fontSize sizing), see fluent.instructions.md.
Common FontAwesome → Fluent mappings:
| FontAwesome | Fluent Icon |
|---|
faQuestionCircle | QuestionCircleRegular |
faBullseye | TargetRegular |
faCamera | CameraRegular |
faCheck | CheckmarkRegular |
faCopy | CopyRegular |
faGripVertical | ReOrderDotsVerticalRegular |
faRotateLeft | ArrowResetRegular |
faSave | SaveRegular |
faSquarePlus | AddSquareRegular |
faTrashCan | DeleteRegular |
faUpload | ArrowUploadRegular |
faChevronDown | ChevronDownRegular |
faChevronUp | ChevronUpRegular |
faGear / faCog | SettingsRegular |
faEye | EyeRegular |
faEyeSlash | EyeOffRegular |
faPlus | AddRegular |
faMinus | SubtractRegular |
faPencil / faEdit | EditRegular |
faClose / faTimes | DismissRegular |
faSearch |
Custom SVG icons
Use createFluentIcon for custom icons (e.g. Babylon logo):
import { createFluentIcon } from "@fluentui/react-icons";
export const MyIcon = createFluentIcon(
"MyIcon",
"1em",
'<g transform="...">' + '<path fill="#e0684b" d="..."/>' + "</g>"
);
The default viewBox is 0 0 20 20. If your SVG source has a different viewBox, compute a transform to map the content bounds into 20×20 space:
- Find the actual content bounding box (min/max of all coordinates)
- Compute scale:
min(20 / contentWidth, 20 / contentHeight)
- Center:
translate((20 - scaledWidth) / 2, (20 - scaledHeight) / 2)
- Apply:
translate(centerX, centerY) scale(s) translate(-minX, -minY)
6. Styling Migration (SCSS → makeStyles)
For general makeStyles, spacing tokens, and inline style rules, see fluent.instructions.md. This section covers migration-specific steps.
Rules
- Zero raw CSS/SCSS after the port. Delete all
.scss files and scssDeclaration.d.ts.
- Only use inline
style={} for truly dynamic values (e.g. drag-and-drop transforms).
Example
import { makeStyles, tokens } from "@fluentui/react-components";
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
},
header: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
},
});
const MyComponent = () => {
const classes = useStyles();
return <div className={classes.root}>...</div>;
};
ToolContext for consistent sizing
Wrap your tool's root content in a ToolContext.Provider with size: "medium" to ensure consistent control sizing:
import { ToolContext } from "shared-ui-components/fluent/hoc/fluentToolWrapper";
<ToolContext.Provider value={{ useFluent: true, disableCopy: false, toolName: "MyTool", size: "medium" }}>{/* tool content */}</ToolContext.Provider>;
7. Known Gotchas
className forwarding on shared primitives
Not all shared primitives forward className to the outermost DOM element. When a primitive does NOT forward className, wrap it in a <div>:
| Primitive | Forwards className? | Workaround |
|---|
Button | ✅ Yes | — |
Dropdown | ✅ Yes | — |
TextInput | ✅ Yes (via mergeClasses) | — |
SyncedSliderInput | ❌ No | Wrap in <div className={...}> |
ColorPickerPopup | ❌ No | Wrap in <div className={...}> |
TextInput width override
TextInput has a hardcoded width: 150px from Fluent's UniformWidthStyling. To make it fill available space:
- Wrap in a
<div> with flex: 1; minWidth: 0 (the fillControl pattern)
- Pass
className={classes.fullWidth} (with fullWidth: { width: "100%" }) to the TextInput — Griffel's deduplication ensures the external className wins over the internal 150px
Textarea slot styling
className on Fluent's Textarea applies to the outer wrapper span, not the inner <textarea> element. To style the actual textarea (e.g. monospace font, no-wrap):
<Textarea className={classes.outerStyles} textarea={{ className: classes.innerStyles }} />
Where:
outerStyles: { minHeight: "160px" },
innerStyles: { fontFamily: "monospace", whiteSpace: "pre", overflowX: "auto" },
HTML custom elements
React's className doesn't work on HTML custom elements (e.g. <babylon-viewer>). Use class= instead:
<babylon-viewer class={classes.myViewer} />
You'll need a JSX IntrinsicElements declaration with class?: string.
ButtonLine nesting
Do not nest ButtonLine inside PropertyLine — this creates a LineContainer inside PropertyLine resulting in double borders. Use the Button primitive directly instead.
Button groups
When multiple action buttons appear together (e.g. upload + reset), wrap them in a gapless flex row to avoid unwanted spacing between buttons:
buttonGroup: { display: "flex", flexDirection: "row", alignItems: "center" },
The gap should be between the left content (e.g. a text input) and the button group, not between individual buttons.
8. Import Paths
The ModularTool framework and Fluent components live in shared-ui-components:
import { MakeModularTool } from "shared-ui-components/modularTool/modularTool";
import { type ServiceDefinition, type IService } from "shared-ui-components/modularTool/modularity/serviceDefinition";
import { type WeaklyTypedServiceDefinition } from "shared-ui-components/modularTool/modularity/serviceContainer";
import { type IShellService, ShellServiceIdentity } from "shared-ui-components/modularTool/services/shellService";
import { useObservableState } from "shared-ui-components/modularTool/hooks/observableHooks";
import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion";
import { Button } from "shared-ui-components/fluent/primitives/button";
No inspector/ imports are needed for tools — everything comes from shared-ui-components/.
9. File Cleanup Checklist
After porting, delete:
Verify
- The Vite app builds cleanly with the package build script
- No remaining imports of
sass, scss, fontawesome, or legacy shared-ui-components
- All controls render correctly in both light and dark themes
- Dynamic functionality (drag-and-drop, modals, etc.) still works
10. Dropdown Options Pattern
The Fluent Dropdown uses DropdownOption<T> instead of the old IInspectableOptions:
import type { DropdownOption } from "shared-ui-components/fluent/primitives/dropdown";
const options: DropdownOption<string>[] = [
{ key: "option1", text: "Option 1" },
{ key: "option2", text: "Option 2" },
];
<Dropdown options={options} value={currentValue} onChange={onValueChanged} />;
For satisfies clauses in const option arrays, use satisfies DropdownOption<string>[] instead of the old satisfies IInspectableOptions[].
Summary: Step-by-Step Porting Order
- Update dependencies — add Fluent + inspector, remove FontAwesome + SCSS
- Update Vite config — add any needed shared aliases
- Update tsconfig — add
inspector path mapping
- Create services — define service identities, contracts, and factory functions
- Rewrite entry point — replace
createRoot with MakeModularTool
- Port components — replace legacy components with Fluent equivalents, convert SCSS to
makeStyles
- Replace icons — swap FontAwesome for
@fluentui/react-icons
- Switch hooks — use
useObservableState from shared-ui-components/modularTool/ instead of local hooks
- Delete obsolete files — remove SCSS, FA wrappers, legacy components
- Build & verify — ensure clean build and correct rendering in both themes
11. Patterns for Large Editor Ports
Distilled from the Flow Graph Editor port (packages/tools/flowGraphEditor/). See its port-to-fluent.md for the full plan.
Phased execution
For tools too large to port in one go (NME-class), use phases that each leave the build green:
- Bootstrap & shell — add Fluent deps, create a
GlobalState service, wrap the existing class component as a single addCentralContent passthrough, switch entry point to MakeModularTool.
- Decompose layout — extract each pane into its own service that still wraps the legacy component. Delete
SplitContainer/Splitter once the shell owns the layout.
- Port surrounding components — rewrite each pane/dialog/toolbar to Fluent +
makeStyles. One component at a time.
- Port property panels — replace
shared-ui-components/lines/* with Fluent property-line HOCs.
- Cleanup — delete local
sharedComponents/, all .scss, obsolete package.json devDeps; run lint/format/build/e2e.
MakeXService(options) factory pattern
When a service needs instance-specific inputs from the tool's entry function (e.g. Show(options)), export a factory rather than a static ServiceDefinition:
export function MakeGlobalStateService(options: IMyToolOptions, hostElement: HTMLElement): ServiceDefinition<[IGlobalStateService], []> {
return {
friendlyName: "Global State Service",
produces: [GlobalStateServiceIdentity],
factory: () => {
const globalState = new GlobalState(options.scene);
return { globalState, dispose: () => { } };
},
};
}
MakeModularTool({
serviceDefinitions: [MakeGlobalStateService(options, hostElement), CentralServiceDefinition, ...],
});
Prefer this over a parentContainer for instance-scoped data.
Bridge services for legacy observables
When the existing codebase uses globalState.on*Observable to trigger UI (toasts, dialogs, etc.), don't rewrite all call sites. Add a small "bridge" service that consumes the framework service and forwards observable events:
export const ToastBridgeServiceDefinition: ServiceDefinition<[], [IGlobalStateService, IToastService]> = {
friendlyName: "Toast Bridge Service",
consumes: [GlobalStateServiceIdentity, ToastServiceIdentity],
factory: (gs, toast) => {
const observer = gs.globalState.onToastNotification.add((d) => toast.showToast(d.message, { intent: d.severity }));
return { dispose: () => observer?.remove() };
},
};
Same pattern works for DialogBridge → IDialogService, etc. Delete the legacy renderer (ToastContainerComponent, MessageDialog) once bridged.
ToolContext override per pane
Dense property panels often want size: "small" independent of the user's tool-wide setting. Override ToolContext inside the pane's content function (spread the parent first so other fields are inherited):
content: () => {
const parent = useContext(ToolContext);
const ctx = useMemo(() => ({ ...parent, size: "small" as const }), [parent]);
return <ToolContext.Provider value={ctx}><PropertyTab .../></ToolContext.Provider>;
},
Toolbar hosts global actions
Convert global buttons (Help, How-to-use, documentation links) to shellService.addToolbarItem({ horizontalLocation: "right", verticalLocation: "bottom" }). Existing top-of-canvas control bars (play/pause/undo/redo) fit naturally in { horizontalLocation: "left", verticalLocation: "top" }, removing the need for a custom bar above the canvas.
Buttons typically just notify an existing globalState.on*Requested observable so the dialog overlay logic stays where it is.
Side panes — ExtensibleAccordion, title + icon
- Use
ExtensibleAccordion (from shared-ui-components/modularTool/components/extensibleAccordion) for node lists and property tabs — gives filtering and pinning for free.
- Set
title and icon on addSidePane so the shell renders the tool name/logo in the pane header (mirrors viewer-configurator/configuratorService.tsx). Use Fluent icons first; createFluentIcon only for Babylon-specific glyphs (logo, port markers).
FileUploadLine for file inputs
Replace local FileButtonLineComponent-style components with FileUploadLine from shared-ui-components/fluent/hoc/fileUploadLine. Its callback receives a FileList — read files[0] if you previously took a single File.
Shared Dialog primitive
Use Dialog from shared-ui-components/fluent/primitives/dialog (open + title + children + actions) for ad-hoc dialogs instead of composing FluentDialog + DialogSurface + DialogBody directly.
Keep GraphCanvasComponent out of scope
The shared shared-ui-components/nodeGraphSystem/ graph canvas is consumed by every node-graph editor and still ships SCSS. Don't try to port it during a tool-level Fluent migration — only port the surrounding shell, panes, dialogs, and property panels.
Source: BabylonJS/Babylon.js — distributed by TomeVault.