Skip to content

Latest commit

 

History

History
130 lines (95 loc) · 12 KB

File metadata and controls

130 lines (95 loc) · 12 KB

apps/ui — LocalMode UI

The @localmode/ui platform: a single Next.js 16 app that is both a shadcn registry endpoint and a Fumadocs documentation site, plus the live /blocks gallery. Deploys to 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

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/<name>.json
add-default-export.ts        # append `export default <Component>` 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/<item> registryDependencies → <origin>/r/ui/<item>.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/<family>/<component>/ 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/<name>.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/<family>/<component> npx shadcn add @localmode/ui/<family>/<component>
ui/<family> (family aggregate) npx shadcn add @localmode/ui/<family>
ui/all (whole catalog) npx shadcn add @localmode/ui/all
ui/blocks/<category>/<block> (or ui/blocks/<name>) npx shadcn add @localmode/ui/blocks/<category>/<block>

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/<family>) 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/<name> routes to their category page, and permanently redirects the 34 localmode.ai/<slug> 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/<family>/<component>/<component>.tsx — presentational and hook-driven, styled with shadcn/ui tokens + cn() from @/lib/utils. Keep the portability invariant: no @localmode/* imports, and define prop shapes locally. Import sibling items as @/components/<item> (add a matching tsconfig.json paths entry) — never a relative ../<item>/ path, which breaks after a flat shadcn add. Add an optional <component>-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:
    {
      "name": "ui/<family>/<component>",
      "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/<family>/<component>/<component>.tsx", "type": "registry:component" }],
      "categories": ["<family>"]
    }
    The ui/all and ui/<family> aggregates pick it up on the next registry:buildnever 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/<family>/<component>.mdx — description → <ComponentPreview name="ui/<family>/<component>" /><InstallTabs name="…" /><AutoTypeTable path="…" name="<Component>Props" /> → examples → <OpenInV0 name="…" /> — and list it in content/docs/<family>/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/<name>/<name>.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/<item>; 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/<name>/page.tsx wrapping the impl in BlockShell with source={readBlockSource('<name>')}. For a multi-block category, nest the block at src/app/blocks/<category>/<slug>/ and add a src/app/blocks/<category>/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/<slug>); otherwise it is deep-routed (ui/blocks/<category>/<slug>).
  4. Declare the registry:block item in registry.json — file path pointing at the impl with a target under components/blocks/<name>/, registryDependencies listing the composed primitives, dependencies listing the @localmode/* (and other npm) packages it imports, and categories: ["blocks", "<category>"]. 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/<name>.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.

Related