From 2a1be3af452743719ed7f2cbcd5e14e13ae361f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 11:18:36 +0000 Subject: [PATCH 01/13] docs(wiki): prioritize utility growth opportunities Co-authored-by: Aymeric Rabot --- wiki/research/utility-opportunities.md | 165 +++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 wiki/research/utility-opportunities.md diff --git a/wiki/research/utility-opportunities.md b/wiki/research/utility-opportunities.md new file mode 100644 index 0000000..dfac822 --- /dev/null +++ b/wiki/research/utility-opportunities.md @@ -0,0 +1,165 @@ +# Utility opportunities: forms easier, LLM tools safer + +Research synthesis, 2026-07-20. This is a prioritization pass over lingo's +shipped surface, plans, backlog, and the source-verified ecosystem research in +this directory. It does not repeat unverified market-size claims. The external +integration findings were most recently checked between 2026-07-04 and +2026-07-09; version-sensitive recipes still need a fresh check when implemented. + +## Product wedge + +Most libraries own one step: natural-language parsing, unit conversion, +validation, formatting, or tool-schema generation. lingo's defensible value is +the whole boundary: + +`messy text -> parse -> canonicalize -> validate -> store/call -> humanize` + +The same options vocabulary drives a human field and a model-facing +`LingoField`. Spans, explicit ambiguity, two-way output, Standard Schema, and +zero runtime dependencies make that shared boundary difficult to reproduce with +a stack of generic validators and conversion helpers. + +The useful growth strategy is therefore not "parse every noun." It is: + +1. remove integration work around the boundary that already exists; +2. expose safety information generic adapters discard; +3. add high-value language coverage as tree-shakeable data; +4. prove speed and correctness with executable examples. + +## Opportunity matrix + +Scores are relative: value and differentiation run from 1 (low) to 5 (high); +cost and bundle risk run from 1 (small) to 5 (large). + +| Opportunity | Audience | Value | Differentiation | Cost | Bundle risk | Recommendation | +|---|---|---:|---:|---:|---:|---| +| React completion state and selection | Forms, computer-use agents | 5 | 3 | 2 | 1 | Ship first | +| Strict-schema preflight (`assertStrictSafe`) | Tool authors | 4 | 4 | 2 | 1 | Next safety slice | +| Warning-preserving direct integrations | Forms, tool authors | 5 | 5 | 2 | 1 | Design after observing adapters | +| Date/duration wire schemas | APIs, agents | 4 | 3 | 3 | 2 | Finish plan 029 | +| Date component certainty | Agents, audit pipelines | 5 | 5 | 4 | 3 | Strategic date follow-up | +| Demo MCP server | Agents, evaluators | 4 | 3 | 2 | 1 | Dogfood `lingoTool` | +| Localized humanize and issue copy | Global forms | 5 | 4 | 5 | 4 | Pack-owned, staged rollout | +| Input calculations in `./calc` | Forms | 4 | 3 | 5 | 4 | Resolve plan 032 first | +| Browser benchmark and comparisons | Evaluators | 3 | 3 | 2 | 1 | Publish proof, not core code | + +No new backlog entries result from this pass: every deferred opportunity above +already appears in a numbered plan or `plans/backlog.md`. + +## First slice: React completions + +`@pascal-app/lingo/complete` already returns ranked, fully parsed completions, +and `lingoInput()` already accepts an injected completion provider. The React +hook currently inherits those option types but does not forward the provider or +surface the resulting list. React users therefore have to rebuild state wiring +that the DOM controller already performs. + +The first slice should expose completion list and highlighted-index state from +`useLingoInput()`, plus a selection helper. The completion engine remains an +explicit caller import, so React-only consumers do not pay for it. Popup markup, +option ids, styling, and keyboard policy remain headless and caller-owned. + +This closes a real integration gap without changing parser behavior, adding a +dependency, or turning the library into a component kit. It also improves +computer-use-agent fields: partial inputs can expose ranked, canonical choices +before commit, while the existing `data-state` and ARIA attributes remain the +machine-readable feedback channel. + +## High-leverage applications + +### One value, one field + +Collapse a value and unit selector into one text field, not an entire form into +a sentence: + +- health intake: `5'11"` and `165 lb` into canonical height and mass; +- shipping and listings: dimensions and weight in the units printed on a label; +- finance: `25 bps`, `0.25%`, or `a quarter point`; +- IoT: `68F`, `20 C`, or an approved fuzzy temperature profile; +- lab and construction: mixed source units canonicalized before storage; +- cooking: volume-to-volume and mass-to-mass only; ingredient density remains + outside lingo's scope. + +Completions are especially useful where bare numbers or short aliases would +otherwise hide an assumption. The UI can show full parsed readings instead of +inventing a second suggestion vocabulary. + +### Progressive enhancement + +Use `lingoInput()` on a normal text input, submit the canonical hidden value when +JavaScript runs, and re-parse server-side either way. A form-associated +`` gives design systems the same path without a framework adapter. +This is a stronger adoption route than shipping a styled field component. + +### One field specification on both sides + +Keep a shared options object for the browser field and the tool argument: + +- the browser gets partial-state UX, commit formatting, bounds, and ARIA; +- `quantityField()` or `dateField()` gets closed JSON Schema, deterministic + reference time, coded issues, and canonical output; +- the database stores one canonical value and optionally the raw input for + audit; +- `format()` or `humanize*()` renders the value back under the two-way + guarantee. + +This is the shortest demonstration of the product thesis because it removes +duplicated validation policy rather than merely sharing types. + +### Tool-call firewall + +Use `lingoObject()` or `lingoTool()` at the execution boundary even when the +agent framework only consumes plain JSON Schema. Generic shape validation cannot +decide whether `"1,234"`, `"next Friday"`, or `"70"` is semantically safe. +lingo can reject ambiguity, require an explicit `now`, return a candidate, or +canonicalize locally without another model call. + +The next small safety utility should walk a field's emitted schema and fail +definition-time checks when strict-provider invariants are violated. It should +not import an AI SDK or provider package. + +### Human confirmation as a first-class branch + +Candidates and warnings are ready-made inputs for review: + +- a form can show the candidate in its hint or description slot; +- a LangGraph-style workflow can interrupt on ambiguity and resume with the + confirmed canonical value; +- an extraction pipeline can keep full quantity output and route warning-bearing + records to review; +- evals can compare canonical values instead of brittle strings. + +Generic Standard Schema adapters intentionally retain only `value` or `issues`, +so success warnings need a direct-lingo integration path rather than a claim +that generic resolvers preserve them. + +## Recommended sequence + +1. Complete the React completion bridge and dogfood it in the accessible site + combobox. +2. Add strict-schema preflight as a tiny, provider-neutral `./ai` helper. +3. Design a warning-preserving integration pattern using evidence from the DOM, + React, form recipes, and tool callbacks; do not mutate validation into a + side-effect. +4. Finish date/duration machine schemas, then add per-component date certainty + when the date result shape is next revised. +5. Publish a demo MCP server and comparative browser benchmarks as executable + adoption proof. +6. Continue locale work through pack-owned humanize templates and message copy, + preserving two-way tests and entry budgets. + +## Sources in this repository + +- `plans/000-vision-and-scope.md` +- `plans/021-mcp-integration.md` +- `plans/029-schema-reference-and-adapters.md` +- `plans/031-completions.md` +- `plans/031-locale-packs.md` +- `plans/032-input-calculations.md` +- `plans/backlog.md` +- `wiki/research/ai-structured-output.md` +- `wiki/research/base-ui-headless-patterns.md` +- `wiki/research/competitive-landscape.md` +- `wiki/research/ecosystem-agent-frameworks.md` +- `wiki/research/ecosystem-form-libraries.md` +- `wiki/research/form-ux-and-database.md` From f9ad4151d9d4efb881603d61a6e02e894693a813 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 11:18:42 +0000 Subject: [PATCH 02/13] feat(react): expose ranked completion state Co-authored-by: Aymeric Rabot --- .../src/components/site/completions-demo.tsx | 91 +++++++-------- apps/site/src/lib/code-snippets.ts | 13 ++- packages/lingo/CHANGELOG.md | 7 ++ packages/lingo/README.md | 22 +++- packages/lingo/docs/recipes.md | 55 +++++++++ packages/lingo/llms.txt | 4 +- packages/lingo/src/react/index.ts | 107 +++++++++++++++++- packages/lingo/src/react/signature.ts | 1 + .../lingo/src/react/use-lingo-input.test.tsx | 85 +++++++++++++- plans/031-completions.md | 36 +++++- 10 files changed, 352 insertions(+), 69 deletions(-) diff --git a/apps/site/src/components/site/completions-demo.tsx b/apps/site/src/components/site/completions-demo.tsx index 320dfeb..fa5f61a 100644 --- a/apps/site/src/components/site/completions-demo.tsx +++ b/apps/site/src/components/site/completions-demo.tsx @@ -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' @@ -35,19 +36,6 @@ type UnitPreset = (typeof UNIT_PRESETS)[number] const localeLingo = createLingo({ locales: [es, fr, pt, zh, ja, enGb] }) -function useDebouncedValue(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) => { @@ -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 @@ -139,17 +127,34 @@ 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(null) + const field = useLingoInput({ + debounce: DEBOUNCE_MS, + listboxId: listId, + complete: (text) => completions(text, completionOptions()), + }) + const { completions: items, 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 ( @@ -159,47 +164,34 @@ function RankedAutocompleteDemo() { title="Autocomplete anything" />
-
- - - Updating… - -
+ 0} autoComplete="off" className="rounded-[6px]" id={`${listId}-input`} - onChange={(event) => updateValue(event.target.value)} onKeyDown={(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) + field.selectCompletion() + } + if (event.key === 'Escape') { + event.preventDefault() + event.currentTarget.blur() } }} - role="combobox" - value={value} + ref={setInputRef} />
@@ -226,9 +218,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))} /> diff --git a/apps/site/src/lib/code-snippets.ts b/apps/site/src/lib/code-snippets.ts index 7b52825..319c4f7 100644 --- a/apps/site/src/lib/code-snippets.ts +++ b/apps/site/src/lib/code-snippets.ts @@ -12,6 +12,7 @@ const range = parseRange("between 5 and 10 kg", { kind: "mass" })` export const completionsSnippet = `import { completions } from "@pascal-app/lingo/complete" import { parseDate, parseDateRange } from "@pascal-app/lingo/date" import { lingoInput } from "@pascal-app/lingo/dom" +import { useLingoInput } from "@pascal-app/lingo/react" // Debounce in your UI (~120–150ms) — completions() re-parses on every call const items = completions("10 kg to 16", { kind: "mass", limit: 8 }) @@ -33,7 +34,17 @@ lingoInput(input, { kind: "mass", complete: (text) => completions(text, { kind: "mass", limit: 8 }), onComplete: (list) => renderGroupedDropdown(list), -})` +}) + +// React exposes the same injected list plus headless highlight/selection state +const field = useLingoInput({ + kind: "mass", + listboxId: "mass-options", + complete: (text) => completions(text, { kind: "mass", limit: 8 }), +}) +field.completions +field.setHighlightedIndex(1) +field.selectCompletion()` export const strictnessSnippet = `import { lingo } from "@pascal-app/lingo" diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index 3200bb0..8a7871d 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -7,6 +7,13 @@ change**, even if the API is untouched. ## [Unreleased] +### Added + +- `useLingoInput()` now forwards an injected ranked-completion provider and + exposes `completions`, `highlightedIndex`, `setHighlightedIndex()`, and + `selectCompletion()` for headless React comboboxes. `./complete` remains an + explicit, tree-shakeable import; no popup UI or runtime dependency is added. + ### Changed - The docs autocomplete showcase now separates ranked completions, loaded-locale diff --git a/packages/lingo/README.md b/packages/lingo/README.md index 7bc5493..6012c36 100644 --- a/packages/lingo/README.md +++ b/packages/lingo/README.md @@ -398,14 +398,26 @@ Style it with attribute selectors: ### React: `@pascal-app/lingo/react` ```tsx +import { completions } from '@pascal-app/lingo/complete' import { useLingoInput } from '@pascal-app/lingo/react' function HeightField() { - const { ref, state, value } = useLingoInput({ kind: 'length', unit: 'm', name: 'height_m' }) - return + const field = useLingoInput({ + kind: 'length', + unit: 'm', + name: 'height_m', + listboxId: 'height-options', + complete: (text) => completions(text, { kind: 'length' }), + }) + return } ``` +The hook exposes `completions`, `highlightedIndex`, +`setHighlightedIndex()`, and `selectCompletion()` for a caller-rendered +listbox. `./complete` stays an injected import; `./react` does not bundle it or +ship popup UI. + ### Web components: `@pascal-app/lingo/element` A form-associated custom element for design systems with no framework @@ -450,9 +462,9 @@ completions('5', { kind: 'length' }).map((c) => c.text) // ['5 m', '5 ft', '5 cm', '5 in', '5 km', '5 mi'] ``` -The DOM field is headless about this too: `lingoInput` accepts injected -`complete` / `onComplete` hooks, so you render your own dropdown — the library ships -no UI. When completions are enabled it wires the combobox side of the ARIA +The DOM field and React hook are headless about this too: inject `complete`, +render your own dropdown, and use `onComplete` or the hook's completion state. +When completions are enabled the input gets the combobox side of the ARIA contract (`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`), and `listboxId` sets `aria-controls` for your listbox. diff --git a/packages/lingo/docs/recipes.md b/packages/lingo/docs/recipes.md index fac625e..abef05b 100644 --- a/packages/lingo/docs/recipes.md +++ b/packages/lingo/docs/recipes.md @@ -696,6 +696,61 @@ const heightField = quantityField({ kind: 'length', unit: 'm', min: 0.3, max: 2. const weightField = quantityField({ kind: 'mass', unit: 'kg', min: 0 }) ``` +### React ranked completions + +Inject `completions()` into the hook and render the listbox yourself. The hook +owns only ranked-list state and selection; popup markup, option ids, styling, +and keyboard policy stay in your component: + +```tsx +import { completions } from '@pascal-app/lingo/complete' +import { useLingoInput } from '@pascal-app/lingo/react' + +function HeightInput() { + const field = useLingoInput({ + kind: 'length', + unit: 'm', + listboxId: 'height-options', + complete: (text) => completions(text, { kind: 'length', limit: 6 }), + }) + + return ( + <> + = 0 + ? `height-option-${field.highlightedIndex}` + : undefined + } + onKeyDown={(event) => { + if (event.key === 'ArrowDown') field.setHighlightedIndex(field.highlightedIndex + 1) + if (event.key === 'ArrowUp') field.setHighlightedIndex(field.highlightedIndex - 1) + if (event.key === 'Enter') field.selectCompletion() + }} + /> +
+ {field.completions.map((item, index) => ( + + ))} +
+ + ) +} +``` + +The `Completion` type is shared through the DOM layer, but the completion +engine is not: importing `./react` alone never pulls in `./complete`. + ### React Hook Form For a whole form, `lingoObject` drops straight into the resolver: diff --git a/packages/lingo/llms.txt b/packages/lingo/llms.txt index 6576144..e9d4ca7 100644 --- a/packages/lingo/llms.txt +++ b/packages/lingo/llms.txt @@ -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/*`) @@ -110,7 +110,7 @@ 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: `useLingoInput(opts)` from `@pascal-app/lingo/react`, with completion list/highlight/selection state when a provider is injected. ## Element (`@pascal-app/lingo/element`) diff --git a/packages/lingo/src/react/index.ts b/packages/lingo/src/react/index.ts index 0b0c02a..971a00f 100644 --- a/packages/lingo/src/react/index.ts +++ b/packages/lingo/src/react/index.ts @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import type { Completion } from '../complete/types' import { type LingoField, type LingoFieldState, @@ -26,10 +27,18 @@ export interface UseLingoInputResult< T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement, > { commit(): void + /** Ranked completions from the injected provider; empty while collapsed. */ + completions: readonly Completion[] + /** Ranked completion currently highlighted, or `-1` when collapsed. */ + highlightedIndex: number quantity: Quantity | QuantityRange | null ref: (node: T | null) => void result: LingoResult | null + /** Select a completion by index, defaulting to `highlightedIndex`. */ + selectCompletion(index?: number): void set(v: number | string): void + /** Move the highlight, clamped to the current completion list. */ + setHighlightedIndex(index: number): void /** Mirrors the underlying `LingoField.state` — see `LingoFieldState`. */ state: LingoFieldState value: number | null @@ -121,6 +130,7 @@ function updateOptions(o: UseLingoInputOptions): Partial { errorElement: o.errorElement, hintElement: o.hintElement, inputmode: o.inputmode, + listboxId: o.listboxId, debounce: o.debounce, } } @@ -134,18 +144,33 @@ function sameSnapshot(a: Snapshot, b: Snapshot): boolean { ) } +interface CompletionView { + completions: readonly Completion[] + highlightedIndex: number +} + +const EMPTY_COMPLETION_VIEW: CompletionView = { + completions: [], + highlightedIndex: -1, +} + /** * React hook wrapping `lingoInput()`: attach `ref` to your ``/ - * `