Skip to content

Commit 65cb299

Browse files
Aymericrclaude
andcommitted
feat: finalize 0.2.0 — locale date vocab, completions polish, pre-release fixes
Wraps up the 0.2.0 work (locale packs + ranked completions, plan 031) and the pre-release review pass. - Locale packs own natural-language date vocabulary: deictics/day parts, relative frames, month-date fillers, calendar periods, compact CJK offsets. - Pin bare midday phrases (midi/mediodía/meio-dia/中午/正午) to today like English `noon`; midnight keeps English's forward-roll. Adds regression tests and records the policy in plan 031 (pre-release review must-fix). - Drop the redundant future-prefix `parts` fallback in offset parsing — fully superseded by the prefix-shifted parse; removes a latent mis-parse surface. - Ranked completions surface plus site locale/completions demos. - Docs: CONTEXT entry list, architecture module table, README (root + package), plans 005/031, wiki decisions/inspiration, backlog follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 885b804 commit 65cb299

61 files changed

Lines changed: 2272 additions & 272 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ packages/lingo/bench/ai-eval.json
2929
*.log
3030
.DS_Store
3131
.vercel
32+
/video

CONTEXT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ its value. Fields never rewrite text while typing (D6).
112112
## Infrastructure nouns
113113

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

118118
**Catalog:** the read-only query surface (`./catalog`) over built-in
119119
unit/kind/currency data — list kinds/units, resolve refs, related units,

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ strings: `"5'11\""`, `"1½ cups"`, `"twenty-five kg"`, `"3pm EST"`,
1414
`"1,234 kg"`. Your database wants canonical values: one number in one unit,
1515
one ISO date. The library is the layer in between: natural-language
1616
quantities, units, dates, and ranges parsed into validated values, then
17-
humanized back. Zero dependencies; English-first parsing with locale-aware
18-
formatting (locale packs are on the roadmap, plan 013).
17+
humanized back. Zero dependencies; English by default with opt-in locale packs
18+
(Spanish, French, Portuguese, Chinese, Japanese, en-GB) for parsing, plus
19+
locale-aware number formatting via `Intl`.
1920

2021
**For the web.** Use one text field instead of a value box plus a unit dropdown:
2122

