Circle V2 API Docs
    Preparing search index...

    UI and Components

    The component library lives in packages/ui and is built on Chakra UI v3. The web app and Storybook consume it via @repo/ui/* imports.

    The design system is configured in packages/ui/src/chakra.system.tsx using Chakra v3's createSystem API:

    • Global CSS: Inter font, Lucide icon stroke width, selection colors.
    • Custom tokens: Color palettes (gossamer, bluishGray), spacing, etc.
    • Semantic tokens: brand, success, error, warning, destructive, sidebar background.
    • Recipes: Custom button, badge, kbd defaults; editable and tooltip slot tweaks.
    • Keyframes: Morphing gradient animations for the auth pages.

    packages/ui/src/ThemeProvider.tsx wires up:

    1. ChakraProvider with the custom system.
    2. next-themes ThemeProvider with attribute="class" (dark/light mode support).
    3. Sonner <Toaster /> for toast notifications.

    To regenerate Chakra types after changing the system:

    pnpm --filter @repo/ui chakra:typegen
    

    Chakra v3 introduces colorPalette as a contextual theming mechanism. Setting colorPalette="brand" on a component makes every colorPalette.* token reference inside it resolve against the brand color scale. This replaces the need to hard-code color scales on every child.

    1. A parent element sets colorPalette="brand" (or any palette name).
    2. Descendants use semantic tokens like colorPalette.fg, colorPalette.solid, colorPalette.muted in style props.
    3. Those tokens resolve to the corresponding shade of the active palette.
    <Box colorPalette="success">
    {/* color resolves to success.fg (green.700 light / green.300 dark) */}
    <Text color="colorPalette.fg">Saved successfully</Text>
    </Box>

    The system config in packages/ui/src/chakra.system.tsx establishes several defaults:

    Scope Default Palette Effect
    :root brand All components inherit brand unless overridden
    ::selection gray Text selection highlight
    Button recipe gray Buttons are neutral by default
    Badge recipe brand Badges use the primary brand color
    Kbd recipe gray Keyboard shortcuts are neutral

    Additionally, @repo/ui wrapper components like Tabs.Trigger and Switch.Root default to brand via colorPalette ?? "brand".

    Semantic palettes are defined in packages/ui/src/lib/createColorPalette.ts via createSemanticColorPalette, which maps a primitive color scale to semantic steps (solid, fg, muted, subtle, emphasized, focusRing, border, contrast, and shades 50--950):

    Palette Base Scale Usage
    brand gossamer Primary actions, CTAs, default accent
    info blue Informational indicators
    success green Success states, confirmations
    error red Error states, validation failures
    destructive red Delete/remove actions (same scale as error, separate semantic name)
    warning yellow Warnings (uses black contrast text)
    ai purple AI-related features and accents

    Raw Chakra scales (gray, blue, green, red, purple, yellow, etc.) also work as colorPalette values and are used for finer-grained styling.

    Use semantic palette names for intent, not raw colors. Prefer colorPalette="error" over colorPalette="red" so the meaning is clear and the underlying scale can be changed in one place.

    Primary actions use brand:

    <Button colorPalette="brand" trackingData={{ event: "save" }}>
    Save
    </Button>

    Status indicators map domain states to palettes. Type the map with BoxProps["colorPalette"] for safety:

    const STATUS_COLOR_MAP: Record<
    SessionProgressStatus,
    BoxProps["colorPalette"]
    > = {
    complete: "success",
    processing: "purple",
    transcribing: "blue",
    failed: "error",
    initialized: "gray",
    };

    <Badge colorPalette={STATUS_COLOR_MAP[status]}>{statusLabel}</Badge>;

    Referencing the active palette in child styles -- useful for icons or text that should follow the palette set by a parent:

    <SparklesIcon
    colorPalette="ai"
    color={{ _dark: "colorPalette.fg", _light: "colorPalette.solid" }}
    />

    Dynamic palette switching for interactive states like copy buttons:

    <IconButton colorPalette={isCopied ? "success" : "gray"} ... />
    

    Each palette created by createSemanticColorPalette provides these steps:

    Token Light Dark Typical Use
    colorPalette.solid 500 500 Solid backgrounds, fills
    colorPalette.fg 700 300 Foreground text, icons
    colorPalette.muted 200 800 Muted backgrounds
    colorPalette.subtle 100 900 Very light backgrounds
    colorPalette.emphasized 300 700 Hover/active states
    colorPalette.border 500 400 Borders
    colorPalette.focusRing 500 500 Focus ring outlines
    colorPalette.contrast #fff #fff Text on solid backgrounds

    Components are imported per-file, not from a barrel:

    import { Button } from "@repo/ui/Button";
    import { Field } from "@repo/ui/Field";
    import { Dialog } from "@repo/ui/Dialog";
    import { Drawer } from "@repo/ui/Drawer";
    import { Table } from "@repo/ui/Table";

    Composites and hooks have their own entry points:

    import { DataTable, MultiFilterMenu } from "@repo/ui/composites";
    import { useDebounce, useMediaQuery } from "@repo/ui/hooks";

    Theme hook (separate entry):

    import { useTheme } from "@repo/ui/useTheme"; // resolvedTheme, setTheme
    

    The UI package uses three main patterns:

    Re-export Chakra components as-is when no extra behavior is needed:

    // packages/ui/src/Theme.tsx
    export { Theme } from "@chakra-ui/react";

    Wrap Chakra components with additional behavior. The Button is the primary example -- it requires analytics trackingData:

    // packages/ui/src/Button.tsx
    export type ButtonProps = ChakraButtonProps & {
    trackingData: AnalyticsEvent | (() => AnalyticsEvent);
    };

    export const Button = ({ ref, children, ...props }: ButtonProps) => {
    const analytics = useAnalytics();

    const handleOnClick: ChakraButtonProps["onClick"] = async (event) => {
    await trackClickEvent(analytics, props.trackingData);
    props.onClick?.(event);
    };

    return (
    <ChakraButton ref={ref} {...props} onClick={handleOnClick}>
    {children}
    </ChakraButton>
    );
    };

    Every Button in the app must provide trackingData, ensuring analytics coverage by default.

    Build richer components using Chakra's slot pattern with static subcomponents:

    // packages/ui/src/Field.tsx
    export type FieldProps = Omit<ChakraField.RootProps, "label"> & {
    label?: React.ReactNode;
    helperText?: React.ReactNode;
    errorText?: React.ReactNode;
    optionalText?: React.ReactNode;
    };

    const BaseField = forwardRef<HTMLDivElement, FieldProps>(function Field(props, ref) {
    const { label, children, helperText, errorText, optionalText, ...rest } = props;
    return (
    <ChakraField.Root ref={ref} {...rest}>
    {label && (
    <ChakraField.Label>
    {label}
    <ChakraField.RequiredIndicator fallback={optionalText} />
    </ChakraField.Label>
    )}
    {children}
    {helperText && <ChakraField.HelperText>{helperText}</ChakraField.HelperText>}
    {errorText && <ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>}
    </ChakraField.Root>
    );
    });

    export const Field = Object.assign(BaseField, {
    Label: ChakraField.Label,
    HelperText: ChakraField.HelperText,
    ErrorText: ChakraField.ErrorText,
    RequiredIndicator: ChakraField.RequiredIndicator,
    Root: ChakraField.Root,
    });

    Larger components live in packages/ui/src/composites/:

    Composite Description
    DataTable Built on TanStack Table. Handles column definitions, sorting, and rendering.
    MultiFilterMenu Multi-select filter dropdown with atoms and context for state management.

    Import via @repo/ui/composites.

    Icons live inside @repo/ui and are exported via the ./icons entry point. The module re-exports lucide-react icons wrapped with the shared Icon component under semantic names:

    // packages/ui/src/icons/index.tsx
    export { SearchIcon, ClientsIcon, TemplatesIcon, ... };

    Usage:

    import { SearchIcon, ClientsIcon } from "@repo/ui/icons";
    

    The @repo/use-form package wraps react-hook-form with automatic Zod validation:

    // packages/use-form/src/index.ts
    export function useForm<TSchema extends FormSchema>(
    props: UseFormProps<TSchema>,
    ) {
    return useReactHookForm({
    ...props,
    resolver: zodResolver(props.schema),
    });
    }

    You always pass a schema instead of a resolver:

    import { useForm } from "@repo/use-form";
    import { z } from "zod";

    const schema = z.object({
    firstName: z.string().min(1),
    lastName: z.string().min(1),
    });

    function PersonForm() {
    const { register, handleSubmit, formState: { errors } } = useForm({
    schema,
    defaultValues: { firstName: "", lastName: "" },
    });

    return (
    <form onSubmit={handleSubmit(onSubmit)}>
    <Field label="First Name" errorText={errors.firstName?.message}>
    <Input {...register("firstName")} />
    </Field>
    </form>
    );
    }

    Feature-specific form hooks are colocated with their feature:

    apps/web/app/(app)/settings/
    ├── page.tsx
    └── lib/
    └── usePersonalInformationForm.ts # schema + defaultValues + useForm

    The hook encapsulates the schema and defaults. The component only receives control / register.

    Re-exports from @repo/use-form for advanced use:

    export {
    useController,
    useFieldArray,
    useFormState,
    useWatch,
    } from "@repo/use-form";

    Lower-level internals (e.g. Controller) are available via @repo/use-form/internals.

    1. @repo/analytics-core defines an abstract AnalyticsClient and AnalyticsEvent type.
    2. @repo/analytics-react provides AnalyticsProvider and useAnalytics().
    3. The web app initializes a LoggerAnalyticsClient (logs to console) in apps/web/lib/providers.tsx.
    4. The Button component automatically tracks clicks via the required trackingData prop.
    <Button trackingData={{ event: "save_template", properties: { templateId } }}>
    Save
    </Button>

    Or with a lazy factory when properties are computed:

    <Button trackingData={() => ({ event: "submit_form", properties: getFormData() })}>
    Submit
    </Button>

    Component stories live in apps/storybook/src/stories/. Run Storybook locally:

    pnpm --filter storybook dev  # http://localhost:6006
    

    New components can live in one of three places. Use this decision framework to pick the right one.

    %%{init:{"theme":"dark"}}%% flowchart TD Start["New component"] --> DomainCheck{"Is it domain-specific?"} DomainCheck -->|No| PkgUI["packages/ui"] DomainCheck -->|Yes| ReuseCheck{"Used by multiple\nroute features?"} ReuseCheck -->|No| Colocate["feature/components/"] ReuseCheck -->|Yes| Shared["shared/domain/"]
    %%{init:{"theme":"default"}}%% flowchart TD Start["New component"] --> DomainCheck{"Is it domain-specific?"} DomainCheck -->|No| PkgUI["packages/ui"] DomainCheck -->|Yes| ReuseCheck{"Used by multiple\nroute features?"} ReuseCheck -->|No| Colocate["feature/components/"] ReuseCheck -->|Yes| Shared["shared/domain/"]
    flowchart TD
      Start["New component"] --> DomainCheck{"Is it domain-specific?"}
      DomainCheck -->|No| PkgUI["packages/ui"]
      DomainCheck -->|Yes| ReuseCheck{"Used by multiple\nroute features?"}
      ReuseCheck -->|No| Colocate["feature/components/"]
      ReuseCheck -->|Yes| Shared["shared/domain/"]

    A component belongs in packages/ui when it is not tied to any domain and could be dropped into any app or Storybook story without knowledge of clients, session notes, templates, etc.

    Examples: Button, Dialog, DataTable, Field, Tabs, SearchInput, icons.

    Characteristics:

    • Zero imports from @repo/trpc, @repo/db/schema, route definitions, or domain stores.
    • Configurable via props, not domain-specific hooks.
    • Has (or should have) a Storybook story.

    A component belongs in shared/ when it composes @repo/ui primitives with domain data (tRPC queries, branded IDs, route definitions) and is imported by two or more route features.

    Examples: PatientCombobox, TemplateCombobox, StartSessionDrawer, SessionNoteProgressBadge.

    Characteristics:

    • Imports domain types (PatientId, SessionProgressStatus) and uses tRPC queries.
    • Has a barrel index.ts re-exporting components and hooks.
    • Follows the same components/ + lib/ shape as route features.

    A component belongs colocated with its route when it is only used by that single feature.

    Examples: ClientsTable, AddClientDrawer, LoginForm, AuditPreviewDrawer.

    Characteristics:

    • Lives in the components/ subfolder next to its page.tsx.
    • Can freely import from shared/ and @repo/ui.

    Components move up the ladder as their usage grows:

    feature/components/  -->  shared/<domain>/  -->  packages/ui
    

    Start by colocating. When a second feature needs the same component, move it to shared/. When a component becomes fully generic (no domain imports), promote it to packages/ui.

    A real example: CollapsibleSection currently lives in apps/web/shared/session-notes/components/ with a TODO noting it is a candidate for @repo/ui once it is generalized.


    Next: Code Style and Tooling