<div align="center">

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

</div>

# @djangocfg/ui-tools

Heavy React tools with lazy loading. No Next.js dependency — works with Electron, Vite, CRA, any React environment.

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

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

---

## What's inside

Sixteen tools, each one lazy-loaded so it doesn't ship until used. Bundle size is the gzipped tool by itself; deps (mapbox, monaco, etc.) load on first render.

| Tool | Bundle | Docs |
|------|--------|------|
| `Map` | ~800KB | MapLibre maps, markers, clusters, layers. [README](src/tools/Map/README.md) |
| `Mermaid` | ~800KB | Diagrams + declarative builders |
| `CodeEditor` | ~550KB | Monaco editor + diff view |
| `PrettyCode` | ~500KB | Syntax-highlighted read-only code |
| `OpenapiViewer` | ~400KB | OpenAPI schema viewer + playground |
| `JsonForm` | ~300KB | JSON-Schema-driven form generator |
| `MarkdownEditor` | ~200KB | Tiptap WYSIWYG with `@`-mentions |
| `LottiePlayer` | ~200KB | Lottie animation player |
| `Chat` | ~150KB | Streaming chat (SSE + tool calls + attachments). [README](src/tools/Chat/README.md) |
| `SpeechRecognition` | ~40KB | Mic capture + STT with pluggable engines (Web Speech / HTTP / WS). [README](src/tools/SpeechRecognition/README.md) |
| `VideoPlayer` | ~12KB core | media-chrome player — YouTube / Vimeo / HLS / MP4 in one composable shell. [README](src/tools/VideoPlayer/README.md) |
| `MarkdownMessage` | ~120KB | Read-only chat-tuned markdown. **SSR-safe** — use as a Client Component, the result is server-rendered. [README](src/components/markdown/MarkdownMessage/README.md) |
| `JsonTree` | ~100KB | JSON visualization (full/compact/inline modes) |
| `AudioPlayer` | ~80KB | WebView-safe waveform player |
| `Gallery` | ~50KB | Image/video gallery + lightbox |
| `ImageViewer` | ~50KB | Zoom/pan/rotate viewer |
| `CronScheduler` | ~15KB | Cron expression builder |
| `Masonry` | ~8KB | Virtualised window-scroll masonry grid with column balancing. [README](src/tools/data/Masonry/README.md) |

Plus utility primitives: `Tree`, `Tour` (with spotlight overlay), `FileIcon`, `UploadDropzone`.

---

## Imports

**There is no root barrel** — `import … from '@djangocfg/ui-tools'` is intentionally **not** supported. Always import from a tool subpath.

Why: a root barrel plus per-tool subpaths would inevitably ship the same modules twice (the bundled `dist/` copy via the root, plus the raw `src/` copy via the subpath). Different physical modules mean different `React.createContext()` instances — `ChatProvider`, `MapProvider`, `TreeProvider` all silently break across the boundary.

The TypeScript / bundler error you'll see is `Cannot find module '@djangocfg/ui-tools'`. That's intentional; fix it by picking a subpath:

```tsx
import { LazyJsonTree } from '@djangocfg/ui-tools/json-tree';
import { Gallery } from '@djangocfg/ui-tools/gallery';
import { LazyMapContainer, MapMarker } from '@djangocfg/ui-tools/map';
import { MarkdownMessage } from '@djangocfg/ui-tools/markdown-message';
import { LazyEditor, useMonaco } from '@djangocfg/ui-tools/code-editor';
import { LazyChat, createPydanticAIChatTransport } from '@djangocfg/ui-tools/chat';
import { LazyMarkdownEditor, mentionPresets } from '@djangocfg/ui-tools/markdown-editor';
import { LazyPlayer, PlayerProvider } from '@djangocfg/ui-tools/audio-player';
// …same pattern for every tool
```

Subpaths come in three flavors:

- **Lazy + light surface.** The heavy component is exported only as a `Lazy*` wrapper; types, hooks, transports, slot components, and pure helpers are exported synchronously. This is the standard pattern.
- **Lazy primitives co-exist with light primitives.** `./map` exports `LazyMapContainer` (heavy MapLibre GL) plus `MapMarker` / `MapPopup` etc. — the markers are thin `react-map-gl` wrappers that don't import the heavy lib at module scope.
- **Synchronous, SSR-safe.** `./markdown-message` is intentionally not lazy — the component is `'use client'` but produces plain HTML, so Next renders it on the server.

