Skip to content

Latest commit

 

History

History
180 lines (152 loc) · 9.99 KB

File metadata and controls

180 lines (152 loc) · 9.99 KB

Forms

The paved road for building forms in the app. The schedule form is the golden example; when building or fixing any form, copy its shape, not the shape of whatever screen you happen to be near.

Golden example files:

  • packages/app/src/schedules/schedule-form-model.ts (+ .test.ts) - the model
  • packages/app/src/schedules/use-schedule-form-model.ts - model lifetime adapter
  • packages/app/src/schedules/use-schedule-form-provider-snapshot.ts - async input adapter
  • packages/app/src/components/schedules/schedule-form-sheet.tsx - render + intent dispatch
  • packages/app/src/schedules/aggregated-schedules.ts / hooks/use-schedules.ts - load-state gating
  • packages/app/e2e/schedules-*.spec.ts - the behavioral contract

The form model

Every non-trivial form gets a plain TypeScript model - zero React imports:

  • openXxxForm(snapshot) constructs a fresh instance from declared inputs (mode, the record being edited, hosts, defaults). Edit mode seeds every value AND display from the snapshot - never from a previous instance.
  • Commands mutate (setHost, setProject(value, display), setModel, …). Derived state (disclosure, canSubmit, displays) is recomputed inside the model on every publish.
  • close() destroys the instance. subscribe/getState feed one useSyncExternalStore in the component.

The component renders state and dispatches intent. That is all it does.

Lifecycle rules (each one killed a real shipped bug)

  1. Fresh mount per open. The sheet returns null when not visible and mounts the open form with a key derived from mode + record identity. A long-lived component instance shared across create/edit is how edit contaminated create.
  2. Construct the model ONCE per mount - useState(() => openXxxForm(snapshot)). NEVER useMemo(() => open(...), [snapshot]): the snapshot's identity depends on live data (projects, hosts, preferences), and any background churn - e.g. a scheduled run creating a workspace - would reconstruct the model and wipe the user's in-progress input.
  3. Late data is an explicit model input, not a reconstruction. applyProviderSnapshot(serverId, …), applyProjectTargets(…), applyHosts(…). Adapters pipe identity changes into these with mechanical effects. Input plumbing is fine; orchestration effects are not - the sheet itself has zero useEffect/useRef, and that is the target for every form.
  4. Resolution is explicit model state, per host (idle | pending | complete), keyed off the opened snapshot's serverId. Waiting for data is a state you can render, not an effect race.
  5. Displays are owned state. The selected option's label is captured at selection/seed time (setProject(value, display)), never re-derived from a live options list - list churn must not flicker or blank a selection.
  6. Disclosure is derived in the model from user intent (host → project → model → thinking/mode), so fields cannot pop in from cache timing.

Form kit

  • Compose Field / SelectField / FormTextInput / SegmentedControl / Switch from components/ui/. Geometry (heights, padding, radii, focus/hover states) is owned by components/ui/control-geometry.ts - controls never declare their own, and screens never nudge global component styles to align a row.
  • Every text field consumes EditingTextInput, directly or through a UI wrapper. The editing surface owns in-progress text; React observes committed edits through onChangeText, and programmatic mutations use the input's replaceText command. Its props omit value and defaultValue, web IME candidates stay unpublished until composition commits, and lint rejects raw React Native TextInput imports outside the primitive.
  • The form declares one size for all fields: sm on desktop, md compact (useIsCompactFormFactor).
  • Availability hierarchy: a field whose capability doesn't apply is hidden (isolation on a non-git project - same gating as New Workspace), not rendered disabled with an explanation. Disabled-with-a-reason hint is only for transient states the user can resolve.
  • Copy is opt-in and rare. No hint/subtext unless the maintainer approved the exact string; validation errors are the exception. State a fact (like the timezone) once - never in a preview line AND a helper line.
  • useUnistyles is banned (see docs/unistyles.md); lint enforces.

Dialog chrome

