npm package: @pretext-studio/core
Description: React hooks for pixel-perfect text layout prediction — no DOM reflow, no layout shifts
Latest version: 0.2.0


# @pretext-studio/core

React hooks for pixel-perfect text layout prediction — no DOM reflow, no layout shifts.

Built on top of [@chenglou/pretext](https://github.com/chenglou/pretext), a pure-JS text layout engine that computes line breaks and heights from font metrics alone — before anything renders.

```
npm install @pretext-studio/core @chenglou/pretext
```

---

## Why

The browser can't tell you how tall a block of text will be until it lays it out. This forces one of two bad patterns:

- **Guess and adjust** — render first, measure with `getBoundingClientRect`, update. Causes layout shift.
- **Arbitrary caps** — `max-height: 9999px` for accordion animation. Causes broken easing.

`@pretext-studio/core` gives you exact pixel heights and line counts *before* paint, with zero DOM reads.

---

## Installation

```bash
npm install @pretext-studio/core @chenglou/pretext
```

Both packages are required. `@chenglou/pretext` is a peer dependency — the layout engine that does the measurement.

---

## Hooks

### `useTextLayout`

Predicts the height and line count of a block of text at a given width.

```tsx
import { useTextLayout } from '@pretext-studio/core'

function Article({ text }: { text: string }) {
  const { height, lineCount, isReady } = useTextLayout({
    text,
    font: '16px Inter, sans-serif',
    width: 640,
    lineHeight: 24,
  })

  return (
    <div style={{ minHeight: isReady ? height : undefined }}>
      {text}
    </div>
  )
}
```

**Options**

| Prop | Type | Description |
|------|------|-------------|
| `text` | `string` | The text to measure |
| `font` | `string` | CSS font string — must match what the browser uses |
| `width` | `number` | Container width in px |
| `lineHeight` | `number` | Line height in px |

**Returns** `{ height, lineCount, isReady }`

---

### `useBubbleMetrics`

Finds the tightest possible bubble width that keeps the same line count. Eliminates the dead space CSS `fit-content` leaves on short last lines.

```tsx
import { useBubbleMetrics } from '@pretext-studio/core'

function ChatBubble({ text, side }: { text: string; side: 'sent' | 'recv' }) {
  const { tightWidth, height, isReady } = useBubbleMetrics({
    text,
    font: '15px Helvetica Neue, sans-serif',
    lineHeight: 20,
    maxWidth: 280,
    paddingH: 12,
    paddingV: 8,
  })

  return (
    <div style={{
      width: isReady ? tightWidth : undefined,
      height: isReady ? height : undefined,
      alignSelf: side === 'sent' ? 'flex-end' : 'flex-start',
    }}>
      {text}
    </div>
  )
}
```

**Options**

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `text` | `string` | — | Message text |
| `font` | `string` | — | CSS font string |
| `lineHeight` | `number` | — | Line height in px |
| `maxWidth` | `number` | — | Max bubble width including padding |
| `paddingH` | `number` | `12` | Horizontal padding each side |
| `paddingV` | `number` | `8` | Vertical padding each side |

**Returns** `{ tightWidth, height, lineCount, wastedPixels, isReady }`

---

### `useStableList`

Pre-computes heights for a list of items so the list can be rendered without layout thrash. Useful for virtualized lists, masonry grids, and any list where you need heights before render.

```tsx
import { useStableList } from '@pretext-studio/core'

function MessageList({ messages }: { messages: { id: string; text: string }[] }) {
  const { heights, totalHeight, isReady } = useStableList({
    items: messages,
    font: '15px Helvetica Neue, sans-serif',
    width: 360,
    lineHeight: 22,
    paddingV: 16,
  })

  return (
    <div style={{ height: totalHeight, position: 'relative' }}>
      {isReady && messages.map((msg, i) => {
        const top = messages.slice(0, i).reduce((sum, m) => sum + (heights.get(m.id) ?? 0), 0)
        return (
          <div key={msg.id} style={{ position: 'absolute', top, height: heights.get(msg.id) }}>
            {msg.text}
          </div>
        )
      })}
    </div>
  )
}
```

**Options**

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `items` | `{ id: string; text: string }[]` | — | List items |
| `font` | `string` | — | CSS font string |
| `width` | `number` | — | Container width in px |
| `lineHeight` | `number` | — | Line height in px |
| `paddingV` | `number` | `16` | Vertical padding per item |

**Returns** `{ heights: Map<string, number>, totalHeight, isReady }`

---

### `MeasuredText`

A drop-in component that pre-sizes itself to the predicted height and optionally reports prediction mismatches for debugging.

```tsx
import { MeasuredText } from '@pretext-studio/core'

<MeasuredText
  text="The quick brown fox..."
  font="16px Inter, sans-serif"
  width={640}
  lineHeight={24}
  debug={process.env.NODE_ENV === 'development'}
  onMismatch={({ predicted, actual, delta }) => {
    console.warn(`Layout mismatch: predicted ${predicted}px, got ${actual}px (Δ${delta}px)`)
  }}
/>
```

**Props**

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `text` | `string` | — | Text content |
| `font` | `string` | — | CSS font string |
| `width` | `number` | — | Container width in px |
| `lineHeight` | `number` | — | Line height in px |
| `debug` | `boolean` | `false` | Show predicted height/line overlay |
| `onMismatch` | `(m: LayoutMismatch) => void` | — | Called when actual height differs by >2px |
| `style` | `React.CSSProperties` | — | Extra styles on the wrapper div |
| `className` | `string` | — | Class name on the wrapper div |

---

### `clearAllCaches`

The SDK caches `prepare()` results internally (LRU, max 2000 entries). Call this if you need to free memory explicitly.

```ts
import { clearAllCaches } from '@pretext-studio/core'

clearAllCaches()
```

---

## The font string

The `font` prop must exactly match the CSS font the browser will use — same family, size, weight, and style. A mismatch between the font you pass and the font the browser renders will produce wrong measurements.

```ts
// Match your CSS exactly
font: '16px Inter, sans-serif'
font: '500 15px "Helvetica Neue", Helvetica, Arial, sans-serif'
font: 'italic 14px Georgia, serif'
```

If you're using a web font, make sure it's loaded before measurements run. Use the `isReady` flag to gate renders until the font is available.

---

## TypeScript

All hooks and the component are fully typed. Key types:

```ts
interface TextLayoutOptions {
  text: string
  font: string
  width: number
  lineHeight: number
}

interface TextLayoutResult {
  height: number
  lineCount: number
  isReady: boolean
}

interface BubbleMetrics {
  tightWidth: number
  height: number
  lineCount: number
  wastedPixels: number
}

interface LayoutMismatch {
  predicted: number
  actual: number
  delta: number
}
```

---

## License

MIT