Agent SkillsBabylonJS/Babylon.js › porting-tools-to-fluent

porting-tools-to-fluent

GitHub

指导将 Babylon.js 工具从旧版 UI 组件迁移到 Fluent UI 的规范,涵盖依赖管理、MakeModularTool 引导、布局重构及样式适配。

.github/skills/porting-tools-to-fluent/SKILL.md BabylonJS/Babylon.js

Trigger Scenarios

port to fluent migrate to fluent fluent migration porting tool UI

Install

npx skills add BabylonJS/Babylon.js --skill porting-tools-to-fluent -g -y
More Options

Non-standard path

npx skills add https://github.com/BabylonJS/Babylon.js/tree/master/.github/skills/porting-tools-to-fluent -g -y

Use without installing

npx skills use BabylonJS/Babylon.js@porting-tools-to-fluent

指定 Agent (Claude Code)

npx skills add BabylonJS/Babylon.js --skill porting-tools-to-fluent -a claude-code -g -y

安装 repo 全部 skill

npx skills add BabylonJS/Babylon.js --all -g -y

预览 repo 内 skill

npx skills add BabylonJS/Babylon.js --list

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:

  1. Bootstrapping — from ad-hoc createRoot + <App /> to MakeModularTool (provides theming, settings, shell layout)
  2. Layout — from hand-rolled split containers / panes to IShellService (central content, side panes, toolbars)
  3. Components — from legacy shared-ui-components to shared-ui-components/fluent/ primitives and HOCs
  4. Styling — from raw SCSS/CSS to makeStyles from @fluentui/react-components
  5. 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 + 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;

        // 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 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, // 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. 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" },
});

// 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:

  1. Find the actual content bounding box (min/max of all coordinates)
  2. Compute scale: min(20 / contentWidth, 20 / contentHeight)
  3. Center: translate((20 - scaledWidth) / 2, (20 - scaledHeight) / 2)
  4. 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:

  1. Wrap in a <div> with flex: 1; minWidth: 0 (the fillControl pattern)
  2. 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:

// 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 / .css files
  • scssDeclaration.d.ts (SCSS module type declarations)
  • FontAwesomeIconButton.tsx or similar FA wrapper components
  • ExpandableMessageLineComponent.tsx or similar legacy message components
  • Local observableHooks.ts (use shared-ui-components/modularTool/hooks/observableHooks instead)
  • App.tsx / App.scss if 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

  1. Update dependencies — add Fluent + inspector, remove FontAwesome + SCSS
  2. Update Vite config — add any needed shared aliases
  3. Update tsconfig — add inspector path mapping
  4. Create services — define service identities, contracts, and factory functions
  5. Rewrite entry point — replace createRoot with MakeModularTool
  6. Port components — replace legacy components with Fluent equivalents, convert SCSS to makeStyles
  7. Replace icons — swap FontAwesome for @fluentui/react-icons
  8. Switch hooks — use useObservableState from shared-ui-components/modularTool/ instead of local hooks
  9. Delete obsolete files — remove SCSS, FA wrappers, legacy components
  10. 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:

  1. Bootstrap & shell — add Fluent deps, create a GlobalState service, wrap the existing class component as a single addCentralContent passthrough, switch entry point to MakeModularTool.
  2. Decompose layout — extract each pane into its own service that still wraps the legacy component. Delete SplitContainer/Splitter once the shell owns the layout.
  3. Port surrounding components — rewrite each pane/dialog/toolbar to Fluent + makeStyles. One component at a time.
  4. Port property panels — replace shared-ui-components/lines/* with Fluent property-line HOCs.
  5. 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);
            // 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 DialogBridgeIDialogService, 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.

Version History

  • cf656ae Current 2026-08-20 15:17

Same Skill Collection

.github/skills/code-review/SKILL.md
.github/skills/create-pr/SKILL.md
.github/skills/monitor-pr/SKILL.md

Metadata

Files
0
Version
cf656ae
Hash
a48e67e5
Indexed
2026-08-20 15:17

- 위키
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-24 22:33
浙ICP备14020137号-1 $방문자$