| Subpath | Ships | Notes |
|---------|-------|-------|
| `@djangocfg/ui-tools/chat` | `LazyChat`, `LazyChatLauncher`, plus the raw components (`ChatRoot`, `ChatLauncher`, `MessageBubble`, `Composer`, `MessageBlocks`, header buttons, …), the composer kit (`ComposerMenuButton`, `ComposerBanner`, `ComposerToolPill`, `ComposerModelPicker`, `ComposerRichTextarea`, …), transports (`createPydanticAIChatTransport`, `createHttpTransport`, …), hooks (`useChat`, `useChatComposer`, …), reducer/core/types/style tokens | Use the `Lazy*` wrappers when possible. The raw components are re-exported synchronously for callers that compose custom chat shells — importing them pulls the dependency graph eagerly. |
| `@djangocfg/ui-tools/code-editor` | `LazyEditor`, `LazyDiffEditor`, `useMonaco`, `useEditor`, `useLanguage`, `useEditorTheme`, `EditorProvider`, types | Monaco (~550 KB) loads only when an editor mounts. `useMonaco` does its own dynamic import. |
| `@djangocfg/ui-tools/audio-player` | `LazyPlayer`, `PlayerProvider`, selector hooks, slot components (`Cover`, `Title`, `PlayButton`, `Waveform`, …), peaks-cache helpers, store, types | Slot components are presentational — safe synchronous re-exports. They only do anything inside a `<LazyPlayer>` provider. |
| `@djangocfg/ui-tools/markdown-editor` | `LazyMarkdownEditor`, `mentionPresets`, types | TipTap + ProseMirror (~200 KB) only loads via the lazy wrapper. |
| `@djangocfg/ui-tools/map` | `LazyMapContainer`, `LazyMapView`, plus light primitives (`MapMarker`, `MapPopup`, `MapCluster`, `MapSource`, `MapLayer`, `MapControls`, `MapProvider`, types) | The heavy MapLibre GL chunk (~800 KB) only loads when `LazyMapContainer` actually mounts. Markers and popups are thin `react-map-gl` wrappers — exported synchronously. |
| `@djangocfg/ui-tools/markdown-message` | `MarkdownMessage`, `ChatMessageRow`, `ActionRow`, `extractTextFromChildren`, types | **SSR-safe.** The component itself is `'use client'`, but rendering produces plain HTML — Next.js will pre-render it on the server when imported from a Client Component. Use this when you want the markdown renderer without dragging in the full chat. |
| `@djangocfg/ui-tools/video-player` | `VideoPlayer` (+ `LazyVideoPlayer` alias), `parseEmbedUrl`, composable parts (`PlayButton`, `SeekBar`, `Volume`, `ControlsBar`, …), per-engine canvases, types | media-chrome shell — YouTube / Vimeo / HLS / MP4 / iframe behind one API. Provider engines (`youtube-video-element`, `hls-video-element`, …) are imported only by the matching canvas, so unused engines tree-shake. [README](src/tools/VideoPlayer/README.md) |
| `@djangocfg/ui-tools/<tool-name>` | One tool | `mermaid`, `speech-recognition`, `json-tree`, `pretty-code`, `openapi-viewer`, `json-form`, `lottie-player`, `video-player`, `image-viewer`, `cron-scheduler`, `gallery`, `tour`, `tree`, `file-icon`, `upload` |
| `@djangocfg/ui-tools/json-form/full` | `JsonSchemaForm`, `ObjectFieldTemplate`, `evaluateDisabledWhen`, all widgets / templates / utils | Eager bundle. Use only for storybook / internal tooling that needs the template + util APIs at module scope. Production code should import `LazyJsonSchemaForm` from `/json-form` instead. |
| `@djangocfg/ui-tools/styles` | Tailwind source CSS | |
| `@djangocfg/ui-tools/dist.css` | Pre-compiled CSS | |

### Code & data inspectors are always-dark by design

`PrettyCode` and `JsonTree` render on a fixed dark surface (`#0d1117`)
regardless of the host UI theme. Same convention as GitHub, VSCode,
ChatGPT, Chrome DevTools. Syntax highlighting / typed-value coloring
ship their own contrast model — mixing them with light UI surfaces
flattens everything into low-contrast pastels.

The chrome (border, padding, toolbars) still uses semantic UI tokens.
Only the *content surface* is fixed. See per-tool READMEs for details.

---

## Lazy loading

Every heavy tool ships in two flavors:

