# Kit & Core Reference

`@uniweb/kit` is the developer-facing API for Uniweb foundations. It provides React hooks for accessing site context, primitive components (Link, Image, Icon), and utility functions. When you need something from the framework, you import it from kit.

The data classes underneath — Website, Page, Block from `@uniweb/core` — are documented below for reference. You access them through hooks and the `block` prop, not by importing core directly.

---

## Hooks

### useWebsite

Access the current website instance.

```jsx
import { useWebsite } from '@uniweb/kit'

function Header() {
  const { website } = useWebsite()

  return (
    <header>
      <h1>{website.name}</h1>
      <nav>
        {website.getPageHierarchy({ for: 'header' }).map(page => (
          <a key={page.id} href={page.route}>{page.label}</a>
        ))}
      </nav>
    </header>
  )
}
```

#### Return Value

```js
const { website } = useWebsite()
```

| Property | Type | Description |
| --- | --- | --- |
| `website` | Website | The active website instance |

### useRouting

SSG-safe access to routing functionality. Returns hook functions that you call to get routing data.

```jsx
import { useRouting } from '@uniweb/kit'

function NavLink({ href, children }) {
  const { useLocation, useNavigate } = useRouting()
  const location = useLocation()
  const navigate = useNavigate()

  const isActive = location.pathname === href

  return (
    <a
      href={href}
      className={isActive ? 'active' : ''}
      onClick={(e) => {
        e.preventDefault()
        navigate(href)
      }}
    >
      {children}
    </a>
  )
}
```

#### Return Value

| Property | Type | Description |
| --- | --- | --- |
| `useLocation()` | function | Returns location object `{ pathname, search, hash }` |
| `useParams()` | function | Returns the route parameters of a parametric page |
| `useNavigate()` | function | Returns navigate function for programmatic navigation |
| `Link` | component | Router Link component (or `'a'` fallback) |
| `isRoutingAvailable()` | function | Check if router context is available |

**SSG Safety**: During SSG/prerender, these return sensible defaults (empty pathname, empty params, no-op navigate) instead of throwing errors.

### useActiveRoute

Active route detection for navigation highlighting.

```jsx
import { useActiveRoute } from '@uniweb/kit'

function NavItem({ page }) {
  const { isActive, isActiveOrAncestor } = useActiveRoute()

  return (
    <a
      href={page.route}
      className={isActiveOrAncestor(page) ? 'active' : ''}
    >
      {page.label}
    </a>
  )
}
```

#### Return Value

| Property | Type | Description |
| --- | --- | --- |
| `route` | string | Current normalized route (e.g., `'docs/getting-started'`) |
| `rootSegment` | string | First segment of route (e.g., `'docs'`) |
| `isActive(pageOrRoute)` | function | Check exact match with current route |
| `isActiveOrAncestor(pageOrRoute)` | function | Check if page or its children are active |

Both `isActive` and `isActiveOrAncestor` accept a **page object** or a **route string**:

```jsx
isActive(page)          // page object with .route property
isActive('/blog')       // route string — same comparison logic
isActiveOrAncestor('/research')  // matches /research, /research/papers, etc.
```

Use `isActiveOrAncestor` for parent nav items that should highlight when child pages are active.

**Why `useActiveRoute` instead of `website.activePage`:** The hook reads from React Router's location, which updates synchronously during navigation. `website.activePage` is a property on a vanilla JS singleton — it's always correct but isn't reactive in the React sense, so it won't trigger re-renders on its own. The hook also delegates route comparison to `Website.isRouteActive()`, which handles normalization and base path, and accepts both page objects and strings without branching.

### usePageState / useWebsiteState

Bridge observable state on `page` and `website` into React. `page.state` is scoped to the current page; `website.state` is site-wide. Both persist across SPA navigation and are readable from outside React (sibling components, non-React helpers).

```jsx
import { usePageState } from '@uniweb/kit'

function QuerySelector() {
  const [slug, setSlug] = usePageState('selectedQuery', 'all-members')

  return (
    <select value={slug} onChange={(e) => setSlug(e.target.value)}>
      <option value="all-members">Everyone</option>
      <option value="tenured-biology">Tenured biologists</option>
    </select>
  )
}
```

Signature:

```js
const [value, setValue] = usePageState(key, defaultValue?)
const [value, setValue] = useWebsiteState(key, defaultValue?)
```

The hook subscribes to the keyed slot of the active page's (or website's) state, returns the current value (or `defaultValue` if unset), and re-renders the calling component when the slot changes. The setter writes to the same slot and fires listeners across any other subscribers.

