# `apps/ui` - LocalMode UI The `@localmode/ui` platform: a single Next.js 16 app that is **both** a [shadcn registry](https://ui.shadcn.com/docs/registry) endpoint **and** a [Fumadocs](https://fumadocs.dev) documentation site, plus the live `/blocks` gallery. Deploys to [`localmode.ai`](https://localmode.ai). It distributes **copy-owned, composable AI UI primitives** ("LocalMode Elements") built on `@localmode/core` and `@localmode/react`. Components are **not** published to npm - like shadcn/ui, consumers install them with the shadcn CLI and own the copied `.tsx`. The `/blocks` gallery adds installable, composed experiences that wire those primitives to real on-device models. > This is a hosted site - end users just visit it. This README is for contributors working in `apps/ui`. ## Stack - Next.js 16 (App Router) + React 19 - Fumadocs (docs, search, `llms.txt`) - Tailwind CSS 4 + **shadcn/ui CSS-variable theming** (NOT daisyUI) - shadcn CLI (`shadcn build`) for the registry pipeline; Playwright + Vitest for tests ## Develop / build / test ```bash pnpm install # from the repo root pnpm --filter ui dev # predev runs registry:build, then next dev (:3000) pnpm --filter ui build # prebuild runs registry:build, then next build + service worker pnpm --filter ui types:check # fumadocs-mdx + next typegen + tsc --noEmit pnpm --filter ui lint # eslint pnpm --filter ui test:unit # vitest - build-script helpers (aggregates, snippet strip, markdown) pnpm --filter ui test:portability # consumer install test: primitives stay zero-@localmode pnpm --filter ui test:blocks # consumer install test: blocks land + resolve their @localmode deps pnpm --filter ui test:e2e # Playwright - /blocks pages with real model downloads + inference ``` ### The registry build pipeline `predev`/`prebuild` run `registry:build`, which is eight steps (see `scripts/`): ``` generate-block-source.ts # snapshot each block impl → src/lib/block-source.generated.ts (Code tabs) prep-registry-dirs.ts # mkdir the nested public/r/ dirs shadcn build needs shadcn build # registry.json items[] → per-item public/r/.json add-default-export.ts # append `export default ` to component payloads (Open-in-v0 auto-mount) strip-registry-blocks.ts # clean the shipped public/r/ui/blocks/**.json content payloads build-aggregates.ts # emit ui/all + per-family aggregates absolutize-registry-deps.ts # @localmode/ui/ registryDependencies → /r/ui/.json check-no-shipped-telemetry.ts # assert no telemetry in any shipped source or payload ``` `public/r/` is **generated and gitignored** - reproducible from a clean checkout, never committed. Never hand-edit `src/lib/block-source.generated.ts` (regenerated by step 1). ## Structure | Path | What lives here | | ---- | --------------- | | `registry/localmode///` | The copy-owned primitives, grouped by family. `lib/` holds the shared internal items (`utils`, `browser-utils`, `use-environment`). | | `registry.json` | The shadcn-schema catalog - the source of every registry item (also served at `/registry.json`). | | `src/app/blocks/` | The `/blocks` gallery + the block implementations. `category-map.ts` is the single source of the category → blocks structure. | | `src/app/docs/`, `content/docs/` | The Fumadocs docs surface and its MDX. | | `src/app/r/[...name]/` | Serves the prebuilt registry JSON at `/r/.json`. | | `scripts/` | The `registry:build` steps + PWA/SEO helpers, each with colocated unit tests. | | `consumer-tests/` | Real `shadcn add` install harnesses (portability + blocks lanes). | | `e2e/` | Playwright specs (`blocks/`, `redirects/`, `site/`). | ## Registry conventions ### Item-naming scheme (`ui/*`) Items publish under `ui/*` so they stay distinct from the `@localmode/*` npm packages. The shadcn namespace key is `@localmode`, mapped in a consumer's `components.json` to `https://localmode.ai/r/{name}.json`. | Item | Install | | ---- | ------- | | `ui//` | `npx shadcn add @localmode/ui//` | | `ui/` (family aggregate) | `npx shadcn add @localmode/ui/` | | `ui/all` (whole catalog) | `npx shadcn add @localmode/ui/all` | | `ui/blocks//` (or `ui/blocks/`) | `npx shadcn add @localmode/ui/blocks//` | The catalog ships 100+ primitives across 10 families plus composed blocks across 12 gallery categories - see `registry.json` and `src/app/blocks/category-map.ts` for the current set. Aggregates (`ui/all`, `ui/`) are **generated** by `scripts/build-aggregates.ts` - never hand-author them. Blocks are **excluded** from every aggregate by design, so installing an aggregate never pulls in a block or its `@localmode/*` deps. ### Primitives are zero-`@localmode` (portability invariant) Primitives are **presentational and portable to any React AI app** - local-first by design, cloud-compatible by contract. A non-block component MUST compile with **zero `@localmode/*` packages installed**: define prop shapes locally (no `import type` from `@localmode/*`), and never list `@localmode/core`/`react`/`transformers` in its `dependencies`. Generic browser helpers come from the copy-owned `@localmode/ui/lib/browser-utils`; navigator-reading hooks from `@localmode/ui/lib/use-environment`. `pnpm --filter ui test:portability` guards this - a reintroduced `@localmode/*` runtime import fails it. ### Blocks are the wiring layer (the only `@localmode` carve-out) A block (`ui/blocks/*`) is a full working surface - chat, semantic search, voice notes, object detection - that composes primitives **and** runs real on-device models. Blocks are the wiring done for the consumer, so they are the **only** registry items allowed to declare `@localmode/*` packages in `dependencies`. Because blocks are excluded from aggregates, a consumer only opts into `@localmode/*` by installing a block explicitly. Every block gates its model download behind an explicit in-block action - nothing downloads on page load. Block sources are testid-free; E2E selects via role/label/text. `pnpm --filter ui test:blocks` pins this boundary. Category names changed during development, so `next.config.mjs` 308-redirects the earlier `/blocks/` routes to their category page, and permanently redirects the 34 `localmode.ai/` URLs the retired showcase app served to the block that absorbed each one. Both tables live in `src/lib/legacy-redirects.ts`, shared with the redirect-walk E2E spec so config and test cannot drift. ## How to add a component A primitive is one copy-owned `.tsx`. `registry.json` is the catalog `shadcn build` reads, so it is the file you edit - the aggregates are generated from it. (Per-family `_family-manifest.json` files carry family metadata; there is no active step that merges them, so add the item to `registry.json` directly.) 1. **Create** `registry/localmode///.tsx` - presentational and hook-driven, styled with shadcn/ui tokens + `cn()` from `@/lib/utils`. Keep the [portability invariant](#primitives-are-zero-localmode-portability-invariant): no `@localmode/*` imports, and define prop shapes locally. Import sibling items as `@/components/` (add a matching `tsconfig.json` `paths` entry) - never a relative `..//` path, which breaks after a flat `shadcn add`. Add an optional `-demo.tsx` for the docs live preview; it **must not** load a model on mount (gate it behind an in-demo action, or mark the preview `gated`). 2. **Declare the item** in `registry.json`: ```jsonc { "name": "ui//", "type": "registry:component", "title": "…", "description": "…", "author": "LocalMode", "dependencies": ["lucide-react"], // ONLY real npm packages the .tsx imports - never @localmode/* "registryDependencies": ["@localmode/ui/lib/utils"], // + other ui/<…> items or bare shadcn names (button, dialog…) "files": [{ "path": "registry/localmode///.tsx", "type": "registry:component" }], "categories": [""] } ``` The `ui/all` and `ui/` aggregates pick it up on the next `registry:build` - **never edit the aggregates** (`scripts/build-aggregates.ts` generates them). 3. **Register the demo loader** (only if it has a `-demo.tsx`) in `src/components/preview-registry.ts`: map the item name to a `ssr:false` dynamic import so the live preview stays client-only and gated. 4. **Author the docs page** `content/docs//.mdx` - description → `` → `` → `` → examples → `` - and list it in `content/docs//meta.json`. 5. **Verify:** `pnpm --filter ui build` (runs `registry:build`, then `next build`) and `pnpm --filter ui test:portability` (proves the item installs with zero `@localmode/*` leakage). ## How to add a block A block is a single self-contained experience that wires primitives to real on-device models. It is the only kind of item allowed to depend on `@localmode/*`. 1. **Create** `src/app/blocks//.tsx` as **one self-contained file** - inline any block-specific helpers (model catalogs, tools, templates, hot-path utils) so the installed/Code-tab file has zero local `./` imports; import primitives via `@/components/`; **gate every model load behind an explicit in-block action** (nothing downloads on page open). Keep the main-file header ≤3 lines and tag any load-bearing constraint comment `/** KEEP */` - `stripSnippet` removes everything else from the shipped surface. No `data-testid`; give every control a role + accessible name instead. 2. **Add the page wrapper** `src/app/blocks//page.tsx` wrapping the impl in `BlockShell` with `source={readBlockSource('')}`. For a multi-block category, nest the block at `src/app/blocks///` and add a `src/app/blocks//page.tsx` `CategoryShell` that mounts every block of the category. 3. **Register the block** in `src/app/blocks/category-map.ts` - the single source of the category → blocks structure. A category with one block whose slug equals the category id stays flat (`ui/blocks/`); otherwise it is deep-routed (`ui/blocks//`). 4. **Declare the `registry:block` item** in `registry.json` - file `path` pointing at the impl with a `target` under `components/blocks//`, `registryDependencies` listing the composed primitives, `dependencies` listing the `@localmode/*` (and other npm) packages it imports, and `categories: ["blocks", ""]`. Blocks are excluded from aggregates automatically (the exclusion keys on the `ui/blocks/` name prefix). 5. **Add the gallery card** to `src/app/blocks/blocks-catalog.ts` (the `/blocks` grid derives from it - never hand-edit `page.tsx`). Set the card's `pageDescription` to the block's full page description so "Copy page" / "View as Markdown" match - a unit test guards against drift. 6. **Add a real-model E2E spec** `e2e/blocks/.spec.ts` - real model download + inference (no mocked model boundary), selecting via role/label/text. 7. **Verify:** `pnpm --filter ui build`, `test:unit`, `test:blocks`, and `test:e2e`. The shipped output must meet the acceptance criterion - every installed block file has **zero `data-testid`, zero QA/E2E comments, and a ≤3-line header**. ## Site surfaces Beyond the registry + docs, the app ships a full site shell: per-component live previews (gated - no model download on page load), a `/blocks` gallery with sidebar/breadcrumb chrome and per-page "Copy page" / "View as Markdown" export, an installable/offline PWA (Serwist service worker, compiled by `scripts/build-sw.mjs` postbuild), and cross-origin isolation for threaded WASM. Legacy demo URLs 308-redirect into `/blocks`. ### LocalMode Bench (`/bench`) The public cross-runtime browser-AI benchmark: `/bench` (leaderboard, ISR over the open GitHub dataset), `/bench/run` (the in-browser runner - suite/lane picker, live progress, JSON export, one-click submission; models download only behind the explicit Run action), and `/bench/methodology` (the versioned protocol, `localmode-bench/5` at the time of writing). The measurement core is [`packages/bench`](../../packages/bench) (`@localmode/bench`); the app contributes the runtime adapters (`src/lib/bench/adapters.ts`), the paired model catalog (`src/lib/bench/catalog.ts`, drift-guarded by `scripts/bench-catalog.test.ts`), and the submission/leaderboard APIs (`src/app/api/bench/*` - HMAC session nonce, server-side recompute from raw traces, plausibility rules, GitHub-as-database commits to [`LocalMode-Bench`](https://github.com/LocalMode-AI/LocalMode-Bench)). Deployment env vars and the results-repo layout are documented in [`packages/bench/SETUP.md`](../../packages/bench/SETUP.md); without them the runner works in dev mode (export only) and `/api/bench/submit` answers 503. E2E: `e2e/bench/bench.spec.ts` (real WASM-lane runs, plus wllama regression guards for templated streaming and disabled prompt caching; WebGPU lanes are covered by the manual real-Chrome sweep). Protocol v2 specifics the app surfaces: TTFT/decode are derived only from incremental streams (at least two non-empty chunks spanning at least 20% of the request); a terminal-burst stream (LiteRT-LM) shows the end-to-end rate instead, marked `e2e` in the runner and leaderboard tables. The quality lane stores raw per-item outputs and shows `(N% parsed)` beside the score when items could not be parsed (unparsed items count as wrong, so a low parse rate marks a format-limited result, not low fidelity). A timed iteration under 16 generated characters gates the cell `invalid` (`degenerate-output`). Every runtime receives the fixed prompt as a single user turn through its own chat template, and the wllama lane pins `providerOptions.wllama.cache_prompt = false` (`withoutPromptCache()` in `adapters.ts`) so every timed iteration pays prefill; the transformers lanes pass the lane's `device` to `preloadModel()`. `aggregateIndex()` in `src/lib/bench/store.ts` aggregates only index entries whose `protocol` matches the current version, so the leaderboard stays empty until the first v2 run publishes and archived v1 runs are never mixed in. Memory guidance: a standard suite peaks near 9 GB of browser memory and a thorough suite above 8 GB, so the runner recommends 16 GB of RAM for those suites; the Transformers.js lanes share one ONNX Runtime WASM heap per page that never shrinks, and under memory pressure a session can fail with `std::bad_alloc` (recorded as an error cell with its cause and a failure-time memory sample, never as data). The fixed runtime execution order is a reproducibility measure only. ## Related - Root overview and package list: [`../../README.md`](../../README.md) - The documentation site: [`../docs/README.md`](../docs/README.md)