<div align="center">

![@djangocfg/ui-core](https://raw.githubusercontent.com/markolofsen/assets/main/libs/djangocfg/ui-core.webp)

</div>

# @djangocfg/ui-core

Framework-agnostic React UI library: 70+ shadcn/Radix components on Tailwind v4, semantic theme tokens, palette hooks for Canvas/SVG, plus a router-adapter system that lets the same `<Link>` / `<Sidebar>` / `<SSRPagination>` work under Next.js, Vite, Electron, Wails, or plain React.

**Part of [DjangoCFG](https://djangocfg.com). [Live demo](https://djangocfg.com/demo/).**

## Install

```bash
pnpm add @djangocfg/ui-core
```

Works in any React host. Next.js apps import components from here directly and
add the Next router adapter from `@djangocfg/ui-core/adapters/nextjs` (see
**Router adapters** below); Next-specific *server* utilities (sitemap, health,
OG images, config) live in the separate [`@djangocfg/nextjs`](../nextjs) package.

Import styles once at the app root — use the golden path, which pins everything
to the right cascade layers so import order can't break layout utilities:

```css
/* FIRST style import in your app entry CSS */
@import "@djangocfg/ui-core/styles/full";   /* Tailwind v4 + tokens + base + utilities, layer-safe */
```

The plain `@djangocfg/ui-core/styles` entry does NOT import Tailwind and emits
its CSS unlayered — if you use it you own the layer ordering (import
`tailwindcss` first). See `src/styles/README.md` § App setup for why. Prefer
`…/styles/full`.

## Quick start

```tsx
import { UiProviders, Button, Card } from '@djangocfg/ui-core';

<UiProviders>
  <Card><Button>Hello</Button></Card>
</UiProviders>
```

`<UiProviders>` mounts Tooltip / Dialog / Toast in the right order **and a top-level error `Boundary`** (a crash shows a recoverable fallback, not a white screen). **Mount it once at the root** — library components (and everything in `@djangocfg/ui-tools`) trust it to be there and never nest their own (a second `TooltipProvider` is the canonical "Tooltip must be used within TooltipProvider" trap). Pass `onError` to forward crashes to your logger, `errorFallback` for a custom (e.g. i18n) crash screen, or `errorBoundary={false}` to opt out.

## Catalogue

| Group | Examples |
|---|---|
| `components/data/` | Avatar · Badge · Card · Table · BalancedText · Skeleton |
| `components/forms/` | Button · Input · Textarea · Select · Switch · Checkbox · Slider · Form |
| `components/feedback/` | Alert · Toast · Banner · Progress · Spinner |
| `components/overlay/` | Dialog · Drawer · Popover · Tooltip · HoverCard · Sheet · ContextMenu · DropdownMenu |
| `components/navigation/` | Sidebar · Tabs · Breadcrumb · Pagination · NavigationMenu · Command |
| `components/layout/` | Container · Grid · Stack · Separator · ScrollArea · Sticky |
| `components/select/` | Combobox · MultiSelect |
| `components/effects/` | Glass · Marquee · Backdrop |
| `components/specialized/` | Accordion · Collapsible · Toggle · Calendar · DatePicker |
| `components/boundary/` | ErrorBoundary |

Imports stay flat — group folders are organisational.

## Hooks (`/hooks`)

| Topic | Hooks |
|---|---|
| `dom/` | `useSize` · `useResizeObserver` · `useMeasure` · `useMutationObserver` · `useIntersection` |
| `device/` | `useIsMobile` · `useIsTouch` · `useMediaQuery` · `useOnline` · `useViewportSize` · `useOrientation` |
| `state/` | `useLocalStorage` · `useSessionStorage` · `useToggle` · `useCounter` · `useDebouncedValue` |
| `events/` | `useEventListener` · `useClickOutside` · `useKeyPress` · `useFocusWithin` |
| `theme/` | `useTheme` · `useResolvedTheme` · `useThemePreset` |
| `feedback/` | `useToast` · `useDialog` · `useClipboard` · `useConfirm` |
| `hotkey/` | `useHotkey` (single key + chord) |
| `audio/` | `useBeep` · `useSpeak` (Web Speech) |
| `tabs/` | `useCrossTab` (BroadcastChannel coordination) |
| `media/` | `useMediaPermissions` · `useUserMedia` · `useDevices` |
| `time/` | `useNow` · `useInterval` · `useTimeout` |
| `router/` | `useLink` · `useRouter` (router-adapter consumer) |

## Lib utilities (`/lib`)

- `cn(...)` — `clsx` + `tailwind-merge` shortcut
- `getIntensity(value, thresholds)` — quantise values into discrete bins (heatmaps, gauges)
- `createLogger()` — leveled console logger
- `dialog-service` — imperative `confirm()` / `alert()` / `prompt()` returning promises
- `persist` — typed localStorage / sessionStorage hooks
- `pretext` (subpath `@djangocfg/ui-core/lib/pretext`) — DOM-free text measurement via [@chenglou/pretext](https://github.com/chenglou/pretext); powers `<BalancedText>` and is the primitive for non-CSS line balancing

## Router adapters

The router-aware primitives (`Sidebar`, `Link`, `SSRPagination`, and the
`useRouter` / `useNavigate` / `useLocation` hooks) navigate through a **pluggable
adapter**, so the same components work under any host. ui-core itself is
router-agnostic and has **zero router dependency** — pick the adapter for your
host and mount it once near the root.

### Which adapter for which host

| Host | Mount this | Extra dependency you install |
|---|---|---|
| **Next.js** (App Router) | `NextRouterAdapter` from `@djangocfg/ui-core/adapters/nextjs` | `next` (you already have it) |
| **Vite / React-Router SPA** | `ReactRouterProvider` from `@djangocfg/ui-core/adapters/react-router` | `react-router` **≥7** — add to your app's `dependencies` |
| **Wails / Electron / plain React / Storybook** | **nothing** — the default History-API adapter is used automatically | none (zero deps) |

`next` and `react-router` are **optional peers**: the base package never imports
them. Only the matching sub-path entry does, so the dep is resolved *only* when
you import that adapter. Never add both — import the one sub-path for your host.

### Where to mount it

Both provider adapters wrap a subtree; every ui-core router call inside it flows
through your app's router. Mount **once**, near the root:

```tsx
// Next.js — inside the client provider stack (below your i18n provider)
import { NextRouterAdapter } from '@djangocfg/ui-core/adapters/nextjs';
<NextRouterAdapter><AppLayout>{children}</AppLayout></NextRouterAdapter>

// Vite / React-Router — INSIDE the router context (an element RouterProvider
// renders, e.g. a layout route wrapping <Outlet/>), NOT around <RouterProvider>.
import { ReactRouterProvider } from '@djangocfg/ui-core/adapters/react-router';
function Shell() {
  return <ReactRouterProvider><Layout><Outlet /></Layout></ReactRouterProvider>;
}
```

> **React-Router gotcha:** mounting `ReactRouterProvider` *around*
> `<RouterProvider>` throws — that subtree has no router context, so
> `useNavigate` fails. Always mount it *inside*.

### What happens if you skip it

Without an adapter, hooks fall back to the **default History-API adapter**
(`window.history.pushState` + `window.location`). This is intentional and works
everywhere with zero deps — but in a *framework* host it silently loses the
framework's routing behavior:

- **Next.js without `NextRouterAdapter`:** no RSC refetch on navigation, no route
  loader, no prefetch. Links still change the URL, but the App Router doesn't
  react. → always mount it in Next apps.
- **React-Router without `ReactRouterProvider`:** programmatic `useNavigate` /
  `<Link>` mutate `window.history` directly and bypass the `<Outlet/>` swap, so
  navigation desyncs from the data router. → always mount it in RR SPAs.

For a plain SPA (Wails/Electron/CRA) the History-API default *is* the right
backend — mount nothing.

### Link adapters (optional, Next.js)

By default `<Link>` from `@djangocfg/ui-core/components` renders a plain `<a>`.
To route Next's prefetch / RSC handling through it, also mount `NextLinkProvider`
alongside `NextRouterAdapter`. For locale-prefixed hrefs with `next-intl`, pass
`createNextIntlLinkAdapter(IntlLink)` as `AppLayout`'s `linkAdapter` — both live
in `@djangocfg/ui-core/adapters/nextjs`.

> **Deeper dive:** full adapter reference, the `useRouter` / `useNavigate` /
> `useLocation` / `useQueryState` hook surface, and a ~20-line recipe for
> **custom** routers (TanStack Router, wouter, Remix, custom transports) live in
> [`src/hooks/router/README.md`](./src/hooks/router/README.md).

> Using `@djangocfg/layouts`? `BaseApp` already mounts the Next adapters for you
> — you don't wire them by hand.

## Theming (`/styles`)

Tailwind v4 with semantic tokens, not raw color scales:

```tsx
<Card className="bg-card border-border text-foreground" />
<Button className="bg-primary text-primary-foreground" />
<Alert className="bg-warning-background text-warning-foreground border-warning-border" />
```

Tokens live in `:root` / `.dark` as fully-wrapped CSS colors; `@theme inline` exposes them as `--color-X` references, so opacity modifiers (`bg-card/40`, `border-foreground/20`) resolve via `color-mix` for **every** semantic token.

Preset text/fill pairs target WCAG AA for compact product UI. For the macOS
preset this is enforced by `pnpm check:contrast`; primary and destructive
foregrounds intentionally switch between light and dark ink where the system
fill's luminance requires it.

`@custom-variant dark (&:where(.dark, .dark *))` binds the `dark:` variant to the `.dark` class on `<html>` (not `prefers-color-scheme`) — every theme-switcher in this monorepo toggles that class.

Tailwind's `text-*` size utilities are bridged to the preset `--font-size-*` scale. Product-specific density belongs in a small CSS override after the static preset import — never in a runtime token builder. See `src/styles/README.md`.

**Programmatic theme colors** for Canvas / SVG / Mermaid:

```ts
import { useThemeColor, alpha, useStylePresets } from '@djangocfg/ui-core/styles/palette';

const primary = useThemeColor('primary');           // #00d9ff (hex, not oklch)
const dim = alpha(primary, 0.15);                   // 'rgba(0, 217, 255, 0.15)'
const { success, warning, danger } = useStylePresets();
```

Always hex-strings — `color-mix(...)` / `oklch(...)` syntax is rejected by Canvas2D fillStyle.

[Live playground](https://djangocfg.com/demo/) covers all tokens, presets, and dark-mode pairs.

## Maintenance rule

After any change to components or hooks — update this README and bump the package patch version. Consumers pin to npm versions; surface drift in this file is the canonical changelog.

## Requirements

- React 18 or 19
- Tailwind CSS v4 (host imports `@djangocfg/ui-core/styles`)

## License

MIT — © djangocfg.