**State changes drive React re-renders, not re-fetches.** Components subscribed to the changed slot re-render and recompute from already-loaded data (typically via `useMemo` keyed on the state value, or a utility like `@uniweb/core`'s `matchWhere`). The framework does not re-dispatch fetches when state changes — the Uniweb model is "fetch once, filter in place" for this pattern. Components that need to re-fetch on user action are domain-aware components that own their own fetching with standard React (`useEffect + fetch`). See [Component Data Patterns](../development/component-data-patterns.md) for the two-role framing.

**What belongs in `page.state` / `website.state`:**

| State | Where it goes |
| --- | --- |
| Filter / sort / toggle state that reshapes already-loaded data | `page.state` or `website.state` |
| Cross-section coordination on a single page (active tab across a tab group, collapsed sidebar state) | `page.state` |
| Cross-page UI (appearance preference, authenticated user, cart open/closed) | `website.state` |
| Component-internal UI state (modal open, input focus, hover) | React's `useState` — not these hooks |
| Fetched data | `content.data` — not these hooks |

These are small observable value stores — plain keys with values. No reactive derivations, no computed signals; if you want that, compose `useMemo` / `useEffect` in the kit layer around these hooks.

### useVersion

Access version information for versioned documentation.

```jsx
import { useVersion } from '@uniweb/kit'

function VersionSwitcher() {
  const {
    isVersioned,
    currentVersion,
    versions,
    getVersionUrl,
    isDeprecatedVersion
  } = useVersion()

  if (!isVersioned) return null

  return (
    <select
      value={currentVersion?.id}
      onChange={(e) => window.location.href = getVersionUrl(e.target.value)}
    >
      {versions.map(v => (
        <option key={v.id} value={v.id}>
          {v.label}
          {v.deprecated && ' (deprecated)'}
        </option>
      ))}
    </select>
  )
}
```

#### Return Value

| Property | Type | Description |
| --- | --- | --- |
| `isVersioned` | boolean | Is current page in a versioned scope? |
| `currentVersion` | object | `{ id, label, latest, deprecated }` |
| `versions` | array | All versions in current scope |
| `latestVersionId` | string | ID of the latest version |
| `versionScope` | string | Route where versioning starts |
| `isLatestVersion` | boolean | Is current the latest version? |
| `isDeprecatedVersion` | boolean | Is current version deprecated? |
| `getVersionUrl(id)` | function | Compute URL for a version |
| `hasVersionedContent` | boolean | Does site have any versioned content? |
| `versionedScopes` | object | Map of scope → `{ versions, latestId }` |

### useThemeData

Access theme configuration.

```jsx
import { useThemeData } from '@uniweb/kit'

function ColorPalette() {
  const theme = useThemeData()

  if (!theme) return null

  const palettes = theme.getPaletteNames()
  const primary500 = theme.getColor('primary', 500)

  return (
    <div style={{ color: primary500 }}>
      Available: {palettes.join(', ')}
    </div>
  )
}
```

See [Site Theming](./site-theming.md) for the full Theme API.

### useColorContext

The color context a section renders in — `'light'`, `'medium'` or `'dark'`. A section pinned with `theme:` renders in that context; one that follows the site renders in the site's current scheme. For a component's own logic that tokens can't cover — a light or dark logo, say — since `theme` is a setting of the section, not a param.

```jsx
import { useColorContext } from '@uniweb/kit'

function Logo({ block }) {
  const context = useColorContext(block)
  return <img src={context === 'dark' ? '/logo-light.svg' : '/logo.svg'} alt="" />
}
```

### useAppearance

Control light/dark mode.

```jsx
import { useAppearance } from '@uniweb/kit'

function DarkModeToggle() {
  const { scheme, toggle, canToggle } = useAppearance()

  if (!canToggle) return null

  return (
    <button onClick={toggle}>
      {scheme === 'dark' ? '☀️ Light' : '🌙 Dark'}
    </button>
  )
}
```

#### Return Value

| Property | Type | Description |
| --- | --- | --- |
| `scheme` | string | Current scheme: `'light'` or `'dark'` |
| `toggle` | function | Switch between schemes |
| `canToggle` | boolean | Is toggling enabled? |
| `setScheme(s)` | function | Set a specific scheme |

The runtime applies the scheme class to `<html>` before the page paints, and this hook reads it back — so `scheme` is correct on the very first render, with no flash. `toggle` and `setScheme` persist the visitor's choice.

**Don't also write the class or touch `localStorage` from your component.** That makes a second writer racing the runtime's, which is how a toggle ends up needing two clicks. `canToggle` is false unless the site's `theme.yml` enables toggling — if your button never appears, that's the config to check. See [site theming](./site-theming.md#rendering-a-toggle).

### useThemeColor / useThemeColorVar

Convenience hooks for accessing theme colors.

```jsx
import { useThemeColor, useThemeColorVar } from '@uniweb/kit'

function Badge() {
  // Get actual color value
  const accentColor = useThemeColor('accent', 600)

  // Get CSS variable reference
  const primaryVar = useThemeColorVar('primary', 500)

  return (
    <span style={{ background: accentColor, borderColor: primaryVar }}>
      New
    </span>
  )
}
```

### useInView

Detect when an element enters the viewport. Useful for lazy loading and scroll animations.

```jsx
import { useInView } from '@uniweb/kit'

function LazyImage({ src, alt }) {
  const { ref, inView } = useInView({
    threshold: 0.1,
    triggerOnce: true
  })

  return (
    <div ref={ref}>
      {inView ? (
        <img src={src} alt={alt} />
      ) : (
        <div className="placeholder" />
      )}
    </div>
  )
}
```

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `threshold` | number | `0` | Visibility threshold (0-1) |
| `triggerOnce` | boolean | `false` | Only trigger once |
| `rootMargin` | string | `'0px'` | Margin around root |

### useHeadings

The headings of the page being read, and which one the reader is level with. What a table of contents needs, with no opinion about how it looks.

```jsx
import { useHeadings } from '@uniweb/kit'

function PageContents() {
  const { headings, activeId, scrollTo } = useHeadings()
  if (!headings.length) return null

  return (
    <nav aria-label="On this page">
      {headings.map((heading) => (
        <button
          key={heading.id}
          onClick={() => scrollTo(heading.id)}
          className={heading.id === activeId ? 'text-primary' : 'text-subtle'}
        >
          {heading.text}
        </button>
      ))}
    </nav>
  )
}
```

Headings come back nested — each carries `{ id, text, level, children }`. The list is derived from the page's own content, so it is present in prerendered HTML rather than appearing after hydration; only `activeId` needs the browser, because only scroll position does. A DOM scan is the fallback for content the hook cannot see.

The anchor ids match what `<Render>` and `<Prose>` stamp — same generator — so `scrollTo` always finds its target.

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `levels` | number[] | `[2, 3]` | Heading levels to collect |
| `root` | string | `'main'` | Selector to scan, for the DOM fallback |
| `offset` | number | header height | Scroll offset in px; defaults to how far the header reaches — `--uw-sticky-top` when the header is a sticky section, else `--header-height` — plus a little room |

Pair it with `website.getBranchHierarchy({ route, for: 'left' })` for the other half of a documentation shell — the navigation rail. See [Website](#website).

### useShortcut / useShortcuts / useShortcutLabel

Bind a keyboard shortcut, and render it the way the visitor's platform writes it.

```jsx
import { useShortcut, useShortcutLabel } from '@uniweb/kit'

function Header() {
  const [open, setOpen] = useState(false)
  useShortcut('mod+k', () => setOpen(true))

  return (
    <button onClick={() => setOpen(true)}>
      Search <kbd>{useShortcutLabel('mod+k')}</kbd>
    </button>
  )
}
```

`mod` is the portable modifier: it matches **Meta or Control on every platform**, so a binding cannot silently fail to fire on somebody's machine. Platform detection happens only in the label, where a wrong guess is cosmetic rather than a dead shortcut. `useShortcutLabel('mod+k')` returns `⌘K` on Apple platforms and `Ctrl+K` elsewhere — which is the point: a hardcoded `⌘` names a key that Windows and Linux visitors do not have.

Kit ships **no default binding and knows no action names**. Which key opens what is your foundation's decision, the same way the site owns its theme values.

```jsx
useShortcut('/', focusSearch)                    // bare key
useShortcut('escape', close, { whileTyping: true })
useShortcuts({ 'mod+k': open, escape: close })   // one listener for several
```

Binding syntax: modifiers `mod`, `ctrl`, `alt`/`option`, `shift`, `meta`/`cmd`, joined with `+`; keys are a single character or a name (`escape`, `enter`, `space`, `tab`, `up`/`down`/`left`/`right`). Case-insensitive.

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | boolean | `true` | Bind only while true |
| `whileTyping` | boolean | *derived* | Fire while an input, textarea or contenteditable has focus |
| `preventDefault` | boolean | `true` | Call `preventDefault()` when it fires |
| `target` | EventTarget | `window` | Listen somewhere other than `window` |

`whileTyping` defaults from the binding rather than a fixed value: a modifier combo fires while a field has focus — including the field it opens — and a bare key does not, since it would otherwise hijack typing. Pass it explicitly to override; `escape` usually wants `true`.

SSR-safe: everything touching the DOM runs inside an effect, so these are inert during prerender. In development, binding one combo twice logs a warning — both handlers fire and both call `preventDefault()`, which otherwise produces no visible symptom.

`useSearchShortcut` still exists as an alias for `useShortcut('mod+k', …)`, kept so existing foundations keep working. Prefer `useShortcut` in new code: it says the same thing without implying the framework chose the key.

### useSearch

Search the site. Which engine answers is a **deployment** choice — a local index the browser downloads, or an endpoint the host serves — and a component is written once against both.

```jsx
import { useSearch } from '@uniweb/kit'

function SearchBox({ website }) {
  const { query, results, total, isLoading, isEnabled } = useSearch(website)

  if (!isEnabled) return null

  return (
    <div>
      <input onChange={(e) => query(e.target.value)} />
      {isLoading && <span>Searching…</span>}
      {total !== null && <span>Showing {results.length} of {total}</span>}
      {results.map((r) => (
        <a key={r.id} href={r.href}>{r.title}</a>
      ))}
    </div>
  )
}
```

| Returns |  |
| --- | --- |
| `results` | matches, capped at `limit` (default 10) |
| `total` | how many matched **before** the cap — the 47 in "showing 10 of 47". `null` when the active provider cannot say |
| `isLoading` | a query is in flight |
| `error` | the `Error` from a failed search, or `null` |
| `lastQuery` | the text of the most recent query |
| `isEnabled` | whether search works on this site — the same answer as [`isSearchEnabled()`](#service-predicates) |
| `query(text, options?)` | run a search (debounced); resolves with the results array |
| `clear()` | drop results and reset |
| `preload()` | warm the index ahead of first use — a no-op for providers with nothing to warm |

**`total` is `null` when unknown, and that is a normal state rather than an error.** A local index always knows the count; an endpoint knows it only if it reports one. Whether a count arrives is a property of how the site is deployed, not of its content — so render the count conditionally and the results unconditionally.

⚠️ `total` is also `null` when a `type:` or `route:` filter narrowed the results locally. A server that does not apply those filters counted a different set, and reporting its number beside a narrowed list would read as "showing 1 of 47" next to a filter that produced the 1 — consistent and wrong.

Every result carries a guaranteed core — `id`, `type`, `route`, `href`, `title`, `pageTitle`, `excerpt`, `snippetHtml` — so a component may render those unconditionally. Everything else (`matches`, `item`, `group`, `sectionId`, …) is provider-optional and `null` where unavailable, for the same reason `total` is. `snippetHtml` contains `<mark>` elements: render it through kit's `SafeHtml`, never as text.

For a count outside React, `createSearchClient(website).queryWithTotal(text, options)` returns `{ results, total }`; plain `query()` returns the results array.

---

### block.dataLoading and block.dataError

A section's data arrives in `content.data`, under the keys its component declares in `meta.js` `data:`. Two properties on the `block` say where a fetch stands:

```jsx
import { DataPlaceholder } from '@uniweb/kit'

function ArticleList({ content, block }) {
  if (block.dataLoading) {
    return <DataPlaceholder lines={4} />
  }
  if (block.dataError?.articles) {
    return <p>Could not load articles.</p>
  }

  const articles = content.data.articles ?? []
  return <ArticleGrid articles={articles} />
}
```

| Property | Value |
| --- | --- |
| `block.dataLoading` | `true` while a fetch the section waits on is in progress |
| `block.dataError` | `null`, or an object keyed like `content.data` holding each failed fetch's message — `{ articles: 'HTTP 502: Bad Gateway' }` |

A declared key is `null` while its fetch is pending, when it failed, and when nothing on the page fills it; `[]` is an answer with no records. The runtime re-renders the section when the fetch completes. See [Data Fetching → What a section receives](./data-fetching.md#what-a-section-receives).

---

### useWholeRecord

Fetch one record whole, on demand, when a section holds less than the record — a list carries each record's brief, on a static site and on a host alike, and an external query may list summaries and name one record's request with `record:`. A section on a parametric page whose key is declared `whole: true` already receives its record whole ([Component Metadata → Briefs or whole records](./component-metadata.md#briefs-or-whole-records)); this hook is for everywhere else: a hover card, a modal, an expanding row.

```jsx
import { useState } from 'react'
import { useWholeRecord } from '@uniweb/kit'

function ArticleCard({ article }) {
  const [open, setOpen] = useState(false)
  const { data: full, loading, error } = useWholeRecord(open ? article : null, {
    query: 'articles',
  })

  return (
    <div>
      <h3>{article.title}</h3>
      <button onClick={() => setOpen(true)}>Read more</button>
      {open && (loading ? <Spinner /> : <ArticleBody article={full} />)}
    </div>
  )
}
```

| Argument | Description |
| --- | --- |
| `record` | A record the section received. `null` or `undefined` skips the fetch |
| `options.query` | The name of the query the record came from. Required with a record |
| `options.param` | The record field its address is built from. Defaults to the one the query's parametric page matches, else `slug` |

Returns `{ data, error, loading }`. `data` is the record whole, as stored — each section under its own name, the brief's included (`full.brief.title`, `full.body.content`). When the query has no separate source for one record — an external query with no `record:` — `data` is the record you passed in, so a component can call the hook unconditionally. It shares the cache with section fetches.

---

### useQueryable

Read the [`queryable:`](./queries.md#queryable--fields-a-foundation-can-filter-on) declaration of a query — the fields a site says a reader may filter on — to render filter controls.

```jsx
import { useQueryable } from '@uniweb/kit'

function MemberFilters() {
  const queryable = useQueryable('members')
  if (!queryable) return null

  return Object.entries(queryable).map(([field, def]) => (
    <FilterControl key={field} field={field} def={def} />
  ))
}
```

Returns the declaration as the site wrote it, or `null` when the query declares none. Kit ships no filter UI: the foundation renders controls for the types it supports and composes a [where-object](./queries.md#where--which-records-match) from the reader's choices.

---

### useFetched / useCacheEntry

Low-level hooks for a component that fetches an address of its own — a component that knows its endpoint, as described in [Component Data Patterns](../development/component-data-patterns.md). They go through the site's fetcher and share its cache, so a request another component already made is answered from memory.

```jsx
import { useFetched } from '@uniweb/kit'

function ProductCard({ params }) {
  const { data, error, loading } = useFetched({
    url: params.productUrl,
    transform: 'data.product',
  })
  if (loading) return <Skeleton />
  if (error) return <p>{error}</p>
  return <Product {...data} />
}
```

`useFetched(request)` dispatches the request and returns `{ data, error, loading }`, aborting when the component unmounts. `useCacheEntry(request)` observes the same cache entry without fetching: `null` until someone fills it, then `{ data, meta }`. Pass `null` to either to skip. A request takes `url`, and optionally `transform`, `method` and `body`.

---

### useFormSubmit

Submit a form, with the `idle → submitting → success | error` lifecycle. The destination comes from the site's host or from its own `services.submit` address, so a component never names an endpoint.

```jsx
import { useFormSubmit } from '@uniweb/kit'

const { submit, status, error, canSubmit } = useFormSubmit({
  block,
  context: { formId: 'contact' },
  summary: (v) => ({ title: v.name, subtitle: v.email }),
})
```

| Returns |  |
| --- | --- |
| `status` | `'idle'` · `'submitting'` · `'success'` · `'error'` |
| `error` | the `Error` from a failed submission, or `null` |
| `response` | the endpoint's reply on success, or `null` |
| `canSubmit` | whether this site declares a destination |
| `canUploadFiles` | whether attachments can be delivered — check before rendering a file input |
| `submit(values, overrides?)` | send; rejects on failure |
| `reset()` | back to `idle` |

---

### useFormValues

Hold the state of a form an **author** designed — a `yaml:form` block at `content.data.form`. A form-rendering component is the inverse of every other one: it doesn't declare the fields, it receives them and draws whatever it's given. This owns the part that is identical in every such component, so a foundation writes only the controls.

```jsx
import { useFormValues, useFormSubmit, valueAt } from '@uniweb/kit'

const { controls, values, setValue, missing, formData, files } =
  useFormValues(content.data.form)
const { submit, canSubmit } = useFormSubmit({ block })

{controls.map((c) => (
  <MyControl key={c.path} control={c}          // branch on c.type — your design
             value={valueAt(values, c.path)}
             onChange={(v) => setValue(c.path, v)} />
))}
<button disabled={!canSubmit || missing.length > 0}
        onClick={() => submit(formData, { files })}>Send</button>
```

| Returns |  |
| --- | --- |
| `controls` | every control, depth-first, each with the dotted `path` its value lives at |
| `values` | what your inputs bind to — holds `File` objects so a file input can show its selection |
| `setValue(path, v)` | set by dotted path (`'email'`, `'address.street'`) |
| `reset()` | back to the declared defaults |
| `missing` | paths of `required` controls still empty — computed, **not** enforced |
| `formData` | what you submit: JSON-safe, file controls omitted |
| `files` | `[{ file, field }]` for `submitForm`, each tagged with its control |

**`valueAt(values, path)` reads a value back out** — the companion of `setValue`, exported because every control-rendering loop needs it. ⚠️ **`values` is nested, so `values[c.path]` does not work**: a control at `address.street` lives at `values.address.street`, and indexing by the dotted path returns `undefined` for every nested control — inputs render blank and their answers submit empty, with nothing failing. Top-level controls happen to work either way, which is what makes the mistake survive a first test.

**Submit `formData`, not `values`.** They differ for one reason that would otherwise be a silent, success-shaped failure: submissions are sent as JSON, and a `File` inside them serializes to `{}` — an attachment that looks sent and arrives empty. File controls are therefore omitted from `formData` and ride in `files`, each tagged with the control it came from so an endpoint can tell two file inputs apart.

**`missing` is computed, not enforced** — the same line `canSubmit` draws. Whether an incomplete form disables the button, shows a message, or submits anyway is your design. Empty means `undefined`, `null`, `''` or `[]`; a `false` boolean is a *value*, so a required checkbox left unticked isn't "missing" — "must be ticked" is a stronger rule that belongs to the component that knows it's a consent box.

A container control (`type: group` with `children`) nests: its answers appear under its own key in `values` and `formData`, and paths are dotted throughout.

| Option |  |
| --- | --- |
| `block` | supplies section type / id and page id / label as submission context |
| `context` | your own identifiers (`formId`, …); wins over what `block` supplies |
| `summary` | `{ title, subtitle, tag? }` or `(values) => that` — a readable digest |
| `files` (per call) | `File`s or `{ file, field }` pairs — sent as bytes after the answers |

**The framework never invents an endpoint, but a host may supply one** — a site on Uniweb Cloud asks for form handling with `submit: true` under `services:` and names no address. `canSubmit` is false only when neither the host nor an address of the site's own provides a destination; render no form rather than posting a visitor's answers nowhere. Companion utilities `submitForm()` and `resolveSubmitTarget(website)` do the same thing without React.

**Attachments go separately from the answers**, so bytes never ride inside the JSON: the manifest is derived from the files you pass, then each file's bytes are sent, then a finalize. Pass them per call and read `canUploadFiles` at render time, not on submit — a file input someone has already used is too late to withdraw.

```jsx
await submit(values, { files: [{ file, field: 'photos' }] })
// → { submissionId, filesUploaded: 1, filesRecorded: 1, totalSizeBytes: 4096 }
```

`filesRecorded` is the endpoint's own count of what reached storage. When it is lower than what was sent, the call throws instead of resolving — every upload can return 2xx and one can still be absent.

A failure after the first request names what landed — *"submission sub-3 was recorded, but uploading 'big.pdf' failed"* — so a form can tell a visitor their message arrived and their attachment did not, rather than reporting a flat failure or a false success.

---

### Service predicates

One per service, no arguments. Call one **before rendering UI for that service**; `false` means draw nothing — not a disabled control, not an explanation.

```jsx
import { isSearchEnabled, isSubmitEnabled, isBackendEnabled } from '@uniweb/kit'

if (!isSearchEnabled()) return null
```

| predicate | true when |
| --- | --- |
| `isSearchEnabled()` | any provider answers search — a server, **or the prebuilt index** a static site ships |
| `isSubmitEnabled()` | form submissions have somewhere to go |
| `isBackendEnabled()` | the site has its own backend, the `backend` service — accounts, per-visitor data. Also exported by `@uniweb/api` |
| `isAssistantEnabled()` | an assistant surface answers |
| `isTrackingEnabled()` | analytics events have somewhere to go |

Each is a synchronous read of the site's own configuration — nothing to await, nothing to retry. They resolve the active website themselves and delegate to [`website.isServiceEnabled(name)`](#website), which is also how you ask about a service kit ships no predicate for.

⛔ **`isSearchEnabled()` is not "is there a search service."** Search is the one service with a local provider, so the predicate is true on a static site that carries an index and no server at all. Gating a search box on anything narrower hides it wherever search works without a host.

The hooks that draw a feature return the same answer as a field — `useSearch().isEnabled`, `useFormSubmit().canSubmit` — so a component already using one needs nothing extra. `uniweb doctor` warns when a component calls one of those hooks and never reads its gate, because a control drawn for a service the site does not have is permanently dead. See [Site Services](./site-services.md).

---

### resolveService

Where a named service lives for this site — search, form submission, an assistant, or anything a foundation defines.

```js
import { resolveService } from '@uniweb/kit'

const { url, source } = resolveService(website, 'assistant')
if (!url) return null // this site has no assistant — render nothing, or degrade
```

To decide only *whether* to draw, a [service predicate](#service-predicates) is shorter — `isAssistantEnabled()`. Reach for `resolveService` when you need the address itself.

Resolution is the same for every service: what the host offers (`config.services.assistant` in the served payload), then the site's own entry (`services.assistant` in `site.yml`) for anything the host does not provide, then neither. `source` says which tier answered — the thing to check when a value you set appears not to be taking effect:

| `url` | `source` | means |
| --- | --- | --- |
| an address | `'host'` | the host offers it |
| an address | `'site'` | the site declared it, and no host offers it |
| `null` | `'site'` | the site switched it off — `false` or `{ enabled: false }` — and no host offers it |
| `null` | `'host'` | a host is answering and does not offer it |
| `null` | `null` | nobody declared anything — no host is speaking |

The last two both mean "no address", and a caller that only reads `url` treats them alike — correct for almost every service. They differ for a caller with a fallback of its own, and search is the one that has one: a host that publishes a services block is stating what it offers, so a name *absent* from that block is declined exactly as a name present with no address. Without that rule, "this host offers analytics and not search" and "there is no host at all" would be the same value, and a site would fall back to an index its host never built.

**`url` is the whole answer, and there is deliberately no explanatory string.** Absence is a rendering decision, not a message: no assistant endpoint → draw no Ask-AI affordance; no submit endpoint → draw no form, or degrade to something that still serves the visitor. A visitor has no stake in which services the operator provisioned, and any wording for that state would be ours to invent, in one language, bypassing the site's own localization. Text a visitor should read is *site content* — authored and localized — never a string a service layer supplies.

A declaration is a string or `{ endpoint }`. Absolute URLs (`https://…`, `//host/…`) pass through — a service on another origin is not the site's to relocate — and a root-relative (`/forms`) or bare (`_search`) endpoint is joined to the site's base path, once.

**The name is open.** The framework ships clients for the services it implements and resolution for anything, so a foundation can define its own and a host can fill it with no framework change. `resolveServiceUrl(endpoint, basePath)` is the join on its own.

See [Receiving Form Submissions](../development/receiving-form-submissions.md).

---

## Data Classes (from @uniweb/core)

You typically access these through `useWebsite()` or the `block` prop. Direct import from `@uniweb/core` is rarely needed.

### Website

The website instance provides access to site-wide data and navigation.

```jsx
function Header({ block }) {
  const website = block.website

  // Site identity
  console.log(website.name)         // 'My Site'
  console.log(website.description)  // 'Site description'

  // Pages
  const pages = website.getPageHierarchy({ for: 'header' })
  const allPages = website.pages

  // Locales (for language switcher UI)
  // Note: Content arrives already localized - no translation lookup needed
  if (website.hasMultipleLocales()) {
    const locales = website.getLocales()    // All locale objects
    const active = website.getActiveLocale() // Current locale code
    const url = website.getLocaleUrl('es')   // URL for switching
  }

  // Search
  if (website.isServiceEnabled('search')) {
    // Show search UI
  }
}
```

#### Methods

| Method | Returns | Description |
| --- | --- | --- |
| `getPageHierarchy(opts)` | array | Get pages for navigation |
| `getBranchHierarchy(opts)` | array | Get pages for one branch of the site |
| `getLocales()` | array | Get all locale objects |
| `getActiveLocale()` | string | Get current locale code |
| `getLocaleUrl(code)` | string | Get URL for a locale |
| `hasMultipleLocales()` | boolean | Check if multilingual |
| `isSearchEnabled()` | boolean | Can this site be searched — by a server or the prebuilt index. In a component, prefer [`isSearchEnabled()`](#service-predicates) from kit |
| `isServiceEnabled(name)` | boolean | Is there a provider for service `name`? The one implementation behind kit's [service predicates](#service-predicates); call it directly for a service kit ships no predicate for |
| `isVersionedRoute(route)` | boolean | Check if route is versioned |
| `getVersionScope(route)` | string | Get version scope for route |
| `getVersionUrl(version, route)` | string | Compute versioned URL |

#### getPageHierarchy Options

```js
website.getPageHierarchy({
  for: 'header',        // layout area name — checked against each page's hideIn
  nested: true,         // Include children (default: true)
  includeHidden: false  // Include hidden pages (default: false)
})
```

`for:` names the **layout area** the menu is being built for, and it is tested against each page's `hideIn:` list. Pass the area you are actually filling — `'header'` for the site menu, `'left'` for a sidebar — so an author can keep a page in one and out of the other. Any area name works, including ones a foundation invents.

Returns:

```js
[
  {
    id: 'about',
    route: '/about',
    title: 'About Us',
    label: 'About',
    description: 'Learn about us',
    order: 2,
    hasContent: true,
    children: [...]
  }
]
```

#### getBranchHierarchy Options

A sidebar shows one branch of a site, not all of it. Give it the active route and it answers that branch's pages.

```js
website.getBranchHierarchy({
  route: location.pathname,  // the active route
  for: 'left',               // layout area, as above
  includeHidden: false
})
```

Under `/docs/reference/cli` it returns the documentation tree; under `/blog/a-post` it returns the blog's. When the route matches no branch — the site root, usually — it returns the whole hierarchy. Pages come back in the order the build settled on, so there is nothing to sort.

Pair it with [`useHeadings`](#useheadings) for the two halves of a documentation shell.

### Page

Access page metadata and layout configuration.

```jsx
function Layout({ block }) {
  const page = block.page

  // Identity
  console.log(page.route)       // '/about'
  console.log(page.title)       // 'About Us'
  console.log(page.description) // 'Learn about our company'

  // Layout flags
  if (page.hasHeader()) { /* render header */ }
  if (page.hasFooter()) { /* render footer */ }

  // Versioning
  if (page.isVersioned()) {
    const version = page.getVersion()
    const versions = page.getVersions()
  }
}
```

#### Properties

| Property | Type | Description |
| --- | --- | --- |
| `route` | string | Page route path |
| `title` | string | Page title |
| `description` | string | Page description |
| `label` | string | Short nav label |
| `id` | string | Stable page ID |
| `layout` | object | Layout flags |
| `sections` | array | Page sections (blocks) |
| `website` | Website | Parent website |

#### Methods

| Method | Returns | Description |
| --- | --- | --- |
| `hasHeader()` | boolean | Should render header? |
| `hasFooter()` | boolean | Should render footer? |
| `getAreaBlocks(name)` | Block[] | Get blocks for a named layout area |
| `getLayoutAreas()` | object | Get all layout areas as `{ name: Block[] }` |
| `getLayoutParams()` | object | Get layout params from page.yml |
| `isVersioned()` | boolean | Is page in versioned scope? |
| `getVersion()` | object | Current version info |
| `getVersions()` | array | All versions in scope |
| `getVersionUrl(id)` | string | URL for version switch |

### Block

The block represents a rendered section.

```jsx
import { ChildBlocks } from '@uniweb/kit'

function Hero({ content, params, block }) {
  // Navigation to related objects
  const page = block.page
  const website = block.website

  // Block identity
  console.log(block.id)   // 'hero'
  console.log(block.type) // 'Hero'

  // Child blocks (for composition)
  if (block.hasChildBlocks()) {
    return <ChildBlocks from={block} />
  }
}
```

#### Properties

| Property | Type | Description |
| --- | --- | --- |
| `id` | string | Section ID |
| `type` | string | Component name |
| `page` | Page | Parent page |
| `website` | Website | Parent website |
| `childBlocks` | array | Nested blocks (file-based children) |
| `insets` | array | Inline `@Component` references (separate from childBlocks) |
| `data` | object | The section's data — the same object as `content.data` |
| `dataLoading` | boolean | A fetch the section waits on is in progress |
| `dataError` | object \| null | Failed fetches' messages, keyed like `content.data` |
| `hasBackground` | boolean | Engine renders a background behind this section |
| `themeName` | string | Color context (`light`, `medium`, `dark`) |
| `state` | any | Persistent component state |

#### Methods

| Method | Returns | Description |
| --- | --- | --- |
| `hasChildBlocks()` | boolean | Has nested sections? |
| `getInset(refId)` | Block\|null | Find an inset by its refId |

---

## Component Props

Every foundation component receives these props:

```jsx
function MyComponent({ content, params, block }) {
  // content - Parsed markdown content
  const { title, paragraphs, links, images, items, data } = content

  // params - Frontmatter parameters (with defaults from meta.js). The section's own
  // settings — theme, background, grid, vars — are not among them: read them from `block`
  const { variant, layout } = params

  // block - Block instance for navigation
  const { page, website } = block

  // data - the keys the component declares in meta.js `data:` — fetched
  // records, or a tagged data block's value; null when nothing fills a key
  const { email, message } = data.contact || {}
}
```

### Content Shape

The runtime guarantees this structure (empty values if not in content):

```js
content = {
  // Headings
  title: '',
  pretitle: '',
  subtitle: '',
  headings: [],

  // Body content
  paragraphs: [],
  links: [],
  lists: [],
  quotes: [],

  // Media
  images: [],
  icons: [],
  videos: [],
  insets: [],        // Inline @Component references

  // Structure
  items: [],         // Child content groups
  headings: [],      // Overflow headings

  // Data
  data: {},          // The declared keys: fetched records, tagged blocks

  // Document order
  sequence: []       // All elements in order
}
```

### Params with Defaults

Param defaults from `meta.js` are automatically applied:

```js
// meta.js
export default {
  params: {
    variant: { type: 'select', options: ['plain', 'cards'], default: 'plain' },
    columns: { type: 'number', default: 3 }
  }
}

// Component receives merged params
function Grid({ params }) {
  const { variant, columns } = params
  // variant = 'plain' if not specified in frontmatter
  // columns = 3 if not specified
}
```

### DataPlaceholder

A ready-made loading placeholder for sections waiting on runtime data. Renders animated pulse bars.

```jsx
import { DataPlaceholder } from '@uniweb/kit'

function EventGrid({ content, block }) {
  if (block.dataLoading) {
    return <DataPlaceholder lines={5} />
  }

  return <div>{/* render events */}</div>
}
```

#### Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `lines` | number | `3` | Number of placeholder bars to render |
| `className` | string | `''` | Additional CSS classes |

Uses `animate-pulse` and the `--border` CSS variable for styling. Includes `role="status"` and `aria-label="Loading"` for accessibility.

### Visual

Renders one visual: an item of the section's [`media` slot](./component-metadata.md#media) by its kind, or the first of the candidates you pass. From `@uniweb/kit`.

```jsx
import { Visual } from '@uniweb/kit'

// meta.js: content: { title: 'Headline', media: 'Photo, video or diagram [1]' }
function SplitContent({ content, block }) {
  return (
    <div className="flex gap-12">
      <div className="flex-1">
        <h2 className="text-heading">{content.title}</h2>
      </div>
      <Visual media={content.media} block={block} className="flex-1 rounded-lg" />
    </div>
  )
}
```

Handed `content.media`, it renders the first item — the first image, video or embedded component the author placed, whatever its kind. Handed one item, it renders that one, which is how a gallery renders every item in the author's order:

```jsx
{content.media.map((item, i) => <Visual key={i} media={item} block={block} />)}
```

#### Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `media` | object or array | — | A `content.media` item — `{ kind: 'image' \| 'video' \| 'inset', … }` — or the list, for its first item |
| `block` | Block | — | The section's block. An embedded component (`kind: 'inset'`) resolves through it |
| `inset` | Block | — | Inset Block instance (from `block.insets` or `block.getInset()`) |
| `video` | object | — | Video object with `src` property |
| `image` | object | — | Image object with `src` and `alt` properties |
| `className` | string | — | CSS classes for the visual container |
| `fallback` | ReactNode | `null` | Fallback when no visual is found |

**Without `media`:** the first of the candidates you pass, in the order inset > video > image — whatever order the author placed them in.

A slot narrowed to one type (`media: { types: ['image'] }`, or `image:`) can render with `<Image>` directly. A slot that takes embedded components must render them — `<Visual>` does; kit's `<Media>` is a video player.

### ChildGrid

Lays a section's child sections out in the columns the section chose with its `grid` key — `3` (equal columns) or `'40/60'` (relative widths) — one of the layouts the component offers in `meta.js` `children.grid` ([Grid layouts](./component-metadata.md#grid-layouts)). From `@uniweb/kit`.

```jsx
import { ChildGrid } from '@uniweb/kit'

function Grid({ block, params }) {
  return <ChildGrid from={block} fallback={3} headerRow={params.headerRow} className="gap-6" />
}
```

#### Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `from` | Block | — | The section whose `childBlocks` are laid out |
| `fallback` | number \| string | `null` | The layout when the section chose none, or chose something that is not a layout — list it first in `children.grid` |
| `headerRow` | boolean | `false` | The first child spans every column |
| `className` | string | — | Classes on the grid; replaces the default `gap-8` |
| `cellClassName` | string | — | Classes on each child's cell |

One column on narrow screens; from `md`, a layout of up to two columns as chosen and a wider one as two equal columns; from `lg`, the chosen layout. With no layout at all, the children stack. The columns are computed by `gridTemplate` from `@uniweb/schemas/grid`, so a visual editor drawing with the same function draws what renders.

Each child renders as a section, in a `div`: its own `theme:` and `background:` apply.

### SectionBackground

The background the author set on a section, drawn by the runtime's own renderer — for a component that places it itself. The runtime draws a section's background behind every section; a component that paints its own declares `background: 'self'` in `meta.js`, and the runtime then draws none. With `SectionBackground` it can still draw the author's image, video, gradient or color, with its overlay, wherever it wants. From `@uniweb/kit`.

```jsx
import { SectionBackground } from '@uniweb/kit'

function Hero({ content, block }) {
  return (
    <section className="relative">
      <SectionBackground block={block} />
      <div className="relative z-10">…</div>
    </section>
  )
}
```

It fills the nearest positioned ancestor, so place it inside an element with `position: relative`, before the content. It draws nothing for a section without a background.

| Prop | Type | Description |
| --- | --- | --- |
| `block` | Block | The section — its `block.background`, normalized by the runtime |
| `className` | string | Classes on the background layer |

### Render vs Prose

`<Prose>` is `<Render>` inside a typography container: one walk over the same content, plus the `prose` classes that style what it emits. Both read the parsed sequence (`content.sequence`) — or parse the ProseMirror document they are given, or the block's — and both render every kind of element a section holds, tables, callouts, insets, icons and math included. Tagged data blocks are left out of both: a component reads them from `content.data`.

|  | `<Render>` | `<Prose>` |
| --- | --- | --- |
| **Emits** | The elements alone — no wrapper, no classes | The same elements, in a `prose` container whose `size` you pick |
| **Use when** | You supply the container: your own type system, or a `prose` element of your own | You want kit's prose typography with nothing more to write |

```jsx
// A section type that lays out what the author wrote with its own type system
import { Render } from '@uniweb/kit'

export default function Section({ block }) {
  return (
    <div className="doc-body">
      <Render block={block} />
    </div>
  )
}
```

```jsx
// The section as written, with prose typography — and its data rendered your own way
import { Prose } from '@uniweb/kit'

export default function Lesson({ content, block }) {
  return (
    <>
      <Prose content={content} block={block} />
      {content.data.quiz && <Quiz data={content.data.quiz} />}
    </>
  )
}
```

The sequence is the section as written, headline included — so a component that draws `content.title` itself renders it twice if it also hands the whole sequence to either one.

#### Both need the prose stylesheet

`<Prose>` emits a `prose` container, and `<Render>` output is normally placed inside one. Your foundation's stylesheet supplies the rules with one line — there is nothing to install:

```css
/* your foundation's styles.css, after the Tailwind import */
@import "@uniweb/kit/prose-tokens.css";
```

Without it the markup is right and completely unstyled — lists lose their bullets and numbers, headings and spacing their typography. The foundation build warns when it renders `<Prose>` or `<Article>` with no prose styles in its CSS, and `uniweb doctor` flags it too.

The rules are written for what `<Render>` emits — a tight list drawn without paragraphs, a button that says it is one, an image's credit, a document's file card — and take every color from the section's theme tokens, so long-form text follows the site's `theme.yml` and the visitor's light or dark scheme with nothing more to do. A `prose` container may sit inside another: it re-points no color, so its text is `--body` as the outer one's is, and a bare `prose` draws at the base step whatever step surrounds it.

**Size and rhythm.** `size` — `sm`, `base`, `lg` (the default), `xl`, `2xl` — picks a step. The classes are `prose-sm` … `prose-2xl`, and they work behind a breakpoint (`md:prose-lg`). Each step is a ratio of the site's reading size, which a site sets in `theme.yml` with the rest of the scale — `prose-size`, `prose-leading`, `prose-measure`, `prose-flow` and `prose-h1` … `prose-h4` ([Long-form text](./site-theming.md#long-form-text)). Unset, each step keeps Tailwind Typography's tuning for its size — line heights, spacing, heading metrics, code blocks, tables and figures — and inline code is a chip at 0.875em of its text at every step. A code block takes the step's size and line height and keeps its own padding, corners and colors, in one box that stays put when its highlighting loads. `<Prose>` and `<Article>` fill their box (`max-w-none`), so `prose-measure` reaches a `prose` element you write without it; set their width on the box around them. To declare the variables in your foundation, described so an editor can offer them, spread kit's declarations into `main.js`:

```js
import { proseVars } from '@uniweb/kit/prose-vars'

export const vars = {
  ...proseVars,
  // your foundation's own
}
```

**Overriding.** Every rule sits beneath Tailwind's utilities, so a utility class wins — on the container (`<Prose className="text-lg">`), or through an element variant: `prose-headings:tracking-tight`, `prose-a:no-underline`, and one for each element (`prose-p:`, `prose-code:`, `prose-pre:`, `prose-table:` …). `prose-a:` styles links and leaves a content button (`[data-role="button"]`) its own look. `not-prose` opts a subtree out. There are no palette modifiers and no `prose-invert`: the colors are the theme's, and `uniweb doctor` flags any it finds in a foundation's components.

Both stamp an `id` on each heading, from the same generator, so `useHeadings` and any anchor link agree with whichever one rendered.

#### Both lay out an author's placement

An author places an image, a video or a table with `{placement=…}` — `left`, `right`, `wide` or `full` ([Placement](./content-structure.md#placement--where-a-block-sits)). Both write it as `data-placement` on the block's box, and `prose-tokens.css` lays the four out: `left` and `right` beside the text at `--prose-side-width` (half the column), `wide` past the column by `--prose-wide-bleed` on each side, `full` across the nearest size container. Mark the element your sections sit in with Tailwind's `@container` class (`container-type: inline-size`) so `full` spans it rather than the viewport, or set `--prose-full-width`. A component built on them declares `sequence: { placement: true }` in its `meta.js`, so a visual editor offers its authors all four ([Sequence](./component-metadata.md#sequence--content-rendered-as-written)).

---

## Utilities

### cn

Merge Tailwind classes, resolving conflicts so a later class wins over an earlier one. Falsy values are dropped, which makes conditional classes read cleanly.

```jsx
import { cn } from '@uniweb/kit'

cn('px-4 py-2', isActive && 'bg-primary', className)
```

**Order matters, and one pair is surprising.** A font-size class overrides a preceding `leading-*`, because in Tailwind a size class like `text-lg` sets *both* font size and line height. So `cn()` treats a later `text-*` as replacing an earlier `leading-*` — and silently drops it:

```jsx
cn('leading-[1.1] text-[clamp(2rem,5vw,4rem)]')  // → 'text-[clamp(2rem,5vw,4rem)]'  ⚠️ leading gone
cn('leading-tight text-4xl')                     // → 'text-4xl'                      ⚠️ leading gone
cn('text-4xl leading-tight')                     // → 'text-4xl leading-tight'        ✅
```

This bites hardest when the size comes from a lookup and the leading is in a shared base string, because the base is written first. Two ways out — put the size before the leading, or fold the leading into the size with Tailwind's slash syntax:

```jsx
const TITLE_SIZE = { hero: 'text-[clamp(2.8rem,6.5vw,4.5rem)]/[1.1]' }   // size and leading in one class
<H1 className={cn('font-bold tracking-tight', TITLE_SIZE[params.layout])} />
```

The same rule applies to any pair Tailwind fuses into one utility. When a class you passed doesn't show up in the DOM, check whether a later class in the same `cn()` owns that property too.

### getLocaleLabel

Get display name for a locale.

```jsx
import { getLocaleLabel, LOCALE_DISPLAY_NAMES } from '@uniweb/kit'

// From locale object with label
getLocaleLabel({ code: 'es', label: 'Spanish' })  // 'Spanish'

// From locale object without label
getLocaleLabel({ code: 'es' })  // 'Español' (from built-in names)

// From string
getLocaleLabel('es')  // 'Español'

// A code the built-in names don't list — its own name, where the platform knows it
getLocaleLabel('ca')  // 'Català'

// Unknown code
getLocaleLabel({ code: 'xx' })  // 'XX'

// Access built-in names directly
console.log(LOCALE_DISPLAY_NAMES.fr)  // 'Français'
```

### splitContent

Split parsed content at `---` dividers into separate regions. Each region has its own `sequence` slice; grouped fields (title, paragraphs, items) are preserved from the original. Use this when the component needs to route different content regions to different UI.

```jsx
import { splitContent } from '@uniweb/kit'

function Lesson({ content, block }) {
  const [lesson, challenge] = splitContent(content)
  return (
    <div>
      <Prose content={lesson} block={block} />
      <aside className="bg-card p-6 rounded-lg">
        <Prose content={challenge} block={block} />
      </aside>
    </div>
  )
}
```

Returns a single-element array (`[content]`) if no divider exists, so destructuring always works.

**Note:** This is different from Loom's `splitAtDividers`, which splits raw ProseMirror nodes *before* parsing for data-driven iteration. `splitContent` splits *after* parsing for UI layout purposes. See "Dividers — Content Boundaries" in the authoring guide.

### Icon Component

Renders icons from multiple sources: library icons, URLs, direct SVG, or built-in icons.

```jsx
import { Icon } from '@uniweb/kit'

// String ref (recommended for library icons)
<Icon icon="lu-house" />         // dash format
<Icon icon="lu:house" />         // colon format
<Icon icon="lucide:house" />     // full library name

// Explicit library + name
<Icon library="lucide" name="house" />

// From URL
<Icon url="/icons/custom.svg" />

// Direct SVG content
<Icon svg="<svg>...</svg>" />

// Built-in icons (no library needed)
<Icon name="check" />
<Icon name="close" />
```

#### Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `icon` | string/object | — | String ref (`"lu-house"`), URL, or `{ library, name }` object |
| `library` | string | — | Icon library shortcode (`lu`, `hi`, `fi`, etc.) |
| `name` | string | — | Icon name within the library |
| `url` | string | — | URL to fetch SVG from |
| `svg` | string | — | Direct SVG content |
| `size` | string | `'24'` | Icon size in pixels |
| `color` | string | — | Icon color (defaults to `currentColor`) |
| `preserveColors` | boolean | `false` | Keep original SVG colors instead of using `currentColor` |
| `className` | string | — | Additional CSS classes |

The string `icon` prop is the most concise way to use library icons. The same string formats work in markdown (`![](lu-house)`), in YAML data (`icon: lu:house`), and in JSX (`<Icon icon="lu-house" />`).

### parseIconRef

Parse an icon string reference into its library and name parts.

```jsx
import { parseIconRef } from '@uniweb/kit'

parseIconRef('lu-house')       // { library: 'lu', name: 'house' }
parseIconRef('lu:house')       // { library: 'lu', name: 'house' }
parseIconRef('lucide:house')   // { library: 'lucide', name: 'house' }
parseIconRef('not-an-icon')    // null (prefix not a known library)
```

Useful when you receive icon strings from structured data (`content.data`) and need to pass them to components that expect separate library/name props.

### Overlay Component

Render a modal, command palette, drawer or toast above the page, from anywhere in the tree.

```jsx
import { Overlay } from '@uniweb/kit'

function SearchDialog({ isOpen, onClose }) {
  if (!isOpen) return null

  return (
    <Overlay onClose={onClose} className="items-center">
      <div role="dialog" aria-modal="true" aria-label="Search" className="w-full max-w-2xl rounded-2xl bg-card">
        …
      </div>
    </Overlay>
  )
}
```

#### Why not a `fixed inset-0 z-…` div

Because that does not work, and the reason is invisible.

The runtime gives each layout area its own `view-transition-name` so the header, rails and body animate independently, and that makes every area a stacking context. A `fixed` dialog inside an area is still placed against the viewport — what the area confines is where it paints in the stack. Areas sit above the body, so a dialog rendered from a section paints **under** the header and footer, whatever z-index it carries; one rendered from your Header paints under a footer or sidebar on the header's layer that comes after it. Raising the number looks like it should help and never does, because the elements are not competing in the same stacking context.

`Overlay` renders into `document.body`, outside every area wrapper, where the z-index means what you expect.

#### It contains focus, so `aria-modal` is true

A modal overlay moves focus in on open, cycles Tab within it, pulls focus back if it escapes, marks the rest of the page `inert`, and restores focus to whatever opened it when it closes. **On by default.**

This matters more than it sounds. `aria-modal="true"` tells assistive technology that everything outside the dialog is unreachable. Without containment that is false in the most literal way: a keyboard user Tabs straight out into the page behind and lands on controls a screen-reader user has been told do not exist. Nothing errors, and the people it fails are the least likely to be in the room when it ships.

You still write `role="dialog" aria-modal="true"` on your own box — it is your element — but the promise is now backed.

#### Modal and non-modal

`modal` (default `true`) is the master switch, because the cluster it governs only makes sense together: scrim, focus trap, scroll lock, inert background. A dialog, palette, drawer or lightbox wants all of it.

`modal={false}` is the other real case — a toast or notification. It escapes the stacking context and does nothing else: no scrim, no trap, no scroll lock, and pointer events pass through so the page stays usable. Content opts back in with `pointer-events-auto`.

```jsx
<Overlay modal={false} className="items-end justify-end p-6">
  <div className="pointer-events-auto rounded bg-card p-4">Saved</div>
</Overlay>
```

#### The scrim is a default, not a constraint

A modal overlay dims the page for you. `className` is applied last and Tailwind conflicts resolve in your favour, so every adjustment is a plain override:

| You write | Result |
| --- | --- |
| *nothing* | the default dimmed scrim |
| `bg-primary/10` | your colour — a theme token works as readily as a fixed one |
| `bg-transparent` | no scrim |
| `backdrop-blur-sm` | kept **alongside** the default |
| `items-center` | content centred instead of top-aligned |

The layer is a flex container, so `items-*` and `justify-*` place your content. The box itself — its width, radius, background, animation — is your foundation's design, the same way kit ships no layout.

#### Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | node | — | The overlay content |
| `onClose` | function | — | Called on Escape and on a scrim click. Omit for an overlay that dismisses some other way |
| `modal` | boolean | `true` | Blocks the page: scrim, focus trap, scroll lock, inert background |
| `className` | string | — | Classes for the scrim / positioning layer, applied last |
| `zIndex` | number \| string | `100` | Stacking order of the layer |
| `initialFocus` | ref \| string \| `false` | first focusable | What to focus on open; `false` leaves focus alone |
| `returnFocus` | boolean | `true` | Restore focus to whatever opened it |
| `trapFocus` | boolean | = `modal` | Turning this off on a modal overlay makes `aria-modal` a false claim — prefer `modal={false}` |
| `lockScroll` | boolean | = `modal` | Prevent the page behind from scrolling |
| `closeOnEscape` | boolean | `true` |  |
| `closeOnScrimClick` | boolean | `true` |  |

Anything else is spread onto the layer.

#### Details worth knowing

- **Escape is handled on the capture phase**, so it still closes while an input inside the dialog has focus.
- **Only a click on the scrim itself closes.** A click that bubbled out of your content does not, so a text selection ending outside the box is safe.
- **Nesting works.** A confirm dialog over a settings dialog: only the topmost closes on Escape and traps focus, and the inner one closing does not unlock scrolling or restore the page underneath the outer one.
- **Nothing is emitted when server-rendered**, so a prerendered page carries no scrim.

#### When not to use it

An anchored popover, dropdown menu or tooltip wants to be positioned relative to its trigger and to leave the page interactive. A full-screen layer with a scrim and a scroll lock is the wrong shape for those — build them in your foundation, positioned normally.

### Link Component

Client-side navigation with `page:` protocol support.

```jsx
import { Link } from '@uniweb/kit'

function Navigation() {
  return (
    <nav>
      <Link to="page:home">Home</Link>
      <Link to="page:about#team">Our Team</Link>
      <Link to="/external" target="_blank">External</Link>
    </nav>
  )
}
```

---

## Access Patterns

### From Hooks (Recommended)

```jsx
import { useWebsite, useRouting } from '@uniweb/kit'

function MyComponent() {
  const { website } = useWebsite()
  const { route } = useRouting()

  // Access locale via website
  const locale = website.getActiveLocale()

  // Use hooks throughout the component
}
```

### From Block Props

```jsx
function MyComponent({ block }) {
  const website = block.website
  const page = block.page

  // Access via block reference
}
```

### When to Use Which

| Scenario | Use |
| --- | --- |
| General component logic | Hooks |
| Accessing page/website from nested components | Hooks |
| Simple property access in main component | Block props |
| Non-React code (utilities) | Block props |

---

## See Also

- [Component Metadata](./component-metadata.md) — Defining component interfaces
- [Content Structure](./content-structure.md) — Content shape and guarantees
- [Site Theming](./site-theming.md) — Theme API and hooks
- [Internationalization](../development/internationalization.md) — Locale switching and build-time translation
- [Versioning](./versioning.md) — Version hooks and API