- `Lazy*` — wrapped in `React.lazy` + `Suspense` + skeleton. Use by default.
- bare tool (`Mermaid`, `JsonTree`, …) — for explicit loading control.

```tsx
import { LazyChat } from '@djangocfg/ui-tools/chat';

<LazyChat transport={transport} />               // loads on mount
<LazyChat fallback={<MySkeleton />} ... />       // custom loading UI
```

Skeletons match the tool's final layout so swap-in is jank-free.

---

## Chat — quick start

The most-used tool. Headless reducer + composable UI: streaming SSE transport, pydantic-AI mapper, decomposed parts (`MessageBubble`, `Composer`, `MessageList`, `ToolCalls`, …), role-aware styling. Floating launcher with FAB, popover/side dock, proactive greeting, live-push notifications, audio mute toggle, reset-with-confirm, Linear-style hotkeys.

```tsx
import {
  ChatLauncher,
  ChatRoot,
  createPydanticAIChatTransport,
} from '@djangocfg/ui-tools/chat';

const transport = createPydanticAIChatTransport({
  buildStreamUrl: (sid, msg) => `${API}/chat/sessions/${sid}/stream?message=${encodeURIComponent(msg)}`,
  streamMethod: 'GET',
  buildHeaders: async () => ({ Authorization: `Bearer ${getToken()}` }),
});

function Chat() {
  // ChatLauncher mounts <ChatProvider> internally — pass transport / audio /
  // config here, then render <ChatRoot /> as a child without props.
  return (
    <ChatLauncher
      transport={transport}
      audio={{}}                         // ChatAudioConfig (sounds map). `{}` uses bundled defaults.
      hotkey={{ key: '/', meta: true }}
      fab={{ variant: 'animated' }}     // size='responsive' default — phone/tablet/desktop
      dock={{ title: 'Assistant', height: 600 }}
      greeting="Hi 👋 Need help?"
      headerSlots={{                     // declarative header buttons, rendered inside the provider
        languagePicker: true,
        modeToggle: { persistAs: 'my.chat.dock' },
        reset: {
          onReset: async () => { await api.clearChat(); return true; },
        },
      }}
    >
      <ChatRoot />
    </ChatLauncher>
  );
}
```

**What's wired by default:** desktop side-mode toggle (auto-hides on narrow screens), persisted dock prefs, two-step Escape, click-to-focus composer, mobile fullscreen with `dvh` heights, push-preview bubble for inbound messages while closed, **ChatGPT-style autoscroll** (sticky-to-bottom within 120 px, every user-send re-anchors the viewport), **bundled chat notification sounds** (sent/received/start/error/mention/notification, ~136KB inlined as `data:`-URLs inside the lazy chat chunk — zero host setup). Native hosts (cmdop_go / Tauri) pass `audio={{ silenced: true, onSoundEvent }}` to keep web silent while routing triggers to the backend.

Need a fully custom input row (e.g. mention autocomplete via `MarkdownEditor`)? Pass `composer={{ render: ({ composer }) => <YourComposer composer={composer} /> }}` to `<ChatRoot>` — it replaces the default `<Composer>` while keeping autoscroll, JumpToLatest, and history behaviour. Story: `UI Tools / Chat / Composer / Mentions`.

Drop `<VoiceComposerSlot />` from `@djangocfg/ui-tools/speech-recognition` into the composer's `slots.blockStart` (`composer={{ slots: { blockStart: <VoiceComposerSlot /> } }}`) for live mic-to-text — **zero props**, reads / writes the composer through `ComposerHandle` registered in chat context. Set `headerSlots={{ languagePicker: true }}` on `<ChatLauncher>` for a flag-button language picker (66 BCP-47 tags). Both auto-hide on Firefox / in-app browsers / missing `getUserMedia`. See [`SpeechRecognition`](#speech-recognition--quick-start) below.

**Page-context.** The assistant can see the page the user is on. The `page-snapshot` engine (`src/lib/page-snapshot`) walks the live DOM into a token-efficient, redacted snapshot; wire `getDynamicMetadata` on `<ChatRoot>` / `<ChatProvider>` (typically from `usePageSnapshot().getChatMetadata`) and it rides the request `metadata`. When the assistant replies with `point` directives, `<HighlightOverlay>` (`src/tools/Chat/highlight`) spotlights the referenced elements. Story: `UI Tools/Chat/Highlight`.

Full docs: [`Chat/README.md`](src/tools/Chat/README.md). Stories are grouped under `UI Tools/Chat` into `Getting Started`, `Messages`, `Composer`, `Launcher`, `Transports`, `Highlight` — plus `Overview` and a one-screen `Showcase`.