Every dialog is an AdaptiveModalSheet (or TabbedModalSheet for a tabbed body). The chrome is the primitive's job, not the screen's - a dialog that hand-rolls any of the below is a bug:

  • Actions are pinned, never scrolled. Cancel/Save/OK go in the footer prop, which supplies the top border, padding, and outer alignment. The row you pass declares only { flex: 1, flexDirection: "row", alignItems: "center", gap: spacing[3] }; re-adding padding or a border double-counts it. Buttons that split the bar carry flex: 1. Action buttons rendered inside children scroll out of reach the moment the body grows - that is the bug this replaces.
  • Scroll regions get the full treatment. useSheetScrollRegion bundles the top/bottom seam fades and the app's hover-hiding overlay scrollbar; the sheet and TabScrollView both go through it, so no dialog wires useScrollEdgeFades and useWebScrollViewScrollbar together by hand. webScrollbar defaults on.
  • The body indent lives inside the scroll view. SHEET_HORIZONTAL_PADDING_SCALE is the one indent token, shared by header, body, and footer so they sit on one vertical line. When children own their own scroll region, pass contentPadding={false} and apply the indent to the scroll view's contentContainerStyle instead. Padding on a wrapper outside the scroller means every field runs flush to the scroll box, and overflow: hidden slices the focus ring off both sides. contentPadding={false} drops the wrapper's inter-child gap too - those children pad themselves off whatever sits above, so a wrapper gap would stack on top and the spacing would stop being uniform.
  • Multi-line fields use TextArea (or wrap a specialised input in TextAreaScrollFrame). A raw <textarea> that overflows paints the browser's own permanent, unthemed scrollbar square against the field's rounded border.
  • Test mocks must render footer. A mock of AdaptiveModalSheet that only renders children makes every action-button query return null.

Settings rows

Use the named components from @/components/settings for settings cards and controls. SettingsCard owns row separators; SettingsRow accepts custom content. Heading-only screens can import SettingsGroup and SettingsSection directly from their modules in @/components/settings/headings/. The plugin UI entry exposes the same components; keep them independent of storage and routing.

Settings search targets and nested forms

The Settings shell owns search requests in screens/settings-search/target.tsx. Rows register explicit semantic IDs rather than looking up visible English text. SettingsField, SettingsSelectField and the Settings heading adapter supply an optional Label renderer to the existing primitive. A field={false} selector uses its existing selected-value label. These adapters add no row wrapper and leave theme fonts, sizing, focus rings and platform gates with the control.

Interactive targets use SettingsButton, SettingsPressable, SettingsEditingTextInput or SettingsMenuItem. They forward refs and layout to the same control; ordinary tab order, input reset/caret ownership, menu selection and action callbacks remain with that control. A search never invokes Save, Delete, Connect, tool fetching or another side effect to expose a destination.

Nested editors retain explicit entity choice. SettingsAdaptiveModalSheet and SettingsTabbedModalSheet compose the sheet's contextBridge; an independently mounted host carries the optional setting ID explicitly and uses the context-only SettingsSearchProvider. The existing AdaptiveModalSheet body and TabScrollView expose their scroll/content refs through ScrollViewportContext. Native search measures against the nearest actual viewport; fixed controls can receive accessibility focus without an invented scroll container. Unmounted or not-yet-laid-out targets stay pending, and navigation invalidates old measurements.

Input keyboard coordination is separate: native EditingTextInput uses SheetAwareTextInput, which reads BottomSheetScope and chooses the existing BottomSheetTextInput inside a sheet. Screens do not maintain a second sheet input registry. AdaptiveTextInput retains explicit resetKey replacement through the editing handle; changing a seed value alone does not overwrite an active edit.

Plugin Settings discovery projects the live, host-scoped plugin registry after its capability gate. It uses the plugin's registered screen identity and route; it does not copy registration state into another catalog. Static built-in search rows remain in docs/settings-inventory.md, generated by scripts/generate-settings-search-catalog.mjs.

Data gating

Aggregate hooks return a discriminated load state:

type AggregateLoadState<T> =
  | { status: "connecting" } // an answer may still be pending
  | { status: "loading" }
  | { status: "loaded"; data: T[] };

Empty states are only typeable inside loaded - a fetch that "succeeded" before hosts connected is connecting, not empty. Query keys carry real fetch inputs (host set, connection statuses), never synthetic version counters.

Anti-patterns (reject in review on sight)

  • useEffect choreography impersonating construct/hydrate/resolve/destroy.
  • One mounted form instance serving create and edit.
  • useMemo-keyed model construction on live-data identity.
  • Selected labels derived from live query lists.
  • isLoading/isEmpty boolean bags where a load-state union belongs.
  • Conditional mounting of hint/error rows that shifts layout (subtext renders only when present, but the pattern for that lives in Field, not ad hoc).