The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
7.6 KiB
Quill Primitives — Agent Guide
What is Quill?
Quill is Insights's design system built on React, TypeScript, Base UI, and Tailwind v4. It follows a layered architecture: tokens > primitives > components > blocks.
This guide covers the primitives layer — the foundational UI components that everything else builds on.
Package structure
packages/quill/
├── packages/
│ ├── tokens/ @hanzo/quill-tokens — Design tokens (JS + generated CSS)
│ ├── primitives/ @hanzo/quill-primitives — Base UI components (this guide)
│ ├── components/ @hanzo/quill-components — Composed primitives
│ └── blocks/ @hanzo/quill-blocks — Product-level patterns
└── apps/
└── storybook/ — Component documentation
Core patterns
1. Base UI wrapping
Every interactive primitive wraps a @base-ui/react component.
Base UI provides behavior, accessibility, and state — we add styling via Tailwind.
import { Button as ButtonPrimitive } from '@base-ui/react/button'
const Button = React.forwardRef<HTMLButtonElement, Props>(({ className, variant, size, ...props }, ref) => (
<ButtonPrimitive
ref={ref}
data-slot="button"
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
))
2. CVA for variants
All variant styling uses class-variance-authority.
Define variants as a cva() call, export the variants type for consumers.
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva('inline-flex items-center ...', {
variants: {
variant: {
default: 'bg-secondary ...',
outline: 'border-foreground/10 ...',
destructive: 'bg-destructive ...',
},
size: {
default: 'h-7 px-2 text-xs',
sm: 'h-6 px-2 text-xs',
xs: 'h-5 px-2 text-[0.625rem]',
},
},
defaultVariants: { variant: 'default', size: 'default' },
})
3. cn() utility
Always use cn() from ./lib/utils for class merging.
It combines clsx (conditional classes) + tailwind-merge (conflict resolution).
import { cn } from './lib/utils'
className={cn(baseClasses, conditional && 'extra-class', className)}
4. data-slot attributes
Every component root sets data-slot="component-name".
This enables parent-based styling via has-data-[slot=...] and *:data-[slot=...].
5. forwardRef for render prop targets
Any component used as a render={<Component />} target in Base UI must use React.forwardRef.
React 18 does not forward refs to function components — Base UI's render prop passes refs.
Components that need forwardRef:
Button,Input,Chip,ChipCloseInputGroup,InputGroupInput,InputGroupButtonComboboxTrigger- Any component passed to Base UI's
renderprop
6. Composition hierarchy
Button
├── Chip (default size=sm, variant=outline)
├── ChipClose (size=icon-xs)
├── InputGroupButton
├── TabsTrigger (render prop)
├── CollapsibleTrigger (render prop)
├── SelectTrigger (render prop)
├── DropdownMenuItem (render prop)
└── ToggleGroupItem (render prop)
Input
└── InputGroupInput (border-0, bg-transparent)
InputGroup
├── ComboboxInput (with anchor context)
└── AutocompleteInput (with anchor context)
Chip + ChipClose
└── ComboboxChip (via render prop)
ButtonGroup
└── ChipGroup (flex-wrap, gap-0)
Component conventions
Creating a new primitive
- Wrap the Base UI component
- Add
data-slot="your-component"to the root - Use
forwardRefif the component may be used inrenderprops - Define variants with CVA if the component has visual variants
- Accept
classNameprop and merge withcn() - Export from the component file (barrel export is in
index.ts) - Update the consumer guide in the same PR —
packages/primitives/AGENTS.md(catalog row, "Choosing a component" cluster, composition pattern). Same rule applies topackages/components/AGENTS.mdandpackages/charts/AGENTS.mdfor their packages. A lint-staged warning fires if quill sources change without an AGENTS.md update
Styling rules
- Use Tailwind utility classes exclusively — no inline styles, no CSS modules
- Use semantic color tokens:
bg-primary,text-foreground,border-input— never raw colors - Components that accept icon children own their icon sizing —
flex-shrink: 0plus asvg:not([class*='size-'])size rule in the component's.cssfile (seebutton.css), so consumers drop in bare lucide icons withoutsize-*classes; the:notguard keeps explicitsize-*as a deliberate escape hatch - Use
group/namefor scoped parent selectors (e.g.,group/tabs,group/input-group) - Use
data-[variant=...]selectors for variant-aware child styling - Focus states:
focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 - Invalid states:
aria-invalid:bg-destructive/50 aria-invalid:border-destructive-foreground/30 - Disabled states:
disabled:pointer-events-none disabled:opacity-50 - Dark mode: use
dark:prefix only when light/dark need different values beyond token swapping
Base UI render prop pattern
When composing primitives inside Base UI components, use the render prop:
// Good — Base UI manages behavior, our component provides styling
<ComboboxPrimitive.Chip render={<Chip />}>
{children}
</ComboboxPrimitive.Chip>
// Good — nesting render props
<InputGroupButton render={<ComboboxTrigger />} />
Context for cross-sibling communication
When siblings need shared state (e.g., ComboboxInput and ComboboxContent sharing an anchor ref),
use React context at their common parent:
const AnchorContext = React.createContext<React.RefObject<HTMLDivElement> | null>(null)
function Parent({ children }) {
const ref = React.useRef<HTMLDivElement>(null!)
return (
<AnchorContext.Provider value={ref}>
<Primitive.Root>{children}</Primitive.Root>
</AnchorContext.Provider>
)
}
Storybook
- Stories live next to components:
component.stories.tsx - Use
Meta<typeof Component>type annotation (notsatisfies Meta<typeof Component>— avoids strict args issues with Base UI props) - Each story should demonstrate a distinct use case
- Run storybook:
pnpm --filter storybook dev
Commands
- Dev storybook:
pnpm --filter storybook dev - Build primitives:
pnpm --filter @hanzo/quill-primitives build - Build tokens:
pnpm --filter @hanzo/quill-tokens build - Format: use prettier via the quill root config
Key dependencies
| Package | Purpose |
|---|---|
@base-ui/react |
Headless UI primitives (behavior + accessibility) |
class-variance-authority |
Type-safe variant class maps |
clsx + tailwind-merge |
Class merging without conflicts |
lucide-react |
Icons |
react-resizable-panels |
Resizable panel layout |
Token system
Components never import color values directly.
They use Tailwind classes (bg-primary, text-foreground) which reference CSS custom properties (--primary, --foreground).
The token flow:
@hanzo/quill-tokensdefines colors as JS objects- Build script generates
color-system.css(actual values) andstyles.css(@thememappings) - Primitives ship only the
@thememappings — the consuming app providescolor-system.css
This means primitives are theme-agnostic — swap color-system.css for a different theme.