Standalone Fluid widget project generated by @fluid-app/fluid-cli-widget.
This project builds and publishes a widget package only. It does not scaffold droplets, a full portal application, or backend services.
pnpm dev # Run local widget protocol endpoints
pnpm typecheck # Run TypeScript checking
pnpm validate # Validate package metadata with the Fluid widget CLI
pnpm build # Validate and build runtime artifacts
pnpm run widget:link # Link this project to an existing Fluid droplet
pnpm run widget:publish # Publish to the linked droplet
pnpm run widget:publish --dry-run # Build upload payload without uploading.
├── AGENTS.md # Portable AI authoring guidance
├── CLAUDE.md # Claude-compatible AI authoring guidance
├── .agents/skills/ # Agent skill docs for this project
├── .claude/skills/ # Claude skill docs for this project
├── fluid.widget.config.ts # Widget CLI ownership + package exports
├── manifest.ts # defineWidget/defineWidgetPackage metadata
├── styles.css # Shadow-root runtime styles
├── src/index.ts # Remote DOM worker entry
└── src/widgets/review-carousel/ReviewCarousel.tsx
pnpm dev exposes:
/__widget-packages__: the local canonical package descriptor consumed by Fluid hosts./__runtime-entry__: the Remote DOM worker entry referenced by that descriptor.
The standalone project does not include a second host or iframe preview app. Portal and builder hosts render the worker through the shared Remote DOM runtime.
The standalone source of truth is manifest.ts, with droplet ownership in
fluid.widget.config.ts. Company portal projects instead use
src/widgets.config.ts and fluid portal deploy.
Stay inside the standalone widget package:
- Put reusable widget components under
src/widgets/. - Put package and palette metadata in
manifest.ts. - Keep runtime registration in
src/index.ts. - Keep runtime styles in
styles.cssor CSS files imported by widget modules. - Do not add Next.js app folders, Rails code, API servers, portal app shells, or droplet scaffolding.
Droplet ownership is managed by the Fluid widget CLI through fluid.widget.config.ts. Link the project with pnpm run widget:link or pass a droplet option to CLI commands when needed.
Widgets are authored with defineWidget() and grouped with defineWidgetPackage() from @fluid-app/portal-sdk/widgets/worker.
defineWidget() describes one widget:
name: stable URL-safe widget name. Changing it changes the generated widget type.component: React component rendered inside the Remote DOM worker.displayName,description,icon,category: builder palette metadata.defaultProps: JSON-serializable defaults for new widget instances.propertySchema: builder property panel fields.container:block,card,inline, orfullscreen.uses: every typed portal function called by this widget. Pass the function values instead of writing capability names.resizable: false/omitted, true,horizontal,vertical,both, or an object with horizontal/vertical flags and optional minimum sizes.
defineWidgetPackage() describes the package:
- Keep
packageTypeset todropletfor this template. - Keep
widgetsas the array of widgets exported by this package. - Use a SemVer
versionwithout build metadata. - Do not manually set
packageStableIdin this generated droplet template; the CLI injects the linked droplet key during validation, build, and publish. - Do not author runtime artifact URLs. Dev and build inject the served
workerEntryUrl.
manifest.ts should remain the single source of package metadata:
import {
defineWidget,
defineWidgetPackage,
} from "@fluid-app/portal-sdk/widgets/worker";
import { ExampleWidget } from "./src/widgets/example/ExampleWidget";
export const exampleWidget = defineWidget({
name: "ExampleWidget",
component: ExampleWidget,
displayName: "Example Widget",
description: "A focused reusable Fluid widget.",
icon: "box",
category: "components",
container: "card",
defaultProps: {
title: "Featured content",
},
propertySchema: {
tabsConfig: [{ id: "content", label: "Content" }],
dataSourceTargetProps: ["title"],
fields: [
{
key: "title",
label: "Title",
type: "text",
defaultValue: "Featured content",
tab: "content",
group: "Copy",
},
],
},
});
export const widgetPackage = defineWidgetPackage({
scope: "droplet",
version: "0.1.0",
packageType: "droplet",
widgets: [exampleWidget],
});
export const widgetPackages = [widgetPackage] as const;
export default widgetPackage;Worker code imports typed portal functions from
@fluid-app/portal-sdk/widgets/worker, calls them directly, and lists the same
function values in the widget's uses array:
import {
defineWidget,
getUserAccount,
navigateTo,
} from "@fluid-app/portal-sdk/widgets/worker";
async function openProfile(): Promise<void> {
const account = await getUserAccount();
await navigateTo(account.slug);
}
export const profileButtonWidget = defineWidget({
name: "ProfileButton",
component: ProfileButton,
uses: [getUserAccount, navigateTo],
});Available built-ins are getUserAccount, getStore, getPortalApp,
getPortalProfile, getNavigationState, buildPortalHref, navigateTo,
getFullscreenState, requestFullscreen, and exitFullscreen. Getters await
and return resolved data instead of exposing query-status snapshots. A
navigation target may be a slug string, { slug }, or { href }.
defineWidget() derives the existing descriptor declarations from uses.
Both the worker and host enforce those declarations. If a call fails with
PortalFunctionError code NOT_DECLARED, add that function to the widget's
uses array. Do not author a raw capabilities array.
getUserAccount() returns an allowlisted account view. It may include email,
but excludes phone numbers, addresses, payment details, credentials,
government or tax identifiers, dates of birth, raw metadata, and unknown
future fields. The host's tenantClient is not exposed to widgets.
For company-specific operations, define a shared typed function with
definePortalFunction<Output, Input>({ capability, version, method }), call it
directly, and include it in uses. The portal owner installs a typed host
handler with implementPortalFunction(function, handler) in
remoteWidgets.functions. Built-ins cannot be overridden; duplicate custom
implementations are rejected.
Custom inputs and outputs must be JSON values: null, booleans, finite numbers,
strings, arrays, and objects composed from those values. Do not use
undefined, functions, Dates, Maps, Sets, class instances, NaN, or
Infinity. The runtime validates the protocol boundary as well as the
TypeScript contract.
Property schemas define editable builder controls. They must be JSON-serializable.
Top-level schema keys:
tabsConfig: optional array of tab objects withidandlabel.fields: array of controls shown in the builder property panel.dataSourceTargetProps: prop keys that can be populated from configured data sources.itemConfigSchema: optional per-item configuration schema for custom data-source selections.validate: optional custom validator in in-app manifests; avoid it for published standalone metadata because package metadata must be serializable.
Base field keys:
| Key | Purpose |
|---|---|
key |
Widget prop key written by the field. Use a unique non-prop key for visual-only controls. |
label |
Builder label. |
type |
Supported field type listed below. |
description |
Optional helper text. |
defaultValue |
Optional JSON-serializable default. |
tab |
Optional tab id matching tabsConfig. |
group |
Optional group label inside a tab. |
advanced |
Marks theme overrides or low-frequency settings; advanced fields render in Custom styling. |
requiresKeyValue |
Optional conditional display rule. An array means all conditions must match. |
Supported field types:
| Type | Use | Extra keys |
|---|---|---|
text |
Single-line string | placeholder, maxLength, tokenSuggestions |
textarea |
Multi-line string | placeholder, rows, maxLength |
number |
Number input | min, max, step |
boolean |
Toggle | None |
select |
Dropdown | options array with label and value |
color |
Basic color value | None |
range |
Slider | Required min, max; optional step |
dataSource |
Data-source selector/configuration | None |
resource |
Single shareable/resource picker | allowedTypes |
image |
Media picker | accept as image, video, or any |
alignment |
Alignment picker | options.verticalEnabled, options.horizontalEnabled |
slider |
Numeric slider | min, max, step, unit |
colorPicker |
Color picker | swatches |
sectionHeader |
Visual section heading | subtitle |
separator |
Visual divider | None |
buttonGroup |
Segmented choice | options with value, optional label, ariaLabel, icon |
colorSelect |
Semantic theme color picker | excludeColors |
sectionLayoutSelect |
Visual section layout picker | None |
background |
Combined background resource/color control | Resource/color field keys as needed |
contentPosition |
3-by-3 content position picker | None |
textSizeSelect |
Theme text-size picker | None |
cssUnit |
Number with unit selector | allowedUnits, defaultUnit, minByUnit, maxByUnit, stepByUnit |
fontPicker |
Google font picker | placeholder |
stringArray |
Editable list of strings | placeholder |
borderRadius |
Composite four-corner radius editor | keys.topLeft, keys.topRight, keys.bottomLeft, keys.bottomRight |
screenPicker |
Portal screen picker | includeSystemItems |
For props that may later be connected to data sources:
- Add the prop key to
dataSourceTargetProps. - Keep the component tolerant of missing, empty, or partially populated values.
- Render useful empty states for empty arrays and failed mapping.
- Keep prop values JSON-serializable.
- Avoid fetching inside the component when a prop or data source can provide the data.
- Use
itemConfigSchemawhen each selected item needs widget-specific settings.
- Use TypeScript interfaces for public props.
- Provide defaults for optional props.
- Treat incoming props as untrusted and validate before reading nested values.
- Keep render logic deterministic and side-effect free.
- Clean up effects and subscriptions.
- Keep bundles small; avoid heavy libraries unless the widget requires them.
- Avoid secrets, tenant credentials, local URLs, and environment-only assumptions.
- Prefer semantic HTML over div-only structures.
- Use headings, sections, lists, buttons, labels, and form elements semantically.
- Make every interactive element keyboard reachable.
- Provide visible focus states.
- Add
aria-labelto icon-only controls. - Add alt text to meaningful images and empty alt text to decorative images.
- Preserve logical heading order inside the widget.
- Use ARIA live regions for important dynamic updates.
- Respect reduced-motion preferences for animation-heavy widgets.
- Use semantic foreground/background token pairs for contrast.
Fluid hosts provide semantic CSS variables. Prefer token-based CSS instead of hard-coded palettes.
Common CSS variables:
- Surfaces:
--background,--card,--popover. - Text:
--foreground,--card-foreground,--popover-foreground. - Brand/actions:
--primary,--primary-foreground. - Supporting UI:
--secondary,--secondary-foreground,--muted,--muted-foreground,--accent,--accent-foreground. - Status/chrome:
--destructive,--destructive-foreground,--border,--input,--ring. - Charts:
--chart-1,--chart-2,--chart-3,--chart-4,--chart-5. - Radius:
--radius,--radius-sm,--radius-md,--radius-lg,--radius-xl. - Theme-engine aliases may include
--font-header,--font-body,--font-size-extra-small,--font-size-small,--font-size-regular,--font-size-large,--font-size-extra-large, and--font-size-giant.
Tailwind equivalents, when Tailwind is available, are semantic utilities such as bg-background, text-foreground, bg-card, text-card-foreground, bg-primary, text-primary-foreground, border-border, ring-ring, rounded-lg, text-sm, and text-xl. This standalone template uses plain CSS for runtime portability.
Light/dark behavior:
- Fluid themes update variables for light and dark mode.
- Explicit dark mode is represented by
data-theme-mode="dark"on a host element. - Some hosts also use system dark mode when no explicit mode is selected.
- Prefer semantic variables so both modes work automatically.
- If mode-specific styling is unavoidable, scope it with
[data-theme-mode="dark"]and keep values token-based.
Runtime CSS rules:
- Import runtime CSS from the guarded build import in
src/index.tsor another worker module included in the build. - Prefix selectors with the widget name to prevent host leakage.
- Do not rely on
bodystyles for published runtime rendering. - If published styles are missing, check that the CSS import is reachable from the built entry.
Before shipping changes, run:
pnpm typecheck
pnpm validate
pnpm build
pnpm run widget:publish --dry-runPublish only after the dry run succeeds:
pnpm run widget:publishBuild output is written under .fluid/widget-dist/. The runtime entry starts the package in a worker with startWidgetPackage(), while hosts discover its canonical descriptor through the widget catalog.
| Failure | Fix |
|---|---|
| Missing droplet UUID | Run pnpm run widget:link or pass a droplet option to validate/build/publish. |
| No source package found | Ensure fluid.widget.config.ts exports widgetPackage or widgetPackages from manifest.ts. |
| Invalid package type | Keep packageType: "droplet". |
| Invalid package key or widget name | Use URL-safe letters, numbers, dots for package keys, and underscore/hyphen/tilde where allowed. |
| Invalid version | Use SemVer without build metadata. |
| Non-serializable metadata | Remove functions, undefined values, Dates, NaN, Infinity, Maps, Sets, and class instances from propertySchema and defaultProps. |
| Relative CSS URL rejected | Import CSS into the bundle or remove manual cssUrls entries. |
| Styles are absent after publish | Confirm styles.css is reachable from the guarded build import in src/index.ts. |
| Published widget fails | Guard browser-only APIs, remove local-only URLs, and avoid environment variables required at runtime. |