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 />toMakeModularTool(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
makeStylesfrom@fluentui/react-components - Icons — from FontAwesome to
@fluentui/react-icons
1. Dependencies
Add
// package.json devDependencies
"@fluentui/react-components": "^9.x", // for makeStyles, tokens, low-level Fluent components
"@fluentui/react-icons": "^2.x" // for all icons
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": "..." // if no other SCSS remains
Vite config
Ensure the shared-ui-components alias is present:
commonDevViteConfiguration({
aliases: {
"shared-ui-components": path.resolve("../../dev/sharedUiComponents/src"),
// ... other aliases as needed
},
});
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:
// BEFORE
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
// AFTER
import { MakeModularTool } from "shared-ui-components/modularTool/modularTool";
MakeModularTool({
namespace: "MyToolName",
containerElement: document.getElementById("root")!,
serviceDefinitions: [
/* your service definitions */
],
toolbarMode: "compact", // "compact" for minimal toolbar, "full" for full toolbar
showThemeSelector: true, // adds theme toggle to toolbar
// Do NOT pass extensionFeeds to disable the extensions dialog
});
MakeModularTool automatically provides:
FluentProvider+ theme (light/dark)SettingsStore(persisted user preferences)ThemeService+ optionalThemeSelectorServiceShellService(layout: central content, side panes, toolbars)ToastProvider+IToastServicefor toast notifications (consumeToastServiceIdentity— do not roll your own container)IDialogService(consumeDialogServiceIdentity) for modal alert/confirm dialogs — replaces ad-hocMessageDialog
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
OpenPopupWindowfromshared-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 legacyCreatePopupfromshared-ui-components/popupHelper— it copies stylesheets into the popup. Fluent andCreatePopupcoexist 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 debuggingproduces— array of service identity symbols this service providesconsumes— array of service identity symbols this service depends onfactory(…consumedServices)— returns an object satisfying the produced contracts + optionalIDisposable
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 Observable<void>();
let someData: SomeType | undefined;
// Register shell content
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 areashellService.addSidePane({ key, title, icon, horizontalLocation, verticalLocation, teachingMoment, content })— side paneshellService.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, // getter
myService.onStateChanged // observable to subscribe to
);
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 |
ColorPickerPopup (primitive) or Color4PropertyLine (with label) |
shared-ui-components/fluent/primitives/colorPicker or .../hoc/propertyLines/colorPropertyLine |
LockObject |
Not needed (Fluent property lines don't use it) | — |
FontAwesomeIconButton |
Button with icon prop |
shared-ui-components/fluent/primitives/button |
SplitContainer / Splitter |
Shell service layout (side panes) | Handled by MakeModularTool |
When to use PropertyLine vs primitives
PropertyLine— Use when a row has a label. RendersInfoLabel+ child content in a standardized layout with hover border. Import fromshared-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" },
});
// Example: TextInput + action buttons
<PropertyLine label="Model URL">
<div className={classes.propertyContent}>
<div className={classes.fillControl}>
<TextInput value={url} className={classes.fullWidth} />
</div>
<div className={classes.buttonGroup}>
<Button icon={ArrowUploadRegular} />
<Button icon={ArrowResetRegular} />
</div>
</div>
</PropertyLine>;
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 |
SearchRegular |
faLink |
LinkRegular |
Custom SVG icons
Use createFluentIcon for custom icons (e.g. Babylon logo):
import { createFluentIcon } from "@fluentui/react-icons";
export const MyIcon = createFluentIcon(
"MyIcon",
"1em", // width — "1em" sizes with font-size
// Single string for complex SVG (supports fill colors):
'<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
.scssfiles andscssDeclaration.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>withflex: 1; minWidth: 0(thefillControlpattern) - Pass
className={classes.fullWidth}(withfullWidth: { width: "100%" }) to theTextInput— 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:
// Service framework
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";
// Shell service
import { type IShellService, ShellServiceIdentity } from "shared-ui-components/modularTool/services/shellService";
// Hooks
import { useObservableState } from "shared-ui-components/modularTool/hooks/observableHooks";
// Fluent primitives and HOCs
import { Accordion, AccordionSection } from "shared-ui-components/fluent/primitives/accordion";
import { Button } from "shared-ui-components/fluent/primitives/button";
// ... etc.
No inspector/ imports are needed for tools — everything comes from shared-ui-components/.
9. File Cleanup Checklist
After porting, delete:
- All
.scss/.cssfiles -
scssDeclaration.d.ts(SCSS module type declarations) -
FontAwesomeIconButton.tsxor similar FA wrapper components -
ExpandableMessageLineComponent.tsxor similar legacy message components - Local
observableHooks.ts(useshared-ui-components/modularTool/hooks/observableHooksinstead) -
App.tsx/App.scssif the root component is replaced by shell service content
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} />;
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
inspectorpath mapping - Create services — define service identities, contracts, and factory functions
- Rewrite entry point — replace
createRootwithMakeModularTool - Port components — replace legacy components with Fluent equivalents, convert SCSS to
makeStyles - Replace icons — swap FontAwesome for
@fluentui/react-icons - Switch hooks — use
useObservableStatefromshared-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
GlobalStateservice, wrap the existing class component as a singleaddCentralContentpassthrough, switch entry point toMakeModularTool. - Decompose layout — extract each pane into its own service that still wraps the legacy component. Delete
SplitContainer/Splitteronce 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, obsoletepackage.jsondevDeps; 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);
// wire options into globalState...
return { globalState, dispose: () => { /* cleanup */ } };
},
};
}
// In Show(options):
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(fromshared-ui-components/modularTool/components/extensibleAccordion) for node lists and property tabs — gives filtering and pinning for free. - Set
titleandicononaddSidePaneso the shell renders the tool name/logo in the pane header (mirrorsviewer-configurator/configuratorService.tsx). Use Fluent icons first;createFluentIcononly 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.