| 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).
Design guidelines: For the shared design system these ports target — color schema, shell/panel structure, component and styling conventions — see .github/design-guidelines.md.
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 | 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. 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={ArrowUploadRegular} onClick={onUpload} />
<Button icon={ArrowResetRegular} onClick={onReset} />
</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",
'<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.