porting-tools-to-fluent
GitHub指导将 Babylon.js 工具从旧版 UI 组件迁移到 Fluent UI 的规范,涵盖依赖管理、MakeModularTool 引导、布局重构及样式适配。
Trigger Scenarios
Install
npx skills add BabylonJS/Babylon.js --skill porting-tools-to-fluent -g -y
SKILL.md
Frontmatter
{
"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 />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 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;
},
};
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} 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", // 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} 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
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.
Version History
- cf656ae Current 2026-08-20 15:17