---

<a id="speech-recognition--quick-start"></a>

## Speech Recognition — quick start

Decomposed STT (Speech-to-Text) tool. Headless hook + composable UI + lazy bundle, same shape as `Chat` and `AudioPlayer`. Default backend is the browser Web Speech API (zero deps, zero network); custom engines plug in via a small interface — Deepgram, AssemblyAI, OpenAI Whisper REST, your own gateway, all without vendor SDKs on the critical path.

```tsx
import {
  DictationButton,
  TranscriptView,
  useSpeechRecognition,
} from '@djangocfg/ui-tools/speech-recognition';

function Dictate() {
  const rec = useSpeechRecognition();      // Web Speech engine, browser language
  return (
    <>
      <DictationButton status={rec.status} onClick={() => rec.toggle()} />
      <TranscriptView transcript={rec.transcript} />
    </>
  );
}
```

For the chat composer:

```tsx
import { VoiceComposerSlot } from '@djangocfg/ui-tools/speech-recognition';

<ChatRoot
  transport={transport}
  composer={{ slots: { blockStart: <VoiceComposerSlot /> } }}
/>

// Plus a flag-button language picker in the dock header:
<ChatLauncher headerSlots={{ languagePicker: true }} ... >
```

**What's wired by default:** auto-hide on Firefox / in-app WebViews / missing `getUserMedia` (via `useVoiceSupport`); live interim+final stream into the composer via `ComposerHandle` (works transparently for the built-in textarea Composer and a TipTap MarkdownEditor); typed prefix anchored; focus + cursor-to-end on start and every partial; 90-second countdown + 2.5-second silence auto-stop; Esc cancels (without closing the chat) / Enter finishes (without submitting); persisted prefs (`djangocfg-stt:prefs`); `<SpeechRecognitionProvider>` for sharing engine state across the tree. Language picker shows 66 BCP-47 tags sourced from the Chrome Web Speech demo with country flags. Custom engines through `createHttpEngine` (REST/Whisper), `createWebSocketEngine` (Deepgram-style streaming), or `createExternalEngine` (Wails / Tauri / native sidecar — `onStart` / `onStop` / `subscribe` and you're done).

Full docs: [`SpeechRecognition/README.md`](src/tools/SpeechRecognition/README.md). Stories: `UI Tools/Speech/{Dictation, ComposerSlot, PushToTalk, MicMeter, Engines, Languages, Errors}` + `UI Tools/Chat/Composer/Speech & Attachments` and `UI Tools/Chat/Launcher/Playground` (flag-button picker in the dock header). Unit-tested with vitest (`pnpm test`, 21 cases).

---

## Markdown

Read-only `<MarkdownMessage>` powers every chat bubble. Stays useful standalone for docs, AI replies, system messages.

```tsx
import { MarkdownMessage } from '@djangocfg/ui-tools/markdown-message';

<MarkdownMessage content="# Hello\n\nGFM + emoji 😄 + code:\n\n```ts\nconst x = 1;\n```" />
```

GFM, soft line breaks, smart typography, emoji shortcodes, sanitized HTML, mermaid blocks, syntax-highlighted code, declarative `linkRules` for custom URL schemes (e.g. `cmdop://`, `vehicle://`). Details: [`MarkdownMessage/README.md`](src/tools/MarkdownMessage/README.md).

---

## Map

MapLibre GL maps with markers, clusters, layers, drawing, geocoding. Subpath: `@djangocfg/ui-tools/map`. Details: [`Map/README.md`](src/tools/Map/README.md).

```tsx
import { MapContainer, MapMarker } from '@djangocfg/ui-tools/map';

<MapContainer style="streets" center={[37.6, 55.7]} zoom={10}>
  <MapMarker position={[37.6, 55.7]} />
</MapContainer>
```

Optional drawing/geocoding peers: `pnpm add mapbox-gl-draw geocoder-control`.

---

## Storybook

Stories live next to each tool and are registered in [`src/stories/index.ts`](src/stories/index.ts):

```bash
pnpm playground
```

Open browser to the URL printed in the console. Categories: `Tools/<ToolName>` plus per-feature subsections for Chat and Map.

---

## Requirements

- React 18+ / React 19
- Tailwind CSS (or import `@djangocfg/ui-tools/dist.css`)
- Peer deps for individual tools auto-installed by your package manager.

---

## License

MIT — see [LICENSE](./LICENSE).