apps/site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"dependencies": {
1818
"@base-ui/react": "^1.6.0",
1919
"@pascal-app/lingo": "workspace:*",
20+
"@vercel/analytics": "^2.0.1",
2021
"class-variance-authority": "^0.7.1",
2122
"clsx": "^2.1.1",
2223
"geist": "^1.7.2",

apps/site/src/app/docs/page.tsx

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { allKinds } from '@pascal-app/lingo'
2+
import { ISSUE_CODES } from '@pascal-app/lingo/schema'
23
import type { Metadata } from 'next'
34

45
import { EscalationLab } from '@/app/escalation/escalation-lab'
@@ -182,39 +183,8 @@ const formSchemaSnippet = `import { standardSchemaResolver } from '@hookform/res
182183
// user types "5 kg" / picks a date; canonical on submit
183184
useForm({ resolver: standardSchemaResolver(shipment) })`
184185

185-
const issueCodes = [
186-
'EMPTY',
187-
'NO_VALUE',
188-
'UNKNOWN_UNIT',
189-
'KIND_MISMATCH',
190-
'RANGE_KIND_MISMATCH',
191-
'CONVERSION_KIND_MISMATCH',
192-
'TRAILING_INPUT',
193-
'SINGLE_VALUE_EXPECTED',
194-
'APPROX_NOT_ALLOWED',
195-
'UNIT_REQUIRED',
196-
'CONVERSION_NOT_ALLOWED',
197-
'NUMBER_FORMAT',
198-
'NONFINITE',
199-
'RANGE_MIN',
200-
'RANGE_MAX',
201-
'RANGE_OPEN_BOUND_NOT_ALLOWED',
202-
'REQUIRED',
203-
'UNSUPPORTED_DATE',
204-
'NOW_REQUIRED',
205-
'TYPO_CORRECTED',
206-
'AMBIGUOUS_NUMBER',
207-
'AMBIGUOUS_UNIT',
208-
'AMBIGUOUS_DATE',
209-
'RANGE_REVERSED',
210-
'COMPOUND_OVERFLOW',
211-
'CIVIL_AVERAGE',
212-
'UNIT_ASSUMED',
213-
'WEEKDAY_ASSUMED_NEXT',
214-
'SLANG_UNIT',
215-
'TZ_IGNORED',
216-
'AMBIGUOUS_TIMEZONE',
217-
]
186+
// Sourced from the package so the badge wall can't drift from IssueCode.
187+
const issueCodes = Object.keys(ISSUE_CODES)
218188

219189
const DOCS_DESCRIPTION =
220190
'Make forms easier, LLM tools safer. One parser for forgiving human forms and stricter tool schemas: parse, strictness, forms, AI tool fields, MCP, convert, currency, dates, and API reference.'

apps/site/src/app/globals.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -897,8 +897,12 @@ p {
897897
--hero-cursor-duration: 92ms;
898898
}
899899

900+
/* Duration must match FIX_CURSOR_TRAVEL_MS in universal-input.tsx. */
900901
.hero-demo-cursor[data-state="fix"] {
901-
--hero-cursor-duration: 620ms;
902+
--hero-cursor-duration: 950ms;
903+
transition:
904+
opacity var(--motion-fast) var(--ease-out),
905+
transform var(--hero-cursor-duration) cubic-bezier(0.3, 0.75, 0.3, 1);
902906
}
903907

904908
.hero-demo-cursor[data-state="clicking"] {

apps/site/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Analytics } from '@vercel/analytics/next'
12
import { GeistMono } from 'geist/font/mono'
23
import { GeistSans } from 'geist/font/sans'
34
import type { Metadata } from 'next'
@@ -125,6 +126,7 @@ export default function RootLayout({
125126
</footer>
126127
</MotionProvider>
127128
</ThemeProvider>
129+
<Analytics />
128130
</body>
129131
</html>
130132
)

apps/site/src/components/site/completions-demo.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { type Completion, type CompletionSource, completions } from '@pascal-app/lingo/complete'
4+
import { parseDate, parseDateRange } from '@pascal-app/lingo/date'
45
import { AnimatePresence, m, useReducedMotion } from 'motion/react'
56
import { useEffect, useId, useMemo, useRef, useState } from 'react'
67

@@ -12,7 +13,14 @@ import { Input } from '@/components/ui/input'
1213
import { Label } from '@/components/ui/label'
1314
import { cn } from '@/lib/utils'
1415

15-
const EXAMPLES = ['2 f', '8 oz', '10 kg to 16', '30 min to 2', '2 h', '5'] as const
16+
const EXAMPLES = [
17+
'noon tomorrow',
18+
'3days starting tomorrow',
19+
'$10',
20+
'2 f',
21+
'10 kg to 16',
22+
'5',
23+
] as const
1624
const DEBOUNCE_MS = 140
1725
const PANEL_EASE: [number, number, number, number] = [0.16, 1, 0.3, 1]
1826

@@ -25,6 +33,8 @@ const SOURCE_ORDER: CompletionSource[] = [
2533
'unit-prefix',
2634
'implied-unit',
2735
'range-implied',
36+
'cross-kind',
37+
'date',
2838
]
2939

3040
const SOURCE_META: Record<CompletionSource, { label: string; hint: string; badgeClass: string }> = {
@@ -58,12 +68,26 @@ const SOURCE_META: Record<CompletionSource, { label: string; hint: string; badge
5868
hint: 'Open ranges fan out to kg, lb, m, ft, …',
5969
badgeClass: 'border-orange-500/25 bg-orange-500/10 text-orange-950 dark:text-orange-100',
6070
},
71+
'cross-kind': {
72+
label: 'Different kind',
73+
hint: 'Optimistic reading when field kind rejects the parse',
74+
badgeClass: 'border-fuchsia-500/25 bg-fuchsia-500/10 text-fuchsia-900 dark:text-fuchsia-100',
75+
},
76+
date: {
77+
label: 'Date & time',
78+
hint: 'Injected date parser from @pascal-app/lingo/date',
79+
badgeClass: 'border-cyan-500/25 bg-cyan-500/10 text-cyan-900 dark:text-cyan-100',
80+
},
6181
}
6282

6383
const DEFAULT_SOURCES = Object.fromEntries(SOURCE_ORDER.map((source) => [source, true])) as Record<
6484
CompletionSource,
6585
boolean
6686
>
87+
const PILL_ACTIVE_CLASS = 'bg-muted text-foreground hover:bg-muted/80'
88+
const FILTER_PILL_INACTIVE_CLASS =
89+
'bg-background text-muted-foreground line-through hover:bg-muted/30 hover:text-muted-foreground'
90+
const SELECT_PILL_INACTIVE_CLASS = 'bg-background text-muted-foreground hover:bg-muted/30'
6791

6892
function useDebouncedValue<T>(value: T, delayMs: number) {
6993
const [debounced, setDebounced] = useState(value)
@@ -100,7 +124,7 @@ export function CompletionsDemo() {
100124
const [value, setValue] = useState('2 f')
101125
const [activeIndex, setActiveIndex] = useState(0)
102126
const [sources, setSources] = useState(DEFAULT_SOURCES)
103-
const [fieldKind, setFieldKind] = useState<FieldKind>('mass')
127+
const [fieldKind, setFieldKind] = useState<FieldKind>('off')
104128
const [customUnits, setCustomUnits] = useState('')
105129
const listRef = useRef<HTMLDivElement>(null)
106130

@@ -117,6 +141,11 @@ export function CompletionsDemo() {
117141
const rawItems = useMemo(
118142
() =>
119143
completions(query, {
144+
date: (text) => {
145+
const now = new Date()
146+
const single = parseDate(text, { now })
147+
return single.ok ? single : parseDateRange(text, { now })
148+
},
120149
limit: 12,
121150
kind: fieldKind === 'off' ? undefined : fieldKind,
122151
units: parsedUnits,
@@ -185,7 +214,10 @@ export function CompletionsDemo() {
185214
).map(([kind, label]) => (
186215
<Button
187216
aria-pressed={fieldKind === kind}
188-
className="h-7 rounded-full px-2.5 text-xs shadow-none"
217+
className={cn(
218+
'h-7 rounded-full px-2.5 text-xs shadow-none',
219+
fieldKind === kind ? PILL_ACTIVE_CLASS : SELECT_PILL_INACTIVE_CLASS,
220+
)}
189221
key={kind}
190222
onClick={() => setFieldKind(kind)}
191223
size="sm"
@@ -230,7 +262,7 @@ export function CompletionsDemo() {
230262
aria-pressed={on}
231263
className={cn(
232264
'h-7 rounded-full px-2.5 text-xs shadow-none',
233-
on && meta.badgeClass,
265+
on ? PILL_ACTIVE_CLASS : FILTER_PILL_INACTIVE_CLASS,
234266
)}
235267
key={source}
236268
onClick={() => toggleSource(source)}

apps/site/src/components/site/docs-search.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,44 @@ function isTypingTarget(target: EventTarget | null) {
2626
)
2727
}
2828

29-
function scoreResult(searchableText: string, query: string) {
30-
if (!query) {
31-
return 0
29+
interface SearchableItem {
30+
searchableKeywords: string[]
31+
searchableText: string
32+
searchableTitle: string
33+
}
34+
35+
function scoreWord(item: SearchableItem, word: string) {
36+
if (item.searchableTitle.startsWith(word)) {
37+
return 6
38+
}
39+
if (item.searchableTitle.includes(word)) {
40+
return 4
41+
}
42+
if (item.searchableKeywords.some((keyword) => keyword.startsWith(word))) {
43+
return 3
44+
}
45+
if (item.searchableText.includes(word)) {
46+
return 1
3247
}
48+
return 0
49+
}
3350

51+
// Every query word must match somewhere; title > keyword > body.
52+
function scoreResult(item: SearchableItem, query: string) {
3453
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
35-
return words.reduce((score, word) => {
36-
if (searchableText.includes(word)) {
37-
return score + (searchableText.startsWith(word) ? 3 : 1)
54+
if (words.length === 0) {
55+
return 0
56+
}
57+
58+
let total = 0
59+
for (const word of words) {
60+
const score = scoreWord(item, word)
61+
if (score === 0) {
62+
return 0
3863
}
39-
return score
40-
}, 0)
64+
total += score
65+
}
66+
return total
4167
}
4268

4369
export function DocsSearch({ className }: { className?: string }) {
@@ -59,7 +85,7 @@ export function DocsSearch({ className }: { className?: string }) {
5985
}
6086

6187
return docsSearchIndex
62-
.map((item) => ({ ...item, score: scoreResult(item.searchableText, trimmed) }))
88+
.map((item) => ({ ...item, score: scoreResult(item, trimmed) }))
6389
.filter((item) => item.score > 0)
6490
.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
6591
.slice(0, 10)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use client'
2+
3+
import { ChevronDownIcon } from 'lucide-react'
4+
5+
import {
6+
DropdownMenu,
7+
DropdownMenuContent,
8+
DropdownMenuRadioGroup,
9+
DropdownMenuRadioItem,
10+
DropdownMenuTrigger,
11+
} from '@/components/ui/dropdown-menu'
12+
import { LOCALE_META, type LocaleChoice, localeMeta } from '@/lib/locale-meta'
13+
import { cn } from '@/lib/utils'
14+
15+
function LocaleFlag({ flag, className }: { flag: string; className?: string }) {
16+
return (
17+
<span
18+
aria-hidden="true"
19+
className={cn(
20+
'inline-flex size-5 shrink-0 items-center justify-center text-[1.125rem] leading-none',
21+
className,
22+
)}
23+
>
24+
{flag}
25+
</span>
26+
)
27+
}
28+
29+
function LocaleOptionLabel({ flag, label }: { flag: string; label: string }) {
30+
return (
31+
<span className="flex min-w-0 items-center gap-2.5">
32+
<LocaleFlag flag={flag} />
33+
<span className="truncate">{label}</span>
34+
</span>
35+
)
36+
}
37+
38+
export function LocaleSelect({
39+
className,
40+
id,
41+
onValueChange,
42+
value,
43+
}: {
44+
className?: string
45+
id?: string
46+
onValueChange: (value: LocaleChoice) => void
47+
value: LocaleChoice
48+
}) {
49+
const current = localeMeta(value) ?? LOCALE_META[0]!
50+
51+
return (
52+
<DropdownMenu>
53+
<DropdownMenuTrigger
54+
aria-label="Locale"
55+
className={cn(
56+
'inline-flex h-8 w-full min-w-36 cursor-pointer items-center justify-between gap-2 rounded-lg border border-transparent bg-[var(--control-background)] py-1 pr-2 pl-2.5 text-sm shadow-[var(--surface-ring)] outline-none transition-[background-color,color,border-color,box-shadow] duration-[var(--motion-fast)] ease-[var(--ease-out)] hover:bg-[var(--control-hover-background)] focus-visible:border-[var(--control-focus-border)] focus-visible:ring-3 focus-visible:ring-ring/50 data-popup-open:bg-[var(--control-hover-background)]',
57+
className,
58+
)}
59+
id={id}
60+
>
61+
<LocaleOptionLabel flag={current.flag} label={current.label} />
62+
<ChevronDownIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
63+
</DropdownMenuTrigger>
64+
<DropdownMenuContent align="end" className="w-52" sideOffset={6}>
65+
<DropdownMenuRadioGroup
66+
onValueChange={(next) => onValueChange(next as LocaleChoice)}
67+
value={value}
68+
>
69+
{LOCALE_META.map((entry) => (
70+
<DropdownMenuRadioItem key={entry.value} value={entry.value}>
71+
<LocaleOptionLabel flag={entry.flag} label={entry.label} />
72+
</DropdownMenuRadioItem>
73+
))}
74+
</DropdownMenuRadioGroup>
75+
</DropdownMenuContent>
76+
</DropdownMenu>
77+
)
78+
}
79+
80+
export function LocaleBadge({ locale }: { locale: string | undefined }) {
81+
const meta = localeMeta(locale)
82+
if (!meta) {
83+
return <span className="numeric-mono text-foreground">{locale ?? '—'}</span>
84+
}
85+
return (
86+
<span className="inline-flex items-center gap-1.5">
87+
<LocaleFlag className="size-4 text-base" flag={meta.flag} />
88+
<span className="numeric-mono text-foreground">{meta.value}</span>
89+
</span>
90+
)
91+
}

0 commit comments

Comments
 (0)