# @proveanything/smartlinks-utils-ui

> Reusable React components for SmartLinks microapps — Asset Picker, Conditions Editor, Icon Picker, and more.

Latest version **2.0.5** (published 2026-10-12) · MIT license · 0 weekly downloads

## Install

```sh
npm install @proveanything/smartlinks-utils-ui
pnpm add @proveanything/smartlinks-utils-ui
yarn add @proveanything/smartlinks-utils-ui
bun add @proveanything/smartlinks-utils-ui
```

## Health

**Score 65/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 2.0.5 |
| Published | 2026-10-12 |
| First published | 2026-02-20 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Dependencies | 3 |
| Unpacked size | 6 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Maintainers | proveanything |
| Keywords | smartlinks, react, components, asset-picker, conditions-editor, icon-picker, digital-twin |

## Links

- npm: https://www.npmjs.com/package/@proveanything/smartlinks-utils-ui
- Repository: https://github.com/proveanything/smartlinks-ui
- Homepage: https://github.com/proveanything/smartlinks-ui#readme
- Issues: https://github.com/proveanything/smartlinks-ui/issues
- npm.io page: https://npm.io/package/@proveanything/smartlinks-utils-ui

## Dependencies (3)

- [clsx](https://npm.io/package/clsx.md) ^2.1.1
- [liquidjs](https://npm.io/package/liquidjs.md) ^10.29.0
- [tailwind-merge](https://npm.io/package/tailwind-merge.md) ^2.5.2

## Alternatives

- [mobx-react](https://npm.io/package/mobx-react.md) — 2.8M weekly downloads
- [rc-tree](https://npm.io/package/rc-tree.md) — 2.6M weekly downloads
- [@react-oauth/google](https://npm.io/package/@react-oauth/google.md) — 1.3M weekly downloads
- [@wagmi/connectors](https://npm.io/package/@wagmi/connectors.md) — 877.0K weekly downloads
- [vee-validate](https://npm.io/package/vee-validate.md) — 836.4K weekly downloads

## Recent versions

- 2.0.5 (latest) — 2026-10-12
- 2.0.4 — 2026-10-06
- 2.0.3 — 2026-10-05
- 2.0.2 — 2026-09-24
- 2.0.1 — 2026-09-21
- 2.0.0 — 2026-09-21
- 1.16.4 — 2026-09-17
- 1.16.3 — 2026-09-17
- 1.16.2 — 2026-09-17
- 1.16.1 — 2026-09-17
- 1.16.0 — 2026-09-17
- 1.15.1 — 2026-07-22
- 1.15.0 — 2026-07-19
- 1.14.4 — 2026-07-02
- 1.14.3 — 2026-07-02
- … 130 more at https://npm.io/package/@proveanything/smartlinks-utils-ui/versions

## README

# @proveanything/smartlinks-utils-ui

Reusable React components for [SmartLinks](https://smartlinks.app) microapps —
the headline module is **RecordsAdminShell**, a complete admin UI for the
`app.records` pattern (global / rule-targeted / per-product data with
automatic variant & batch drill-down where the collection supports them).
Also ships an Asset Picker, Icon Picker, Font Picker, Link Picker, Conditions
Editor, and a friendly Liquid template builder.

- Package: `@proveanything/smartlinks-utils-ui`
- Tracks: `@proveanything/smartlinks ≥ 1.9`
- **Per-component reference docs:** [`docs/`](./docs/README.md) — props, slots, behaviour, examples
- **Conceptual SDK docs:** [`ui-utils.md`](https://github.com/proveanything/smartlinks-sdk/blob/main/docs/ui-utils.md) and [`records-admin-pattern.md`](https://github.com/proveanything/smartlinks-sdk/blob/main/docs/records-admin-pattern.md)

## Installation

```bash
npm install @proveanything/smartlinks-utils-ui
```

### Peer dependencies

```bash
npm install react react-dom @proveanything/smartlinks @tanstack/react-query lucide-react
```

Supported host versions: React **18.3 or 19**, `@proveanything/smartlinks` **^2.0.0**,
`@tanstack/react-query` **^5**, `lucide-react` **^0.462 or ^1.x**. React, the SDK,
React Query, `liquidjs`, `lucide-react` and TipTap are all treated as externals —
the package never bundles a second copy, so `npm install` needs no
`--legacy-peer-deps`.

`@tanstack/react-query` is required by `RecordsAdminShell` (caching, pagination,
optimistic save). Wrap your app in a `<QueryClientProvider>` somewhere up the
tree.


## Setup

Import the pre-compiled styles **once** in your app entry (e.g. `main.tsx`):

```tsx
import '@proveanything/smartlinks-utils-ui/styles.css';
```

Components inherit your shadcn-compatible CSS variables (`--primary`,
`--background`, `--border`, …) and pick up your theme automatically.

### Tailwind CSS 4 hosts

V2 is built with **Tailwind CSS 4**. The package publishes fully compiled CSS,
so your app should import `styles.css` as shown above but must **not** add this
package to its Tailwind `@source` paths. Utilities inside the package resolve
through your existing shadcn-compatible CSS variables, including `.dark`
overrides; the package does not install a reset or replace your app's tokens.

When migrating a host app from Tailwind 3, follow Tailwind's normal v4 migration
and keep these variables available: `--background`, `--foreground`, `--card`,
`--card-foreground`, `--popover`, `--popover-foreground`, `--primary`,
`--primary-foreground`, `--secondary`, `--secondary-foreground`, `--muted`,
`--muted-foreground`, `--accent`, `--accent-foreground`, `--destructive`,
`--destructive-foreground`, `--border`, `--input`, `--ring`, and `--radius`.

### V2 host checklist

- Use React 18.3 or 19 and `@proveanything/smartlinks` 2.x.
- Move the host app to Tailwind CSS 4 and retain its semantic CSS variables.
- Import `@proveanything/smartlinks-utils-ui/styles.css` exactly once.
- Keep `QueryClientProvider` above `RecordsAdminShell`.
- Install TipTap peers only when importing `/liquid-tiptap`.
- Remove picker-specific z-index, Escape, and scroll-lock workarounds; V2 handles
  nested dialogs through its shared layer manager.

## Modules

| Subpath | Module | Use when |
|---------|--------|----------|
| `/records-admin` | **RecordsAdminShell** + hooks (`useResolvedRecord`, `useRecordEditor`, …) | Building any global / rule-targeted / per-product admin (variant & batch drill-down is automatic) |
| `/asset-picker` | AssetPicker | Pick / upload / paste / URL-import media assets |
| `/icon-picker` | IconPicker | Searchable Font Awesome 7 Pro picker |
| `/font-picker` | FontPicker | Google Fonts + custom uploaded fonts |
| `/conditions-editor` | ConditionsEditor | Recursive AND/OR rule builder (12 condition types) |
| `/facet-rule-editor` | FacetRuleEditor | Server-side facet rules (AND-of-OR) for record targeting |
| `/link-picker` | LinkPicker | External URL / app / deep-link picker (stores a `LinkTarget`) |
| `/liquid-editor` | **LiquidField** + `VariableBrowser`, `useLiquidFields`, schema helpers | Let non-technical operators write Liquid templates by clicking labelled fields |
| `/liquid-tiptap` | LiquidRichField + `LiquidVariableNode` | The same field chips inside a TipTap rich text editor (optional peer dep) |

Each module has a full reference doc under [`docs/`](./docs/README.md):

- [RecordsAdminShell](./docs/records-admin-shell.md) · [Records hooks](./docs/records-admin-hooks.md) · [AdminPageHeader](./docs/admin-page-header.md)
- [AssetPicker](./docs/asset-picker.md)
- [IconPicker](./docs/icon-picker.md)
- [FontPicker](./docs/font-picker.md)
- [ConditionsEditor](./docs/conditions-editor.md) · [FacetRuleEditor](./docs/facet-rule-editor.md)
- [LinkPicker](./docs/link-picker.md)
- [Liquid editor](./docs/liquid-editor.md) · [LiquidRichField (TipTap)](./docs/liquid-tiptap.md)


### Records Admin Shell

> 🎨 **Admin chrome is neutral — not your brand.** `AdminPageHeader` /
> `RecordsAdminShell` render deliberately neutral admin chrome (black/zinc/slate),
> and admin surfaces **must** support dark mode. Do **not** pull the public/customer
> theme into admin: no branded headers, no customer colours, no marketing fonts. The
> public surface is where the brand lives; admin is a consistent operator tool across
> every app. (App authors routinely reach for the public theme here — resist it.)

> ⚠️ **Before you mount the shell, decide cardinality.** If your app is a
> list of things (auction items, FAQs, image gallery, perks) you want
> `items.cardinality: 'list'`. If it's one winning record per scope
> (warranty, nutrition, care instructions) keep the default `'singleton'`.
> The default is `'singleton'`, so multi-item apps **must opt in** — see
> [Choosing cardinality](./docs/records-admin-shell.md#-choosing-cardinality-read-this-first).
> (`'collection'` is still accepted as a deprecated alias for `'list'`.)

```tsx
import {
  RecordsAdminShell,
  type EditorContext,
} from '@proveanything/smartlinks-utils-ui/records-admin';
import * as SL from '@proveanything/smartlinks';

<RecordsAdminShell<NutritionData>
  SL={SL}
  collectionId={collectionId}
  appId={appId}
  recordType="nutrition"
  label="Nutrition info"
  scopes={['collection', 'rule', 'product']}
  contextScope={{ productId, variantId, batchId }} // optional, from iframe URL
  defaultData={() => ({})}
  renderEditor={(ctx) => <NutritionForm ctx={ctx} />}
  renderPreview={({ resolved }) => <pre>{JSON.stringify(resolved, null, 2)}</pre>}
/>
```

The shell renders the browser pane (scope tabs, search, status pills), the
editor pane (sticky save / discard / delete footer with optimistic save), and
the inheritance markers — you only provide the form for one record.

If the side list has only one configured scope, its scope selector is hidden automatically. Override with `rail.scopeNavigation: 'show' | 'hidden'`, or rename visible choices with `rail.scopeLabels` (for example `{ collection: 'All posts' }`).

For a blog, FAQ, gallery or other multi-entry publisher, use list cardinality
and name the entries once. The built-in title, create buttons, search and empty
state then use that terminology automatically. A global-only list opens as a
full-page table/card browser; adding Rules or Products brings back the scope rail.

```tsx
<RecordsAdminShell
  scopes={allowTargeting ? ['collection', 'rule', 'product'] : ['collection']}
  items={{
    cardinality: 'list',
    noun: 'blog post',
    pluralNoun: 'blog posts',
    views: ['table', 'cards'],
  }}
  {...props}
/>
```

When a noun is the first word of a built-in string (the list heading defaults
to the plural noun on its own), it is capitalised automatically — "New blog
post", "No blog posts yet", but the list heading reads "Blog posts". Templates
that lead with other words keep the noun exactly as configured.

On the **public widget** side, read the resolved value with `useResolvedRecord`:

```tsx
import { useResolvedRecord } from '@proveanything/smartlinks-utils-ui/records-admin';

const { data, source, isLoading } = useResolvedRecord({
  SL, appId, recordType: 'nutrition',
  collectionId, productId, variantId, batchId,
});
// source: 'proof' | 'batch' | 'variant' | 'product' | 'rule' | 'collection' | null
```

### Asset Picker

```tsx
import { AssetPicker } from '@proveanything/smartlinks-utils-ui/asset-picker';

<AssetPicker
  scope={{ type: 'collection', collectionId: 'abc123' }}
  mode="dialog"
  allowUpload
  accept={['image/*']}
  onSelect={(asset) => setHeroUrl(asset.url)}
  trigger={<button>Choose image</button>}
/>
```

### Conditions Editor

```tsx
import { ConditionsEditor } from '@proveanything/smartlinks-utils-ui/conditions-editor';

<ConditionsEditor
  value={rules}
  onChange={setRules}
  collectionId={collectionId} // auto-loads facet definitions
  versions={[{ title: 'Default', value: '' }]}
  tags={['featured', 'new']}
/>
```

### Icon Picker

```tsx
import { IconPicker } from '@proveanything/smartlinks-utils-ui/icon-picker';

<IconPicker
  mode="dialog"
  value="fa-solid fa-heart"
  onSelect={(icon) => setIcon(icon.name)}
  trigger={<button>Pick icon</button>}
/>
```

Requires the Font Awesome 7 Pro kit script on the host page.

### Font Picker

```tsx
import { FontPicker } from '@proveanything/smartlinks-utils-ui/font-picker';

<FontPicker
  mode="dialog"
  value="Inter"
  showPreview
  onSelect={(font) => {
    console.log(font.cssFontFamily); // ready for `font-family:` CSS
    console.log(font.loadSnippet);   // <link> or @font-face block to inject
  }}
/>
```

### Link Picker

Always persist the `LinkTarget` union — never a resolved URL — and resolve it at
runtime with `SL.navigation.resolveLink()`.

```tsx
import { LinkPicker, type LinkTarget } from '@proveanything/smartlinks-utils-ui/link-picker';

<LinkPicker
  collectionId={collectionId}
  value={link}                    // LinkTarget | undefined
  onChange={setLink}
/>
```

Supports external URLs, other apps in the collection, and deep links into an app
(including custom deep-link params). See [link-picker.md](./docs/link-picker.md).

### Liquid Templates

A Liquid template builder aimed at **non-technical operators**: they click
labelled fields ("Product name", "Lot number") and never have to type Liquid.
Chips render inline, clicking one opens a settings panel (swap field, fallback,
date style, number padding, capitalisation), and a live preview + friendly
validation sit underneath. What gets **saved** is ordinary Liquid.

```tsx
import { LiquidField, useLiquidFields } from '@proveanything/smartlinks-utils-ui/liquid-editor';

// Optional: ask the platform which custom fields this brand actually has
const { schema, sampleData } = useLiquidFields({
  collectionId,
  productId, proofId, batchId, variantId, lotId, // optional — pulls real example values
  objects: ['contact', 'product', 'proof', 'lot'],
  extraSchema: [{
    key: 'order',
    label: 'Order',
    variables: [{ path: 'order.reference', label: 'Order reference', example: 'ORD-1042' }],
  }],
});

<LiquidField
  label="Email body"
  value={body}
  onChange={setBody}
  schema={schema}
  sampleData={sampleData}
/>
```

Key points for host apps:

- **Objects you support** — the editor does not guess the brand's subscription.
  Pass `objects={['contact','product','lot']}` to hide the rest (`collection`,
  `product`, `proof` (item record), `variant`, `batch`, `lot`, `contact`,
  `attestation`, `interaction`, `case`, `thread`, `record`, `user`). A bare key
  keeps the whole group; a dotted key offers a single field, e.g.
  `objects={['contact.firstName','contact.lastName','product']}`.
- **Standard SDK objects** — interactions, cases, conversations and app records
  are built in with their standard wrapper fields. Their free-form payloads
  (`data`, `owner`, `admin`, `metadata`) are app-specific: pass a real record via
  `useLiquidFields({ samples: { case, thread, record, interaction } })` to have
  those keys discovered with live examples, or declare them in `extraSchema`.
- **Your own fields** — merge them with `extraSchema`; they appear as a normal
  group in the field list with their example values.
- **Custom fields, automatically** — `useLiquidFields` reads the brand's
  declared product / item / contact field definitions from the SDK and, when
  given record IDs, real example values. Failures are ignored per-source, so a
  brand without lots just gets fewer groups.
- **Single-line use** — `<LiquidField singleLine showPreview={false} />` for
  subject lines and short labels.
- `liquidjs` is bundled as a dependency and lazy-loaded — apps that only use
  `VariableBrowser` never download the engine.

Rich text variant (Markdown out, chips in the editor):

```tsx
import { LiquidRichField } from '@proveanything/smartlinks-utils-ui/liquid-tiptap';

<LiquidRichField value={markdown} onChange={setMarkdown} format="markdown" />
```

TipTap is an **optional peer dependency** — install it only if you use this
component: `npm i @tiptap/react @tiptap/core @tiptap/pm @tiptap/starter-kit tiptap-markdown`.

The toolbar is yours to arrange — pass an ordered list of built-in buttons,
custom buttons, and a `'spacer'` that pushes the rest to the right. Handy when
the message is mostly hand-written text and the dynamic fields are a side
option:

```tsx
<LiquidRichField
  value={body}
  onChange={setBody}
  toolbar={['bold', 'italic', 'separator', 'bulletList', 'spacer', 'fields']}
/>
```

Extra TipTap extensions go in via `extraExtensions`, paired with your own
toolbar button.

Full reference: [liquid-editor.md](./docs/liquid-editor.md) ·
[liquid-tiptap.md](./docs/liquid-tiptap.md).

## Dialogs and nested modals

Every modal surface in the toolkit (Asset Picker, Icon Picker, Font Picker, Link
Picker, the Records Admin dialogs and the Liquid field panels) runs through one
shared layer stack. That means you can open any picker **from inside your own
dialog** — including a Radix `Dialog` — without host-side workarounds:

- page scroll is locked once and restored exactly once, by the last layer to close;
- each layer stacks above its parent automatically (no z-index juggling);
- **Escape** closes only the topmost layer and does not bubble to your dialog;
- focus moves into the new layer on open and returns to the previous element on close;
- clicks inside our panels never reach a host click-outside handler.

If you previously suppressed Escape or unlocked scrolling by hand around a picker,
you can remove that code.

## Prerequisites


All components assume `@proveanything/smartlinks` is initialised in your app
via `SL.initializeApi()`. Admin components (RecordsAdminShell, AssetPicker
upload, etc.) call the SDK with `admin: true` — do not render them in
public-facing views.

## Tree shaking

Each component has its own subpath export — bundle only what you use:

```tsx
// Bundles only RecordsAdmin
import { RecordsAdminShell } from '@proveanything/smartlinks-utils-ui/records-admin';

// Barrel import — bundler tree-shakes the rest
import { RecordsAdminShell } from '@proveanything/smartlinks-utils-ui';
```

If you use subpath imports, import `styles.css` separately — subpaths do not
pull it in automatically.

## Development

```bash
cd packages/smartlinks-ui
npm install
npm run build    # tsup
npm run dev      # watch mode
```

## License

MIT

---
_Source: https://npm.io/package/@proveanything/smartlinks-utils-ui · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
