원클릭으로
porting-tools-to-fluent
// 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.
// 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.
Thorough code review of branch changes. Supports automatic/interactive fixing, instruction-based, agnostic, or combined review lenses. Use when: review my code, review this branch, do a code review, review branch changes, check my changes. Input: [--base <branch>] [--mode automatic|interactive] [--lens instructions|agnostic|both]
Orchestrates the full PR lifecycle: merge upstream, create draft PR, self code review, mark ready, monitor, and iterate on fixes. Can also monitor and iterate on an existing PR. Input: [--push-remote <fork>] [--upstream-remote <remote>] [--base <branch>] [--merge] [--mode automatic|interactive] [--review-lens instructions|agnostic|both] [--pr <number>]
Monitor one or more GitHub PRs and maintain a live status table showing title, link, check status, resolved/total comments, and reviewer approval. Shows a Windows dialog when a PR is ready to merge. Input: a comma-separated list of PR numbers, "mine", or "all".
| 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. |
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).
A Fluent port replaces four layers:
createRoot + <App /> to MakeModularTool (provides theming, settings, shell layout)IShellService (central content, side panes, toolbars)shared-ui-components/fluent/ primitives and HOCsmakeStyles from @fluentui/react-components@fluentui/react-icons// 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.
"@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
Ensure the shared-ui-components alias is present:
commonDevViteConfiguration({
aliases: {
"shared-ui-components": path.resolve("../../dev/sharedUiComponents/src"),
// ... other aliases as needed
},
});
Ensure the shared-ui-components path mapping is present (no inspector mapping needed):
"paths": {
"shared-ui-components/*": ["../../dev/sharedUiComponents/src/*"]
}
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 + optional ThemeSelectorServiceShellService (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 MessageDialogMakeModularTool 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.
OpenPopupWindow from shared-ui-components/fluent/hoc/popupWindow.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.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 + optional IDisposableexport const MyServiceIdentity = Symbol("MyService");
export interface IMyService extends IService<typeof MyServiceIdentity> {
readonly someData: SomeType | undefined;
readonly onStateChanged: IReadonlyObservable<void>;
}
export const MyServiceDefinition: ServiceDefinition<[IMyService], [IShellService]> = {
friendlyName: "My Service",
produces: [MyServiceIdentity],
consumes: [ShellServiceIdentity],
factory: (shellService) => {
const onStateChanged = new 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;
},
};
shellService.addCentralContent({ key, component }) — main content areashellService.addSidePane({ key, title, icon, horizontalLocation, verticalLocation, teachingMoment, content }) — side paneshellService.addToolbarItem({ key, horizontalLocation, verticalLocation, teachingMoment, component }) — toolbar buttonAll return IDisposable — clean up in your service's dispose().
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
);
| 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 |
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.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} onChange={setUrl} className={classes.fullWidth} />
</div>
<div className={classes.buttonGroup}>
<Button icon={ArrowUploadRegular} onClick={onUpload} />
<Button icon={ArrowResetRegular} onClick={onReset} />
</div>
</div>
</PropertyLine>;
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 |
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:
min(20 / contentWidth, 20 / contentHeight)translate((20 - scaledWidth) / 2, (20 - scaledHeight) / 2)translate(centerX, centerY) scale(s) translate(-minX, -minY)For general makeStyles, spacing tokens, and inline style rules, see fluent.instructions.md. This section covers migration-specific steps.
.scss files and scssDeclaration.d.ts.style={} for truly dynamic values (e.g. drag-and-drop transforms).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>;
};
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>;
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 has a hardcoded width: 150px from Fluent's UniformWidthStyling. To make it fill available space:
<div> with flex: 1; minWidth: 0 (the fillControl pattern)className={classes.fullWidth} (with fullWidth: { width: "100%" }) to the TextInput — Griffel's deduplication ensures the external className wins over the internal 150pxclassName 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" },
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.
Do not nest ButtonLine inside PropertyLine — this creates a LineContainer inside PropertyLine resulting in double borders. Use the Button primitive directly instead.
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.
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/.
After porting, delete:
.scss / .css filesscssDeclaration.d.ts (SCSS module type declarations)FontAwesomeIconButton.tsx or similar FA wrapper componentsExpandableMessageLineComponent.tsx or similar legacy message componentsobservableHooks.ts (use shared-ui-components/modularTool/hooks/observableHooks instead)App.tsx / App.scss if the root component is replaced by shell service contentsass, scss, fontawesome, or legacy shared-ui-componentsThe 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[].
inspector path mappingcreateRoot with MakeModularToolmakeStyles@fluentui/react-iconsuseObservableState from shared-ui-components/modularTool/ instead of local hooksDistilled from the Flow Graph Editor port (packages/tools/flowGraphEditor/). See its port-to-fluent.md for the full plan.
For tools too large to port in one go (NME-class), use phases that each leave the build green:
GlobalState service, wrap the existing class component as a single addCentralContent passthrough, switch entry point to MakeModularTool.SplitContainer/Splitter once the shell owns the layout.makeStyles. One component at a time.shared-ui-components/lines/* with Fluent property-line HOCs.sharedComponents/, all .scss, obsolete package.json devDeps; run lint/format/build/e2e.MakeXService(options) factory patternWhen 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.
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 paneDense 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>;
},
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.
ExtensibleAccordion, title + iconExtensibleAccordion (from shared-ui-components/modularTool/components/extensibleAccordion) for node lists and property tabs — gives filtering and pinning for free.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 inputsReplace 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.
Dialog primitiveUse Dialog from shared-ui-components/fluent/primitives/dialog (open + title + children + actions) for ad-hoc dialogs instead of composing FluentDialog + DialogSurface + DialogBody directly.
GraphCanvasComponent out of scopeThe 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.