Skip to content
Source
Sign inSign up

web-ui.md

74 lines4.5 KB

1# Web UI
2​
3## Primitives
4​
5Controls come from [`components/ui`](../../apps/web/src/components/ui).
6[AGENTS.md](../../AGENTS.md#taste) says how to use and extend them.
7​
8Don't style a raw `<button>`, `<input>`, `<select>`, or `<textarea>` to look like a control a
9primitive provides. Raw elements are right when the element isn't that control: semantic rows, tabs,
10resize handles, swatches, editor surfaces, anything whose behavior or geometry differs by design.
11​
12## Styles
13​
14Style with Tailwind classes in the component that renders the element.
15​
16- Global CSS, in [`index.css`](../../apps/web/src/index.css) or any other stylesheet, is only for
17 what classes on the owning component can't express: generated Markdown or imperative DOM, custom
18 elements and shadow roots, animations, runtime theme variables, and browser or Electron
19 integration.
20- No `style={{ ... }}` or inline `<style>` for static values a class covers. Values computed at
21 runtime are fine.
22- Theme-only rules use `@variant dark` and `@variant light`, not `.dark` selectors.
23​
24## Composition
25​
26The composer banners are the reference.
27​
28- [`ComposerBanner.tsx`](../../apps/web/src/components/chat/ComposerBanner.tsx) exports one object
29 of small slots. Slots own their classes, take `className`, and spread the rest of their props,
30 merging classes with `cn`. Variants and density are props on `Root`. A slot that needs a control
31 renders `Button`.
32- [`ComposerBannerStack.tsx`](../../apps/web/src/components/chat/ComposerBannerStack.tsx) composes
33 the slots and owns only stack behavior: priority, expand and collapse, dismiss transitions.
34​
35Build new UI the same way. Compose or extend a component instead of copying its markup and classes.
36When a call site repeats a treatment another one already uses, make it a variant or a slot on the
37shared component. A new composer banner or notice uses the `ComposerBanner` slots.
38​
39## Handlers in large components
40​
41V8 gives every closure created in one render a single shared scope. A memoized handler keeps
42the scope of the render that created it, and that scope holds the other memoized values of that
43render. When handlers in one component change at different times, each keeps an older scope that
44holds older handlers, and old renders, with their server config and thread data, stay in memory
45for as long as the component is mounted. React Compiler's memoized closures do the same.
46​
47In large, long-lived components such as `ChatView` and `ChatComposer`, write handlers as plain
48functions and wrap them with [`useStableCallback`](../../apps/web/src/hooks/useStableCallback.ts)
49only where they leave the component: props, hook dependency arrays, and effect or memo bodies.
50Calls inside the component keep the plain function, so work that resumes after an `await` still
51acts on the render it started in. A function that a child calls during render, or after an
52`await`, comes from a module-level factory memoized on its inputs, so its closure keeps only
53those inputs.
54​
55## React Compiler
56​
57The compiler memoizes a component or hook only when it can prove the code follows React's rules.
58Anything it can't prove, such as reading a ref during render or calling `setState` from an effect,
59makes it skip the whole function without a build error. Lint reports each skip as a
60`react(...)` warning with the note "React Compiler skipped optimizing this component or hook".
61​
62Every file under `apps/web/src` must compile. Lint makes those rules errors there, and
63`t3code/no-manual-memo` rejects `useMemo` and `useCallback`, which the compiler makes redundant;
64no upstream rule bans them outright. It also rejects `"use memo"`. The compiler withholds a cache
65scope when a value reaches plain function calls on both sides of a hook call. A `"use memo"`
66helper forces one, but hides the ordering problem. Fix the order instead: plain uses of a value go
67before the hook, or the helper is inlined. A `use*` helper that calls no hook is compiled as a
68plain function and caches nothing, so a derived-value helper reads its source hook itself instead
69of taking the value as an argument. `t3code/no-hookless-use-function` rejects a `use*` function
70that calls no hook. Lint misses some bailouts, notably `try` with `finally` and
71`throw` inside `try`, so [`reactCompilerCoverage.test.ts`](../../apps/web/src/reactCompilerCoverage.test.ts)
72compiles every file and fails on any skip. Async handlers rarely need `try`: an
73atom command settles into a result, so cleanup can follow the `await`. `useStableCallback` still
74marks handlers that leave a large component, as described above.