Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ This file is the canonical agent guide for every tool (`CLAUDE.md` is just

1. **Zero runtime dependencies.** Nothing under `dependencies`, ever. `Intl.*` is
allowed (built into every runtime). React appears only as an optional peer for
`./react`. Mechanically gated: `packages/lingo/scripts/check-zero-deps.mjs`
`./react` and `./react-native`. Mechanically gated:
`packages/lingo/scripts/check-zero-deps.mjs`
(part of `bun run check` and CI).
2. **Size budgets are enforced** (`bun run size`, min+gzip via esbuild).
**`packages/lingo/scripts/size.mjs` is the single source of truth for budget
Expand Down Expand Up @@ -127,6 +128,7 @@ Inside `packages/lingo/` the same scripts run directly (`bun run test`, etc.).
- `dom/` — headless input controller (entry `./dom`)
- `element/` — custom-element wrapper over the DOM controller (entry `./element`)
- `react/` — React hook adapter (entry `./react`)
- `react-native/` — DOM-free React Native TextInput hook (entry `./react-native`)
- `ai/` — Standard Schema fields for LLM structured output (entry `./ai`)
- `mcp/` — Model Context Protocol tool helpers (entry `./mcp`)

Expand Down
4 changes: 2 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ its value. Fields never rewrite text while typing (D6).
## Infrastructure nouns

**Entry:** a published subpath — `.`, `./core`, `./date`, `./dom`, `./element`,
`./describe`, `./catalog`, `./schema`, `./ai`, `./mcp`, `./react`, `./complete`,
`./locales/*`. *Avoid: subpackage, plugin.*
`./describe`, `./catalog`, `./schema`, `./ai`, `./mcp`, `./react`,
`./react-native`, `./complete`, `./locales/*`. *Avoid: subpackage, plugin.*

**Catalog:** the read-only query surface (`./catalog`) over built-in
unit/kind/currency data — list kinds/units, resolve refs, related units,
Expand Down
4 changes: 3 additions & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dev": "NEXT_DIST_DIR=.next-dev next dev",
"build": "bun run typecheck && next build",
"start": "next start",
"test": "vitest run",
"typecheck": "tsgo --project tsconfig.json --noEmit",
"kill": "bun ./scripts/kill-port.mjs",
"clean": "bun ./scripts/clean-next.mjs",
Expand All @@ -31,12 +32,13 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"jsdom": "^29.1.1",
"react-doctor": "^0.6.2",
"react-grab": "^0.1.48",
"react-scan": "^0.5.7",
Expand Down
101 changes: 98 additions & 3 deletions apps/site/public/llms-small.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Agent fetch order (online): `https://lingo.pascal.app/llms.txt` (index) → `https://lingo.pascal.app/docs/<section>.md` (per-topic) or `https://lingo.pascal.app/llms-full.txt` (complete narrative). Offline: this file (`node_modules/@pascal-app/lingo/llms.txt`) is the compressed self-contained reference. Keep user measurements as strings in tool schemas; call lingo to convert, validate, surface spans, and handle ambiguity.

Entries: `@pascal-app/lingo` (core+units+fuzzy), `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element` (`<lingo-input>`), `@pascal-app/lingo/react`, `@pascal-app/lingo/ai` (LLM tool fields), `@pascal-app/lingo/mcp` (MCP tool helper), `@pascal-app/lingo/describe` (rich value/result descriptions), `@pascal-app/lingo/catalog` (query units/kinds/currencies + ISO country codes), `@pascal-app/lingo/complete` (ranked autocomplete completions), `@pascal-app/lingo/schema` (JSON Schema + OpenAPI + enum reference), `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` (tree-shakeable language packs), `@pascal-app/lingo/core`.
Entries: `@pascal-app/lingo` (core+units+fuzzy), `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element` (`<lingo-input>`), `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai` (LLM tool fields), `@pascal-app/lingo/mcp` (MCP tool helper), `@pascal-app/lingo/describe` (rich value/result descriptions), `@pascal-app/lingo/catalog` (query units/kinds/currencies + ISO country codes), `@pascal-app/lingo/complete` (ranked autocomplete completions), `@pascal-app/lingo/schema` (JSON Schema + OpenAPI + enum reference), `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` (tree-shakeable language packs), `@pascal-app/lingo/core`.

## Core (`@pascal-app/lingo`)

Expand Down Expand Up @@ -41,7 +41,7 @@ const items = completions("10 kg to 16", { kind: "mass", limit: 8 })
// [{ text, result, confidence, source }] — source: parse|alternative|unit-ambiguity|unit-prefix|implied-unit|range-implied|cross-kind|date
```

Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` for autocomplete dropdowns. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`.
Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })` for autocomplete dropdowns. The React hook exposes `completions`, `highlightedIndex`, `setHighlightedIndex()`, and `selectCompletion()` while keeping popup UI caller-owned. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`.

## Locales (`@pascal-app/lingo/locales/*`)

Expand Down Expand Up @@ -110,7 +110,102 @@ field.set("6ft")
field.commit() // hidden input submits canonical value
```

`field.value` · `.quantity` · `.result` · `.state` ('idle'|'incomplete'|'valid'|'invalid') · `.set()` · `.commit()` · `.update()` · `.destroy()`. Never rewrites while typing; canonicalizes on blur/Enter/submit. With `complete`/`onComplete`, the input gets the headless WAI-ARIA combobox attributes (`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`); pass `listboxId` to set `aria-controls` for your rendered listbox. React: `useLingoInput(opts)` from `@pascal-app/lingo/react`.
`field.value` · `.quantity` · `.result` · `.state` ('idle'|'incomplete'|'valid'|'invalid') · `.set()` · `.commit()` · `.update()` · `.destroy()`. Never rewrites while typing; canonicalizes on blur/Enter/submit. With `complete`/`onComplete`, the input gets the headless WAI-ARIA combobox attributes (`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`); pass `listboxId` to set `aria-controls` for your rendered listbox.

## React (`@pascal-app/lingo/react`)

```tsx
import { completions } from "@pascal-app/lingo/complete"
import { useLingoInput } from "@pascal-app/lingo/react"

function HeightField() {
const field = useLingoInput({
kind: "length",
unit: "m",
name: "height_m",
listboxId: "height-options",
complete: (text) => completions(text, { kind: "length" }),
})
return (
<>
<input ref={field.ref} data-state={field.state} />
<ul id="height-options" role="listbox">
{field.completions.map((item, index) => (
<li
key={item.text}
role="option"
aria-selected={index === field.highlightedIndex}
onMouseDown={(e) => {
e.preventDefault()
field.selectCompletion(index)
}}
>
{item.text}
</li>
))}
</ul>
</>
)
}
```

- Attach `field.ref` to `<input>` / `<textarea>` (DOM controller underneath).
- Live state: `state`, `value` (canonical number), `quantity`, `result`.
- Completions (when `complete` injected): `completions`, `highlightedIndex`, `setHighlightedIndex(i)`, `selectCompletion(i?)`.
- Imperative: `set(number|string)`, `commit()`. Controlled: `value: number | null` + `onValueChange`.
- Keyboard: handle Arrow/Enter in **capture phase** (`onKeyDownCapture`) so selection beats the controller's native Enter-to-commit.
- `'use client'` entry; React is an optional peer. `./complete` stays an explicit import.

## React Native (`@pascal-app/lingo/react-native`)

```tsx
import { FlatList, Text, TextInput, Pressable, View } from "react-native"
import { completions } from "@pascal-app/lingo/complete"
import { useLingoTextInput } from "@pascal-app/lingo/react-native"

function WeightField({ onWeight }: { onWeight: (kg: number | null) => void }) {
const field = useLingoTextInput({
kind: "mass",
unit: "kg",
min: 0,
max: "500 kg",
required: true,
complete: (text) => completions(text, { kind: "mass", limit: 6 }),
onValueChange: onWeight,
})

return (
<View>
<TextInput
{...field.inputProps}
placeholder="165 lb or 75 kg"
accessibilityLabel="Package weight"
/>
{field.errorMessage ? <Text>{field.errorMessage}</Text> : null}
{!field.errorMessage && field.hint ? <Text>{field.hint}</Text> : null}
<FlatList
data={[...field.completions]}
keyExtractor={(item) => item.text}
renderItem={({ item, index }) => (
<Pressable onPress={() => field.selectCompletion(index)}>
<Text>{item.text}</Text>
</Pressable>
)}
/>
</View>
)
}
```

DOM-free headless adapter — same parse/commit semantics as web, no browser APIs:

- `inputProps`: `{ value, onChangeText, onBlur, onSubmitEditing }` — spread onto RN `TextInput`.
- Display vs canonical: `text` (what the user sees), `value` (number in `unit`), `submitValue` (backend string), `quantity` / `result`.
- Lifecycle: typing uses `partialState` (`idle`/`incomplete`/`valid`/`invalid`); blur / submit / `commit()` canonicalizes (`display: 'canonical'|'echo'|'preserve'`).
- Validation: `required`, `min`/`max` (parseable strings or numbers), `errorMessage`, `hint`, `touched`, `dirty`.
- Completions (injected): `completions`, `highlightedIndex`, `setHighlightedIndex(i)`, `selectCompletion(i?)` — render with `FlatList`; engine is not bundled.
- Imperative: `set(number|string)`, `commit()`. Controlled: `value` + `onValueChange`.
- Imports React only (optional peer). Does **not** import `react-native`, DOM, or `./complete`.

## Element (`@pascal-app/lingo/element`)

Expand Down
18 changes: 17 additions & 1 deletion apps/site/src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
formSnippet,
integrationSnippets,
localeSnippet,
reactNativeSnippet,
schemaTabs,
strictnessSnippet,
typeSafetySnippet,
Expand Down Expand Up @@ -487,7 +488,7 @@ export default async function Home() {
<Section
explainer="Turn any native input into a natural-language field: parse on input, never rewrite mid-typing, and submit one hidden canonical value on commit."
id="forms"
kicker="DOM + React"
kicker="DOM + React + Native"
title="Inputs"
>
<CodeBlock code={formSnippet} filename="dom-field.ts" lang="ts" />
Expand Down Expand Up @@ -515,6 +516,21 @@ export default async function Home() {
</p>
<CodeBlock code={elementSnippet} filename="lingo-input.html" lang="html" />
</div>
<div className="flex min-w-0 flex-col gap-3">
<SubHeading id="forms-react-native">React Native</SubHeading>
<p className="text-muted-foreground text-sm">
<code className="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-foreground text-sm">
useLingoTextInput
</code>{' '}
gives React Native{' '}
<code className="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-foreground text-sm">
TextInput
</code>{' '}
the same partial-state parsing and commit canonicalization without importing the DOM
controller or React Native runtime.
</p>
<CodeBlock code={reactNativeSnippet} filename="weight-field.tsx" lang="tsx" />
</div>
</Section>

<Section
Expand Down
95 changes: 44 additions & 51 deletions apps/site/src/components/site/completions-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { fr } from '@pascal-app/lingo/locales/fr'
import { ja } from '@pascal-app/lingo/locales/ja'
import { pt } from '@pascal-app/lingo/locales/pt'
import { zh } from '@pascal-app/lingo/locales/zh'
import { useEffect, useId, useMemo, useState } from 'react'
import { useLingoInput } from '@pascal-app/lingo/react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'

import { DocsPane, DocsSplitPaneSection } from '@/components/site/docs-split-pane'
import { LocaleBadge } from '@/components/site/locale-select'
Expand All @@ -35,19 +36,6 @@ type UnitPreset = (typeof UNIT_PRESETS)[number]

const localeLingo = createLingo({ locales: [es, fr, pt, zh, ja, enGb] })

function useDebouncedValue<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = useState(value)

useEffect(() => {
const timer = window.setTimeout(() => {
setDebounced(value)
}, delayMs)
return () => window.clearTimeout(timer)
}, [delayMs, value])

return { debounced, pending: debounced !== value }
}

function completionOptions() {
return {
date: (text: string) => {
Expand Down Expand Up @@ -92,7 +80,7 @@ function RankedList({
pending = false,
}: {
activeIndex: number
items: Completion[]
items: readonly Completion[]
listId: string
onActiveIndexChange: (index: number) => void
onPick: (item: Completion) => void
Expand Down Expand Up @@ -139,17 +127,35 @@ function RankedList({

function RankedAutocompleteDemo() {
const listId = useId()
const [value, setValue] = useState('2 f')
const [activeIndex, setActiveIndex] = useState(0)
const { debounced: query, pending } = useDebouncedValue(value, DEBOUNCE_MS)
const items = useMemo(() => completions(query, completionOptions()), [query])
const inputRef = useRef<HTMLInputElement | null>(null)
const field = useLingoInput({
debounce: DEBOUNCE_MS,
listboxId: listId,
complete: (text: string) => completions(text, completionOptions()),
})
const items = field.completions as readonly Completion[]
const { highlightedIndex: activeIndex } = field
const activeId =
items.length > 0 ? `${listId}-item-${Math.min(activeIndex, items.length - 1)}` : undefined
activeIndex >= 0 && items.length > 0 ? `${listId}-item-${activeIndex}` : undefined

function updateValue(next: string) {
setValue(next)
setActiveIndex(0)
}
const setInputRef = useCallback(
(node: HTMLInputElement | null) => {
inputRef.current = node
field.ref(node)
},
[field.ref],
)

const updateValue = useCallback((next: string) => {
const input = inputRef.current
if (!input) {
return
}
input.value = next
input.dispatchEvent(new Event('input', { bubbles: true }))
}, [])

useEffect(() => updateValue('2 f'), [updateValue])

return (
<DocsSplitPaneSection aria-label="Ranked autocomplete example">
Expand All @@ -159,47 +165,35 @@ function RankedAutocompleteDemo() {
title="Autocomplete anything"
/>
<div className="flex flex-col gap-2">
<div className="flex items-end justify-between gap-3">
<Label htmlFor={`${listId}-input`}>Type a partial value</Label>
<span
aria-live="polite"
className={cn(
'font-mono text-[10px] text-muted-foreground uppercase transition-opacity duration-150',
pending ? 'opacity-100' : 'opacity-0',
)}
>
Updating…
</span>
</div>
<Label htmlFor={`${listId}-input`}>Type a partial value</Label>
<Input
aria-activedescendant={activeId}
aria-autocomplete="list"
aria-busy={pending}
aria-controls={listId}
aria-expanded={items.length > 0}
autoComplete="off"
className="rounded-[6px]"
id={`${listId}-input`}
onChange={(event) => updateValue(event.target.value)}
onKeyDown={(event) => {
onKeyDownCapture={(event) => {
if (items.length === 0) {
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.min(index + 1, items.length - 1))
field.setHighlightedIndex(activeIndex + 1)
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
field.setHighlightedIndex(activeIndex - 1)
}
if (event.key === 'Enter' && items[activeIndex]) {
if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault()
updateValue(items[activeIndex]!.text)
event.stopPropagation()
field.selectCompletion(activeIndex)
}
if (event.key === 'Escape') {
event.preventDefault()
event.currentTarget.blur()
}
}}
role="combobox"
value={value}
ref={setInputRef}
/>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs">
Expand All @@ -226,9 +220,8 @@ function RankedAutocompleteDemo() {
activeIndex={activeIndex}
items={items}
listId={listId}
onActiveIndexChange={setActiveIndex}
onPick={(item) => updateValue(item.text)}
pending={pending}
onActiveIndexChange={field.setHighlightedIndex}
onPick={(item) => field.selectCompletion(items.indexOf(item))}
/>
</DocsPane>
</DocsSplitPaneSection>
Expand Down
Loading
Loading