# @mittwald/flow-react-components — Agent Guide

**Building an app or mStudio extension in your own project?** Read
[USAGE.md](./USAGE.md) first — component selection, templates, layout and
spacing, what is safe to depend on. Everything below is for changing Flow, and
its patterns (`flowComponent`, `PropsContext`, views, CSS modules) do not belong
in an application.

Component patterns for the core package. Read the
[root AGENTS.md](https://github.com/mittwald/flow/blob/main/AGENTS.md) first for
repo-wide rules (generated code, Definition of Done, workflow).

> **Full pattern catalog:**
> [PATTERNS.md](https://github.com/mittwald/flow/blob/main/packages/components/PATTERNS.md)
> lists every convention (184 patterns) with per-pattern applicability (when to
> use / when not), a canonical example each, and a decision cheat-sheet. This
> guide covers the must-know core; look there when deciding between two
> approaches.

## Component anatomy

```
src/components/Button/
├── Button.tsx               # implementation (hand-written)
├── Button.module.scss       # styles (hand-written)
├── Button.module.d.scss.ts  # GENERATED — CSS-module class-name types
├── index.ts                 # barrel (hand-written)
├── view.ts                  # GENERATED — remote view declaration (@flr-generate only)
├── stories/
│   ├── Default.stories.tsx  # Storybook stories (expected for every component)
│   └── lib.tsx              # shared story fixtures/helpers (optional)
├── components/              # subcomponents, same anatomy (optional)
├── locales/                 # de-DE.locale.json + en-US.locale.json (when UI text)
└── *.browser.test.tsx       # behavior tests (when behavior is non-trivial)
```

The barrel exports the view first (only on `@flr-generate` components), then the
component:

```ts
export * from "./view";
export { type ButtonProps, Button } from "./Button";
export { default } from "./Button";
```

## The flowComponent factory

Nearly every component registers through
`flowComponent(name, Implementation, options?)`
(`src/lib/componentFactory/flowComponent.tsx`). It wires up the props context,
tunnel support, `wrapWith`, and remote isolation — do not rebuild any of that
manually.

```tsx
import type { PropsWithChildren } from "react";
import type Aria from "react-aria-components";
import {
  flowComponent,
  type FlowComponentProps,
} from "@/lib/componentFactory/flowComponent";

export interface ButtonProps
  extends
    PropsWithChildren<Omit<Aria.ButtonProps, "children">>,
    FlowComponentProps<HTMLButtonElement> {
  /** The color of the button. @default "primary" */
  color?: "primary" | "success" | "secondary" | "danger";
}

/** @flr-generate all */
export const Button = flowComponent("Button", (props) => {
  const { color = "primary", className, ref, ...rest } = props;
  // …
});

export default Button;
```

Conventions:

- Props type is exported as `<Name>Props`; it extends the wrapped React Aria
  props plus `FlowComponentProps<RefElement>`.
- **Ref as prop** (React 19) — no `forwardRef`.
- `options.type`: `"ui"` (default — gets props-context isolation), `"layout"`,
  or `"provider"`. The factory applies `ClearPropsContext` isolation for UI
  components itself — don't add extra clearing casually.
- The registered name must match the component/directory name, and the props
  type must be registered in `src/components/propTypes/index.ts`
  (`FlowComponentPropsTypes`) — `flowComponent` names are typed as `keyof` of
  that hand-maintained registry, so a missing entry fails the typecheck.
- Most components wrap `react-aria-components` primitives; expose ARIA props
  directly only where React Aria lacks the behavior.
- **Keep the returned JSX readable.** Lift computed nodes (icon, label, tooltip
  text) and non-trivial conditionals into named consts above the `return`; the
  returned tree should stay scannable — no large inline ternaries or deep logic
  in the markup. Split a growing tree into subcomponents.

## PropsContext — contextual composability

`PropsContext` makes components adapt **automatically** when composed inside
other components: they receive default prop values and, mainly, CSS classes from
their surroundings. This is the backbone of Flow's composability.

```tsx
// IllustratedMessage.tsx — every <Icon> inside renders large:
const propsContext: PropsContext = {
  Icon: { className: styles.icon, size: "l" },
  Heading: { className: styles.heading, color },
};

return (
  <div {...rest}>
    <PropsContextProvider props={propsContext}>{children}</PropsContextProvider>
  </div>
);
```

- Contexts **nest** (a context can configure props contexts of nested
  components) and support **dynamic props**: `dynamic((localProps) => value)`
  derives values from the consumer's props.
- Local props always win over context props.
- Although exported, `PropsContext` is **primarily an internal API** — prefer it
  for intra-Flow composition, not as a consumer-facing feature.
- **Only put remote-capable components into a `PropsContext`** — non-remote
  components break remote rendering.
- When parent context must not leak into a component's children, use targeted
  clearing, e.g. `wrapWith: <ClearPropsContext />` (see `Modal.tsx`).
- **A context that tunnels `Button` must tunnel overlay triggers as a whole** —
  spread `overlayTriggersTunneledTo(tunnel)` (`@/lib/propsContext`). An overlay
  trigger's button is the react-aria trigger: `OverlayTrigger` pins it with
  `tunnel: null`, because the press handling, the trigger ref and
  `aria-haspopup`/`aria-expanded`/`aria-controls` travel through a
  `PressResponder` that only reaches its own subtree. The list includes
  `DialogTrigger` because a host sees a different component than the author
  wrote: `ModalTrigger`, `PopoverTrigger` and `LightBoxTrigger` are not
  `@flr-generate`, so a remote tree reaches the host as a `DialogTrigger` with
  no `OverlayTrigger` around it — which is why `DialogTrigger` pins the button
  itself as well.
- **A context that places a bare overlay gets the written-out trigger for
  free.** `Badge` accepts a `ContextualHelp` on its own and wraps it with a
  trigger button (`tunnel` + `wrapWith`). `OverlayTrigger` pins its own overlay
  with `tunnel: null, wrapWith: null`, so a `ContextualHelpTrigger` the consumer
  wrote keeps its help and its button — tunnel the trigger as well and style its
  `Button` through the context, and both spellings land in the same slot.

Why this works the way it does across the remote boundary:
[docs/remote-ui.md](https://github.com/mittwald/flow/blob/main/docs/remote-ui.md).

## Views — remote-transparent composition

Components tagged `/** @flr-generate all */` get a generated view (`view.ts` +
`src/views/<Name>View.tsx`). **Inside `flr-universal` components, compose other
Flow components through their views** (`@/views/*`) — a view automatically
switches to the remote counterpart in a remote context:

```tsx
import ButtonView from "@/views/ButtonView"; // ✓ works local and remote
import { Button } from "@/components/Button"; // ✗ host-only in remote context
```

Why this works the way it does across the remote boundary:
[docs/remote-ui.md](https://github.com/mittwald/flow/blob/main/docs/remote-ui.md).

Remote generation details:

- `@flr-generate all` on the component const marks it for generation.
- `@flr-provider` carries a `flowComponent`'s `type: "provider"` over to the
  generated remote component. Without it the remote element is created as a
  `"ui"` component and wrapped in a `ClearPropsContext` — which drops every
  props context a provider around it just set. It only matters where the
  provider actually runs inside the remote tree, i.e. where the component that
  renders it is not itself `@flr-generate` (`ModalTrigger` → `OverlayTrigger` →
  `DialogTriggerView`).
- **A prop that carries rendered output has to be a slot, not a property.** A
  remote property is transported as data, and a React element carries
  `$$typeof: Symbol(react.…)` — `postMessage` refuses symbols and rejects the
  whole message, so one such prop drops the entire mutation batch and the
  extension renders nothing. `isSlot` recognises `ReactNode` and `ReactElement`
  (instantiated too — the match is anchored so the
  `AdaptChild*EventHandler<any, ReactElement<…>>` type every event prop carries
  is not swept in). What it cannot convert is a **function returning** rendered
  output, because the host has to call it: that needs an eager slot or
  `@flr-ignore-props`. `checkSerializableProps` **fails generation** on any such
  prop, so a new one cannot ship.
- **A function property's return value is a Promise on the host.** Functions do
  cross — as thread proxies — but calling one is a round trip. Type the return
  as `Promise<T> | T` and await it on the host (`ChartTooltip`'s formatters), or
  keep the prop off the remote surface with `@flr-ignore-props` (`XAxis`/`YAxis`
  `tickFormatter`). A host that reads the result synchronously gets the Promise
  itself — recharts concatenated it into every tick as `[object Promise]`.
  `checkSerializableProps` fails generation on a new one; the pre-existing set
  is listed in `acknowledgedValueReturningProps`, which can neither grow nor go
  stale.
- `@flr-ignore-props` excludes props that must not cross the remote boundary —
  either because they cannot be serialized, or because they could do **too much
  on the host side**. A global ignore list lives in
  `dev/remote-components-generator/config.ts`: `style` and
  `dangerouslySetInnerHTML` are always ignored for safety; `ref`, `controller`,
  `tunnel`, `key`, `children`, `wrapWith` because they don't serialize; and
  `renderEmptyState` plus react-aria's `render` because the host would call them
  and get rendered output back — neither was ever a deliberate Flow API, and
  both cost the whole mutation batch when they were tried. Use the per-component
  tag for additional cases (see `TunnelEntry.tsx`).
- After changing props of an `@flr-generate` component:
  `pnpm nx build:remote-components components` and **commit** the results
  (view.ts, `src/views/*`, `remote-*/src/auto-generated/**`).
- Props of these components are consumed by mStudio extension developers — no
  breaking changes; deprecate instead (see
  [Deprecating an API](#deprecating-an-api)). Why this matters:
  [docs/remote-ui.md](https://github.com/mittwald/flow/blob/main/docs/remote-ui.md).

## Deprecating an API

Never break a shipped API — keep the old path working and warn at runtime with
`useWarnDeprecation` (from `DeprecationWarningProvider`). This covers every kind
of deprecated entry point: a prop, a whole component, a hook, or an integration
export (`nextjs`, `mittwald-password-tools-js`).

```tsx
const warnDeprecation = useWarnDeprecation();
if ("action" in props) {
  warnDeprecation("The 'action' prop is deprecated. Use 'onAction' instead.");
}
```

- Warn a deprecated **prop** only when it is actually passed; warn a deprecated
  **component** unconditionally at the top of its render.
- `useWarnDeprecation` is a hook — it must run inside a render or another hook.
  A class constructor works only when it is itself a `Model.useNew`-style
  render-time hook (e.g. `List`), not when it is constructed conditionally (e.g.
  `new ItemView(…)`); warn one level up in that case.
- Messages are deduplicated per string; follow the shape
  `The '<name>' <prop|component|function> is deprecated and will be removed in a future release. Use '<replacement>' instead.`
- A type-only deprecation (e.g. a deprecated type alias) has no runtime path —
  nothing to warn.

## Styling

- One `<Name>.module.scss` per component. Scoped class names are generated from
  the component's **path** (`dev/vite/cssModuleClassNameGenerator.ts`) —
  deliberately semantic CSS that could be used standalone. Never bypass CSS
  modules for component roots (the global reset targets `flow--` classes).
- Root class is the lower-camel component name (`.button`); modifier classes
  match prop values (`.size-s`, `.primary`).
- Class composition with `clsx`, consumer `className` appended last:
  `clsx(styles.button, styles[size], styles[color], className)`.
- **Element-type selectors are a lint error** — `selector-max-type: 0` on
  `*.module.{css,scss}`, blocking since #3021. Style what the component renders,
  through its own class. Where the element genuinely carries no class —
  react-aria internals, third-party svgs, consumer-supplied children, or content
  the component does not author (`Markdown`, `Text`) — opt out explicitly and
  name the reason:
  `// stylelint-disable-next-line selector-max-type -- react-markdown output`.
  There is no autofix. Two traps when introducing a class instead: a `flow--`
  class pulls the global reset's
  `*:where([class*="flow--"] [class*="flow--"]) { font: inherit }`
  (`src/styles/globals.scss`) onto the element — that reflows `CodeBlock`'s
  `<pre>`, so it stays an element selector — and a class raises specificity
  where an element selector deliberately lost a tie (`Modal`'s `> span` against
  `.flow--heading--heading-content:empty`).
- **`:global(.flow--…)` names are checked** by
  `flow/no-unknown-global-flow-class` (`dev/stylelint/`), against the classes
  the committed `*.module.d.scss.ts` stubs declare, run through the build's own
  `cssModuleClassNameGenerator`. The reference has to name a class some
  component actually generates — otherwise the selector matches nothing, which
  used to fail without a single signal anywhere. Its ESLint twin
  `flow/no-unknown-flow-class` (`dev/eslint/`) checks hardcoded `flow--…` names
  in `src/**` TS/TSX strings — `className`s, `querySelector`s, test assertions —
  against the same set (`dev/flowClassNames/`). A runtime-built prefix
  (`` `flow--button--${color}` ``) passes if a known class starts with it; a
  test asserting a removed class stays absent needs an
  `eslint-disable-next-line` with the reason.
- **`styles` is precisely typed** by generated `*.module.d.scss.ts` stubs (a
  committed generated artifact — see the root
  [Generated code](https://github.com/mittwald/flow/blob/main/AGENTS.md#generated-code--must-be-committed)
  table, `pnpm nx build:scss-types components`). A narrow-union index
  (`styles[color]`, ``styles[`size-${size}`]``) stays type-safe as-is —
  including a helper's return, as long as the helper is typed as a
  template-literal union (see `getContainerBreakpointSizeClassName`). Only a
  `string`-typed or runtime index needs `styleClassname(styles, key)` from
  `@/lib/scss/selectors` (returns `string | undefined`) — **not** an
  `as keyof typeof styles` cast, which hides missing classes.
- **Use design-token CSS variables** — global (`--font-size-text--m`) or
  component-namespaced (`--button--corner-radius`). No hard-coded colors, sizes,
  radii.
- Shared mixins via `@use "@/styles/mixins/…"`: `focus` (focus ring),
  `formControl` (border/color/interaction states of form fields), `ellipsis`.
  Group repeated variants in local mixins.
- Structure sections with comments: `/* Elements */`, `/* States */`,
  `/* Size */`, `/* Variants */`.
- **A `z-index` is scoped by the nearest ancestor stacking context** — mark that
  element `isolation: isolate` explicitly. `position: relative`, a scroll
  container (`overflow: auto`) and a flex/grid parent are **not** stacking
  contexts, so without it the value competes with the whole page instead of the
  siblings it was written for. A `transform` animation in between is one only
  _while it runs_, so the scope otherwise changes with the animation state.
  Isolating does not reorder anything inside the component: among positioned
  siblings, `z-index: auto` and a `0`-equivalent stacking context paint in the
  same step, in tree order. The exception is an element portalled into
  `document.body` (`NotificationContainer`) — that one sits in the root stacking
  context by construction and cannot be scoped; say so in a comment.
- **Overriding a dependency that injects its own stylesheet** (CodeMirror,
  react-easy-crop, FontAwesome) needs `@layer flow.unlayered { … }`: their
  `<style>` elements are unlayered, and unlayered CSS beats layered CSS
  regardless of specificity, so a normal rule never applies in
  `all-layered.css`. Only for the library's own `:global()` selectors — the
  `flow/unlayered-third-party-only` lint rule enforces that, because the marker
  costs consumers the layer-based overridability of the rule. New or changed
  rendered behavior here gets a test in `src/tests/layered/`, which runs against
  the layered variant; the default browser project cannot see this class of bug.

## Testing — the actual bar

| Artifact                                   | When                                                                                                                                                                                     |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stories/Default.stories.tsx`              | **Always.** Realistic args, controls, meaningful variants. Story title category matches the docs (`Actions/…`, `Form Controls/…`, `Overlays/…`, `Status/…`).                             |
| `*.browser.test.tsx` (vitest browser mode) | Component has real **behavior**: interaction, controlled state, async flows, form integration, controllers. Render with `vitest-browser-react`, interact via `userEvent`, query by role. |
| `*.test.ts(x)` (unit)                      | Pure logic in `src/lib/` or component utility functions.                                                                                                                                 |
| `*.test-types.tsx`                         | Generic/typed public APIs — `expectTypeOf` plus `@ts-expect-error` negative assertions.                                                                                                  |

Run: `pnpm nx test:unit components`,
`pnpm nx test:browser components --browser.name=webkit`. The browser targets
install the Playwright browsers for you **locally**; `pnpm test:browser:prepare`
adds the system libraries Linux needs. On CI they do nothing — the workflows
provision browsers themselves, per job and under their own cache keys.

**Iterate through nx too**, not with a bare `vitest`. Everything after the `--`
goes to vitest, so a single file and watch mode have nx targets as well:

```shell
pnpm nx test:browser components -- src/components/Popover --browser.name=webkit
pnpm nx test:browser:dev components
```

Only the nx target's `dependsOn` rebuilds the design tokens the styles entry
compiles against. A bare `vitest run` skips it and renders against whatever
`packages/design-tokens/dist` happens to hold — a token added in the same branch
is simply absent, and CSS drops every declaration referencing it without a
warning (#3194).

Four traps cost time in every new browser test:

- **Click the label, not the control.** `Checkbox`, `Radio`, `Switch` and
  `Rating` stack their icons on top of a visually hidden input, so playwright
  refuses the input with `<svg …> intercepts pointer events`. Query the control
  by role for assertions and click its label text
  (`page.getByText(name, { exact: true })`).
- **Hover needs the pointer modality first.** react-aria only counts a hover as
  a hover while `getInteractionModality() === "pointer"`, and a freshly loaded
  page has no modality at all — so `Tooltip` and everything else that opens on
  hover stays closed no matter how long the test waits. Press something neutral
  with the mouse once before hovering.
- **`await render(...)` before any bare DOM query.** A locator assertion polls
  and waits the first render out; a `document.querySelector` right after
  `render()` runs before React has committed and reads `null`. That turns an
  assertion that something is _absent_ into a silent pass, so give any helper
  that queries the DOM directly a guard that throws when its element is missing.
- **A pointer left behind hovers the next render.** The pointer stays where the
  last test left it, and that can be the previous file: each browser page runs
  file after file, in parallel runs too. Firefox hovers what a test renders
  underneath. The symptom is an `expect.poll` that keeps reading a hover state
  (a Rating preview, an open tooltip) until "Matcher did not succeed in time".
  Call `parkPointer()` from `@/lib/dev/parkPointer` in the file's `beforeEach`
  (`Rating`, `PasswordCreationField`); an `afterEach` leaves the file's first
  test exposed. It is opt-in on purpose: moving the pointer before every test
  breaks `Tooltip`'s keyboard-focus test.

## i18n & a11y

- Component-internal UI text lives in colocated `locales/de-DE.locale.json`
  **and** `locales/en-US.locale.json` — always add both languages. The strings
  support ICU MessageFormat (variables, `plural`, `select` — see
  `PasswordCreationField/locales/` for real usage). Import the files with a glob
  import and consume them via the Flow hook:

  ```tsx
  import locales from "./locales/*.locale.json";
  import { useLocalizedStringFormatter } from "@/components/TranslationProvider";

  const stringFormatter = useLocalizedStringFormatter(locales, "Modal");
  ```

- Introducing a **new language** is welcome — but translate everything
  initially: every `locales/` directory in the package gets the new file.
- Icon-only buttons get a localized `aria-label`; decorative icons are
  `aria-hidden` (the `Icon` component handles this when no label is given).
- Form fields wire label/description/error via `useFieldComponent`.
  `wrapperProps` go on the outer element, `controlProps` on what assistive
  technology lands on (the input or the group), spread after `rest`: its
  `aria-describedby` references the error only while one is rendered and already
  contains the consumer's own ids.

## Public API surfaces

| Export                                                          | Contents                                                                                                                                                                                                                                            |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.` (default)                                                   | Everything listed **manually** in `src/components/public.ts` — new public components must be added there.                                                                                                                                           |
| `./internal`                                                    | Advanced internals (`flowComponent`, prop helper types, …).                                                                                                                                                                                         |
| `./flr-universal`                                               | Curated subset that works local _and_ remote. Adding to `public.ts` does **not** add here.                                                                                                                                                          |
| `./tunnel`                                                      | `@mittwald/react-tunnel` re-exported (`src/index/tunnel.ts`) plus `getTunnelProviderId`, so a consumer's own tunnels share Flow's module instance instead of creating a second React context.                                                       |
| `./nextjs`, `./react-hook-form`, `./mittwald-password-tools-js` | Integrations (`src/integrations/`): wrappers around third-party dependencies that not every consumer should pay for — they get their own export entry instead of entering the core surface.                                                         |
| `./all.css`, `./all-layered.css`                                | Bundled stylesheet, plain and `@layer`-wrapped.                                                                                                                                                                                                     |
| `./component-index`                                             | Generated consumer-facing index: every public component with its status and its own props (`dev/component-index/`). What the docs site's prop tables read, and the one prop dataset a consumer's agent should use.                                  |
| `./doc-properties`                                              | `react-docgen-typescript` output in its raw `ComponentDoc[]` shape, filtered to the props a consumer can act on. Prefer `./component-index`. The unfiltered dump the generators above read is `.cache/doc-properties.json`, which is not published. |

**Adding any new export entry?** Add the subpath to `keptSubpaths` in
`packages/codemods/src/migrations/imports-to-package-root/transform.ts`. That
codemod collapses subpath imports onto the package root with a catch-all `else`,
so a subpath that is not listed gets flattened onto a root that does not export
its names. The guard beside the transform fails the codemods unit tests when you
forget.

**Adding a new integration export entry?** Also register it in the component
status registry so it is covered: add the entry to `STATUS_EXPORT_ENTRIES`
(`dev/status-registry/exportEntries.ts`) — the `FlowExportEntry` union and the
registry regenerate from it. A generation-time guard fails if an entry resolves
to zero components.

Prop JSDoc feeds the generated `doc-properties.json` and the docs site: write
doc comments on public props, use `@default` for defaults and `@internal` for
props to hide. A deprecated **value** of a still-current prop — `Button`'s
`color="accent"` — is listed with `@deprecatedValues accent` (comma-separated
for several) and drops out of the properties table, so the table only offers
values that should still be used. The value stays in the prop's type and keeps
working; the runtime warns via `useWarnDeprecation` as usual.

## Misc

- Application-wide component defaults: `ComponentDefaultsProvider`
  (`src/components/ComponentDefaultsProvider/`) is where a behavior default that
  an application should be able to set **once** belongs. Add the setting to the
  `ComponentDefaults` interface plus its built-in value in
  `builtInComponentDefaults`, and read it with `useComponentDefaults("<Name>")`
  at the place that decides. Resolution order: local prop → provider →
  deprecated `flags` → built-in default. This is not a replacement for
  `PropsContext`: a UI component clears the props context for its children, so
  PropsContext cannot carry an app-wide default past the first UI ancestor.
- Feature flags: `src/flags.ts` is the deprecated predecessor of the
  `ComponentDefaultsProvider`. Assigned flags still act as an application-wide
  default (and warn via `useWarnDeprecation`); do not add new ones.
- `SettingsProvider` (`src/components/SettingsProvider/`) is the built-in
  persistence for component settings (e.g. `List` remembering its view
  settings), with pluggable backends (localStorage by default). Internal
  component infrastructure — extension developers don't need it.
- `stories/lib.tsx` holds story-only fixtures — never import it from component
  code.
- Storybook discovers all `src/**/*.stories.tsx` automatically; there is no
  registry to update.

## Non-obvious conventions

Easy-to-miss conventions not spelled out above. Full details and examples in
[PATTERNS.md](https://github.com/mittwald/flow/blob/main/packages/components/PATTERNS.md).

- **`PropsContext` is structural, not just styling** — nested entries, `dynamic`
  children, semantic defaults (icon size, heading level, status), and contextual
  `wrapWith` define much of a composite's internal API.
- **The factory supplies hidden infrastructure** — memoization, nested-context
  preservation, slot propagation, UI isolation, and tunnel entry/provider wiring
  are all automatic consequences of `flowComponent`; don't rebuild them.
- **Raw string children get `Text`-normalized** where typography is
  context-driven (detect raw strings, wrap in `Text`); explicit structured
  children are left intact.
- **Semantic generated CSS classes are coordination points** — scoped modules
  still use `:global(.flow--…)` when independently rendered Flow descendants
  must affect layout. Copy the name from the target component's
  `*.module.d.scss.ts`; the generator drops a suffix that equals the component
  name, so a hand-derived name silently matches nothing.
  `flow/no-unknown-global-flow-class` blocks that.
- **Controllers coexist with declarative props** — overlay-like APIs support
  controlled/uncontrolled props _and_ a controller object, not one or the other.
- **A component without `value` and without `defaultValue` still has to render
  as controlled** — react-aria's `useControlledState` reads only `undefined` as
  uncontrolled. A component that mirrors the value in its own state therefore
  hands react-aria `undefined` on the first render and a real value after the
  first change: the value changes owner mid-flight, and React and react-aria
  warn `A component changed from uncontrolled to controlled`. Pass a sentinel
  instead of `undefined` — `null` where "nothing selected yet" is representable
  (`Tabs`' `selectedKey`), otherwise the type's empty value (`""` for text,
  `NaN` for a number, `null` for a date range — `useControlledHostValueProps`'
  `emptyValue`). Mind `??` once the sentinel is `null`: a caller-supplied `null`
  must not fall through to `defaultValue`. Nothing breaks visibly, and a
  component holding its own document state (`CodeEditor`) does not even warn —
  it changes owner silently.
- **Complex behavior is split by vocabulary** — `components/` for render,
  `hooks/` for behavior, `lib/` for pure transforms, `models/` for durable
  state.
- **`SettingsProvider` combines async resources, hierarchical MobX stores, and
  serialized, Suspense-aware writes** — reuse it rather than rolling
  persistence.
- **The empty `Default` story is intentional** — realistic args and rendering
  live in the typed `meta`, so `export const Default: Story = {}` is the norm.
- **CSS leans on modern relational/low-specificity selectors** — `:has`,
  `:where`, logical properties, data attributes, and container boundaries reduce
  the need for runtime styling props.
- **Foreign SVGs go through `Icon`** — a Tabler icon or a custom SVG placed
  straight into a host that styles its icon slot (`Button` and `Combine` select
  on `:has(.icon)`; eleven components set `Icon` defaults through
  `PropsContext`) never gets the `flow--icon` class, so icon sizing and
  icon-only padding silently do not apply. Wrap it — `<Icon><IconFoo /></Icon>`
  — as the [Icon page](https://flow.mittwald.de/components/content/icon)
  documents. Nothing errors: types, lint and the console stay clean.
- **A collection child is rendered twice** — react-aria renders the children of
  a collection (`Select`, `ComboBox`, `Autocomplete`, `Menu`, `ListBox`, `Tree`,
  …) once into its hidden collection document and once for real. Both renders
  are full component instances, so anything derived per instance happens twice
  for one logical element: `useId` returns two ids, effects run twice, and every
  registration into shared state lands twice. Whatever such a child registers
  therefore needs an id derived from the element itself, not a generated one —
  options pass their collection key as the tunnel's `staticEntryId`
  (`Option/optionsTunnel.ts`), and a new tunnelled collection child must do the
  same. Without it the tunnel exit renders each child twice: the keys stay
  unique, so the DOM and the tests look right, but the collection holds two
  nodes per key and its `prevKey`/`nextKey` chain closes into a ring — `ArrowUp`
  on the first item wraps to the last, and react-stately's focus restoration
  walks `getKeyBefore` forever and freezes the tab (#3146).
- **Universal exports are deliberately explicit** — remote-safe values and their
  types are curated in `flr-universal.ts` independently of the main public
  surface; adding to `public.ts` does not add them there.
