diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1649e2f..e9b8695 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -70,9 +70,23 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/lingo/package.json packages/lingo/CHANGELOG.md
- git commit -m "chore(release): v${{ steps.bump.outputs.version }}"
- git tag "v${{ steps.bump.outputs.version }}"
+ git commit -m "chore(release): v$VERSION"
+ git tag "v$VERSION"
git push origin HEAD --tags
- gh release create "v${{ steps.bump.outputs.version }}" --generate-notes
+ # Release body = the curated changelog section, never an auto commit
+ # list. The notes file lives in RUNNER_TEMP so it can't be committed,
+ # and reaches gh via --notes-file: the changelog is full of backticks
+ # and quotes that would execute or break quoting if interpolated.
+ # npm has already published by this point, so a missing or empty
+ # section falls back to --generate-notes rather than failing the run.
+ NOTES="$RUNNER_TEMP/release-notes.md"
+ if node packages/lingo/scripts/changelog-section.mjs "$VERSION" \
+ --changelog packages/lingo/CHANGELOG.md --out "$NOTES"; then
+ gh release create "v$VERSION" --title "v$VERSION" --notes-file "$NOTES"
+ else
+ echo "::warning::no changelog section for $VERSION; using generated notes"
+ gh release create "v$VERSION" --title "v$VERSION" --generate-notes
+ fi
env:
GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ steps.bump.outputs.version }}
diff --git a/CONTEXT.md b/CONTEXT.md
index 681ef88..462a665 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -32,6 +32,15 @@ kilograms, kelvin, seconds…). *Avoid: amount, measurement, normalized value.*
**Range:** a `QuantityRange` — min/max quantities, `plusMinus`, open bounds,
approximate/fuzzy flags. Never use "range" for text offsets — that's a **span**.
+**Date range:** a `DateRange` from `parseDateRange()` — start/end endpoints,
+either end optionally open. Unqualified "range" means the quantity kind; say
+"date range" when you mean this one. It comes in three shapes, and these are
+the names to use: a **time slot** from clock grammar (`2pm to 4pm`, `9-5`), a
+**dated span** between two dates (`Aug 3 - Aug 9`; "date-to-date span" is the
+long form), and a **calendar period** widened to its real first and last day
+(`next week`, `August`, `2027`). The runtime-only `dated` flag is `true` on the
+latter two and absent on a slot — test it truthy, never `=== false`.
+
**Span:** `{ start, end }` character offsets into the ORIGINAL input string
(the normalizer keeps an offset map, hard rule 3). *Avoid: range, position,
location.*
@@ -129,8 +138,9 @@ docs generate Zod/Valibot/TypeBox/ArkType/Effect adapters + a dictionary from it
`createLingo()` returns an isolated **instance** with its own registry,
messages, and fuzzy vocab.
-**Corpus:** `packages/lingo/tests/corpus/contract-v1.json` — the behavior
-contract. Drift is classified **ADDITIVE** (new inputs now parse) or
+**Corpus:** the behavior contracts under `packages/lingo/tests/corpus/` —
+`contract-v1.json` for English plus one `locale--contract-v1.json` per
+locale pack. Drift is classified **ADDITIVE** (new inputs now parse) or
**BREAKING** (existing interpretations changed) by `scripts/corpus-diff.mjs`;
BREAKING requires a decision entry and a major version.
diff --git a/README.md b/README.md
index c1ed4b9..3a50143 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,7 @@ with the package: [`packages/lingo`](packages/lingo/README.md).**
|------|------------|
| [`packages/lingo`](packages/lingo) | `@pascal-app/lingo`, the published library (src, tests, bench, size/corpus/zero-deps gates) |
| [`apps/site`](apps/site) | Docs site with live parser demos (Next.js, port 3000 or next free) |
+| [`skills/`](skills/README.md) | Agent skills shipped for coding agents (`skills/lingo` — the on-ramp to using the library) |
| [`plans/`](plans/README.md) | Forward-looking specs, one numbered living markdown file per topic |
| [`wiki/`](wiki/README.md) | As-built docs: architecture, decisions, conventions, credits, research |
| [`AGENTS.md`](AGENTS.md) | Canonical agent guide (hard rules, workflow, module map) |
diff --git a/apps/site/package.json b/apps/site/package.json
index 9108209..b6ee81f 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -18,10 +18,12 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@pascal-app/lingo": "workspace:*",
+ "@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.2",
+ "katex": "^0.18.1",
"lucide-react": "^1.23.0",
"marked": "^18.0.7",
"motion": "^12.42.2",
@@ -33,6 +35,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/apps/site/public/llms-small.txt b/apps/site/public/llms-small.txt
index 650dd0e..5f2eb66 100644
--- a/apps/site/public/llms-small.txt
+++ b/apps/site/public/llms-small.txt
@@ -97,6 +97,8 @@ import { parseDate, parseDateRange, humanizeDate, parseDuration } from "@pascal-
const now = new Date("2026-07-08T12:00:00Z")
parseDate("three days ago", { now }) // grain "day"; reference-dependent inputs need explicit now
parseDateRange("2pm to 4pm", { now }) // { start, end } civil endpoints
+parseDateRange("Aug 3 - Aug 9", { now }) // dated span, day grain, dated: true
+parseDateRange("August", { now }) // whole period: Aug 1 → Aug 31
parseDuration("1h30").duration.base // 5400 seconds
humanizeDate(d, { now }) // "3 days ago" — always re-parses within one grain
```
@@ -105,6 +107,10 @@ humanizeDate(d, { now }) // "3 days ago" — always re-parses within one grain
- Trailing timezones detected on `.zone`; pass `applyZone: true` to resolve the real UTC instant.
- `parseDateRange`, `humanizeDateRange`, `humanizeDuration`.
+`parseDateRange` reads three shapes, in this order: a **time slot** (`2pm to 4pm`, `between 9am and 5pm`, `9-5`, `from 3pm`), a **date-to-date span** (`July 1 to July 5`, `Aug 3 - Aug 9`, `from tomorrow to friday`, `Mon-Fri`, `2026-08-01 to 2026-08-05`), and a **whole calendar period** expanded to its real first and last day (`next week` → Mon–Sun, `next month` → 1st–last, `2027` → Jan 1–Dec 31, `August`, `this weekend` → Sat–Sun). A coarse endpoint widens on the CLOSING side too: `July to August` ends Aug 31 and `until August` ends Aug 31, while `from August` opens on the 1st. `this weekend` on a Saturday or Sunday means the weekend in progress. Two absolute dates given backwards (`2026-08-09 to 2026-08-03`) are swapped with a `RANGE_REVERSED` warning; overnight clock slots (`9pm to 5am`) pass through untouched. A runtime-only `dated` flag says which grammar matched, so one field can drive a day picker, a range picker, or a slot picker; `humanizeDateRange` renders dated ranges as dates (`2026-08-01 to 2026-08-31`) and slots as clock phrases, both re-parseable.
+
+Not supported: quarters (`Q3`, `next quarter`), elliptical right sides (`Aug 3-9`), and dash-joined ISO dates with no spaces (`2026-08-01-2026-08-05`) — all return `UNSUPPORTED_DATE`. Use a spaced dash or `to` between ISO dates.
+
## DOM (`@pascal-app/lingo/dom`)
```ts
@@ -249,7 +255,7 @@ Fields implement Standard Schema (validate + jsonSchema). Input JSON Schema is `
- `toJSONSchema(field, { io?, target? })`, `repairTextWith(spec)`, `repairToolCallWith(specsByTool)`.
- `canonicalizeValues(value, spec)`, `quantityMatch`, `dateMatch`.
-Tool-boundary defaults: `AMBIGUOUS_NUMBER` → error + candidate; `dateField` escalates `TZ_IGNORED` and requires explicit `now` for reference-dependent dates; `lingoObject` is closed (`additionalProperties: false`; `{passthrough:true}` opts out).
+Tool-boundary defaults: `AMBIGUOUS_NUMBER` → error + candidate; `dateField` escalates `TZ_IGNORED` and requires explicit `now` for reference-dependent dates; `lingoObject` is closed (`additionalProperties: false`; `{passthrough:true}` opts out). `dateRangeField` shares those guards and accepts every `parseDateRange` shape — slots, dated spans, and calendar periods — returning `{ start?, end?: ISO }`.
## MCP (`@pascal-app/lingo/mcp`)
@@ -325,13 +331,15 @@ The `/docs#forms-ux` section and `/docs/forms-ux.md` markdown mirror show the sa
| TZ_IGNORED | Timezone detected but not applied | Pass `applyZone:true` or remove zone from input |
| TYPO_CORRECTED | Typo auto-fixed | Use `strictness:'confirm'` to fail with candidate |
| RANGE_MIN / RANGE_MAX | Value outside bounds | Re-emit within min/max advertised in field description |
+| RANGE_REVERSED | Bounds given high-to-low; parser swapped them | Emit low-to-high, or accept the swap |
+| UNSUPPORTED_DATE | Date/range shape not in the grammar (`Q3`, `Aug 3-9`) | Re-emit a supported shape or an explicit `start to end` pair |
| RATE_REQUIRED | Cross-currency without rates | Call `convertCurrency` with injected rates |
Full list: EMPTY, NO_VALUE, UNKNOWN_UNIT, KIND_MISMATCH, RANGE_KIND_MISMATCH, CONVERSION_KIND_MISMATCH, RATE_REQUIRED, TRAILING_INPUT, SINGLE_VALUE_EXPECTED, APPROX_NOT_ALLOWED, UNIT_REQUIRED, CONVERSION_NOT_ALLOWED, NUMBER_FORMAT, NONFINITE, LOCALE_NOT_LOADED, RANGE_MIN, RANGE_MAX, RANGE_OPEN_BOUND_NOT_ALLOWED, REQUIRED, UNSUPPORTED_DATE, NOW_REQUIRED, TYPO_CORRECTED, AMBIGUOUS_NUMBER, AMBIGUOUS_UNIT, AMBIGUOUS_DATE, RANGE_REVERSED, COMPOUND_OVERFLOW, CIVIL_AVERAGE, UNIT_ASSUMED, WEEKDAY_ASSUMED_NEXT, SLANG_UNIT, TZ_IGNORED, AMBIGUOUS_TIMEZONE. Override copy via `messages` option map.
## Canonical examples (input → essence)
-"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol.
+"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "Aug 3 - Aug 9" → dated date-range 2026-08-03..2026-08-09 · "August" → dated date-range 2026-08-01..2026-08-31 (whole month) · "next week" → Mon..Sun · "this weekend" → Sat..Sun (the one in progress on Sat/Sun) · "2026-08-09 to 2026-08-03" → swapped 08-03..08-09 + RANGE_REVERSED · "Q3" → ok:false UNSUPPORTED_DATE (quarters need a fiscal-year anchor; not supported) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol.
## Docs (online)
diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx
index 4c1ea90..c73f208 100644
--- a/apps/site/src/app/docs/page.tsx
+++ b/apps/site/src/app/docs/page.tsx
@@ -8,14 +8,17 @@ import { IntegrationsTabs } from '@/app/integrations/integrations-tabs'
import { AiCanonicalizerDemo } from '@/components/site/ai-canonicalizer-demo'
import { AiEvalReadout } from '@/components/site/ai-eval-readout'
import { SectionHeading, SubHeading } from '@/components/site/anchor-heading'
+import { CalendarFieldDemo } from '@/components/site/calendar-field-demo'
import { CodeBlock } from '@/components/site/code-block'
import { CodeTabs } from '@/components/site/code-tabs'
import { CommandBlock } from '@/components/site/command-block'
import { CompletionsDemo } from '@/components/site/completions-demo'
import { CoverageExplorer } from '@/components/site/coverage-explorer'
+import { DataGridDemo } from '@/components/site/data-grid-demo'
import { DocsNav } from '@/components/site/docs-nav'
import { DocsPageActions } from '@/components/site/docs-page-actions'
import { FormUxGallery } from '@/components/site/form-ux-gallery'
+import { LatexUnitsDemo } from '@/components/site/latex-units-demo'
import { ParsePlayground } from '@/components/site/parse-playground'
import { PerformanceSection } from '@/components/site/performance-section'
import {
@@ -189,6 +192,23 @@ const formSchemaSnippet = `import { standardSchemaResolver } from '@hookform/res
// user types "5 kg" / picks a date; canonical on submit
useForm({ resolver: standardSchemaResolver(shipment) })`
+const gridColumnSnippet = `// The column owns the unit; the cell owns nothing but text.
+const columns = {
+ mass: quantityField({ kind: 'mass', unit: 'kg' }),
+ temp: quantityField({ kind: 'temperature', unit: 'C' }),
+ price: quantityField({ kind: 'currency', unit: 'USD' }),
+ shipBy: dateField({ now }),
+}
+
+function cell(column: keyof typeof columns, text: string) {
+ const { value, warnings, issues } = columns[column].safeParse(text)
+ if (issues) {
+ return { state: 'refused', note: issues[0].message }
+ }
+ // "$12.50" resolved to USD, "half a ton" to a short ton — say so.
+ return { state: warnings ? 'assumed' : 'ok', value, warnings }
+}`
+
// Sourced from the package so the badge wall can't drift from IssueCode.
const issueCodes = Object.keys(ISSUE_CODES)
@@ -832,6 +852,18 @@ export default async function Home() {
{' '}
for fields lingo doesn't own.)
+
+
A column is a schema
+
+ The same idea scales past a single field. Give a table column a{' '}
+ quantityField and a cell can take any notation that column can resolve
+ — pounds and ounces, a comma decimal, Fahrenheit — normalizing into one canonical
+ unit, so the totals row can just add numbers. What it cannot resolve stays an issue
+ on the cell that caused it.
+
+
+
+
format() emits re-parses to the
same value.
+
+
Typeset the reading
+
+ A canonical reading is structured enough to render as notation, not just text. The
+ unit id and the numeric value are separate fields, so m/s2 becomes a
+ real fraction with a superscript and ± tolerance becomes a proper
+ interval. This demo maps results to LaTeX in ~90 lines; lingo itself ships no
+ renderer.
+
+
+
.
+
+
One field, three readings
+
+ parseDateRange also reads date-to-date spans (
+ Aug 3 - Aug 9) and whole calendar periods (next week,{' '}
+ this weekend, next month), each expanded to its real first
+ and last day. Because the reading says which shape it found, one input can decide
+ between a day picker, a two-month range picker, and a time slot — no mode toggle for
+ the person typing.
+
+
+
- Locale packs are data-only subpath entries: @pascal-app/lingo/locales/es,{' '}
- fr, pt, zh, ja, and{' '}
- en-gb. Successful parses expose result.locale, which the
+ Locale packs are data-only subpath entries: @pascal-app/lingo/locales/en,{' '}
+ en-gb, es, fr, pt, zh
+ , and ja. Successful parses expose result.locale, which the
playground above shows beside the parse state.
@@ -1014,7 +1069,7 @@ export default async function Home() {
- Section pages
+ Section pages
Every section is also a standalone page (append .md to any of them for
the markdown version an agent can fetch).
diff --git a/apps/site/src/components/site/anchor-heading.tsx b/apps/site/src/components/site/anchor-heading.tsx
index a0d0459..a2ec179 100644
--- a/apps/site/src/components/site/anchor-heading.tsx
+++ b/apps/site/src/components/site/anchor-heading.tsx
@@ -23,7 +23,7 @@ export function SectionHeading({
{kicker}
) : null}
-
+
-
+
addDays(first, i - lead))
+}
+
+/**
+ * Range first, then single date. `parseDateRange` is the stricter reader — it
+ * declines "tomorrow" — so trying it first is what lets one field decide
+ * between a day, a span, and a time slot without the caller picking a mode.
+ */
+function read(text: string, now: Date): Reading {
+ const empty: Reading = { end: null, mode: 'none', result: null, start: null }
+ if (text.trim() === '') {
+ return empty
+ }
+ const range = parseDateRange(text, { now })
+ if (range.ok) {
+ const start = range.start?.date ?? null
+ const end = range.end?.date ?? null
+ // `dated` marks the calendar grammar, but an anchored duration ("3 days
+ // starting monday") is a span too, so day-grained endpoints count. Reading
+ // everything undated as a clock slot mislabels it and hides a month.
+ const dated = range.dated === true
+ const spans = dated || range.start?.grain === 'day' || range.end?.grain === 'day'
+ if (!spans) {
+ return { end, mode: 'slot', result: range, start }
+ }
+ // Calendar grammar reports an inclusive last day; an anchored duration
+ // reports an exclusive end. Normalize to the last day actually covered so
+ // the highlight and the day count agree.
+ return { end: end && !dated ? addDays(end, -1) : end, mode: 'range', result: range, start }
+ }
+ const single = parseDate(text, { now })
+ if (single.ok) {
+ return { end: null, mode: 'single', result: single, start: single.date }
+ }
+ return empty
+}
+
+function monthLabel(d: Date): string {
+ return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
+}
+
+/** True once the reading pinned a time of day, not just a calendar day. */
+function isTimed(result: DateRange | DateResult | null): boolean {
+ if (!(result && 'grain' in result)) {
+ return false
+ }
+ return result.grain === 'hour' || result.grain === 'minute' || result.grain === 'second'
+}
+
+function summary(reading: Reading): string {
+ const { start, end, mode } = reading
+ if (mode === 'none' || !(start || end)) {
+ return 'No reading'
+ }
+ if (mode === 'single' && start) {
+ // "tomorrow at 3pm" resolves to an instant, so dropping the clock here
+ // would show the field as less precise than the reading actually is.
+ return isTimed(reading.result)
+ ? start.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' })
+ : start.toLocaleDateString('en-US', { dateStyle: 'full' })
+ }
+ if (mode === 'slot' && reading.result && 'type' in reading.result) {
+ return humanizeDateRange(reading.result as DateRange)
+ }
+ const days =
+ start && end
+ ? Math.round((startOfDay(end).getTime() - startOfDay(start).getTime()) / 864e5) + 1
+ : 0
+ const left = start ? start.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }) : '—'
+ const right = end
+ ? end.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' })
+ : 'open'
+ return days > 0
+ ? `${left} → ${right} · ${days} ${days === 1 ? 'day' : 'days'}`
+ : `${left} → ${right}`
+}
+
+const MODE_COPY: Record = {
+ none: 'no reading',
+ range: 'date range',
+ single: 'single date',
+ slot: 'time slot',
+}
+
+export function CalendarFieldDemo() {
+ const hydrated = useHydrated()
+ const now = useMemo(() => (hydrated ? new Date() : SSR_NOW), [hydrated])
+ const [value, setValue] = useState('next week')
+ const [cursor, setCursor] = useState(null)
+ const [roving, setRoving] = useState(null)
+ // Bumped only by arrow keys, so focus is never stolen on mount or on typing.
+ const [focusTick, setFocusTick] = useState(0)
+ const gridRef = useRef(null)
+
+ const reading = useMemo(() => read(value, now), [value, now])
+ const { mode, start, end } = reading
+ const twoUp = mode === 'range'
+
+ // The calendar follows the parse unless the reader has paged away from it.
+ const anchor = useMemo(
+ () =>
+ cursor ??
+ (start
+ ? new Date(start.getFullYear(), start.getMonth(), 1)
+ : new Date(now.getFullYear(), now.getMonth(), 1)),
+ [cursor, start, now],
+ )
+ const months = useMemo(() => (twoUp ? [anchor, addMonths(anchor, 1)] : [anchor]), [twoUp, anchor])
+
+ const onScreen = useCallback(
+ (day: Date) =>
+ months.some((m) => day.getFullYear() === m.getFullYear() && day.getMonth() === m.getMonth()),
+ [months],
+ )
+
+ // Exactly one day carries tabIndex 0, so the grid is a single tab stop
+ // instead of 42 (84 in two-month mode) and arrow keys do the rest.
+ const tabDay = useMemo(() => {
+ const candidate = roving ?? start ?? now
+ return onScreen(candidate) ? candidate : anchor
+ }, [roving, start, now, onScreen, anchor])
+
+ useEffect(() => {
+ if (focusTick === 0) {
+ return
+ }
+ gridRef.current?.querySelector(`[data-day="${ymd(tabDay)}"]`)?.focus()
+ }, [focusTick, tabDay])
+
+ const STEPS: Record = useMemo(
+ () => ({ ArrowDown: 7, ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7 }),
+ [],
+ )
+
+ const moveFocus = useCallback(
+ (event: ReactKeyboardEvent, day: Date) => {
+ const weekIndex = (day.getDay() + 6) % 7
+ const step = STEPS[event.key]
+ const next =
+ step === undefined
+ ? event.key === 'Home'
+ ? addDays(day, -weekIndex)
+ : event.key === 'End'
+ ? addDays(day, 6 - weekIndex)
+ : event.key === 'PageUp'
+ ? new Date(day.getFullYear(), day.getMonth() - 1, day.getDate())
+ : event.key === 'PageDown'
+ ? new Date(day.getFullYear(), day.getMonth() + 1, day.getDate())
+ : null
+ : addDays(day, step)
+ if (!next) {
+ return
+ }
+ event.preventDefault()
+ if (!onScreen(next)) {
+ setCursor(new Date(next.getFullYear(), next.getMonth(), 1))
+ }
+ setRoving(next)
+ setFocusTick((tick) => tick + 1)
+ },
+ [STEPS, onScreen],
+ )
+
+ const select = useCallback(
+ (day: Date) => {
+ setCursor(new Date(day.getFullYear(), day.getMonth(), 1))
+ // Clicking inside a single-date reading extends it into a range — the
+ // widget grows a second calendar rather than the reader choosing one.
+ if (mode === 'single' && start && !sameDay(start, day)) {
+ const [a, b] = day < start ? [day, start] : [start, day]
+ setValue(`${ymd(a)} to ${ymd(b)}`)
+ return
+ }
+ setValue(ymd(day))
+ },
+ [mode, start],
+ )
+
+ const inRange = useCallback(
+ (day: Date): boolean => {
+ if (!(start && end) || mode === 'slot') {
+ return false
+ }
+ const t = startOfDay(day).getTime()
+ return t > startOfDay(start).getTime() && t < startOfDay(end).getTime()
+ },
+ [start, end, mode],
+ )
+
+ const isEdge = useCallback(
+ (day: Date): 'start' | 'end' | 'only' | null => {
+ if (mode === 'slot') {
+ return start && sameDay(start, day) ? 'only' : null
+ }
+ if (start && sameDay(start, day)) {
+ return end && !sameDay(start, end) ? 'start' : 'only'
+ }
+ if (end && sameDay(end, day)) {
+ return 'end'
+ }
+ return null
+ },
+ [start, end, mode],
+ )
+
+ return (
+ }
+ detailsLabel="Output"
+ stageClassName="min-h-[34rem] justify-start"
+ title="Adaptive date field"
+ >
+
+
+
{
+ setValue(e.target.value)
+ setCursor(null)
+ }}
+ placeholder="next week, Aug 3 - Aug 9, tomorrow…"
+ spellCheck={false}
+ value={value}
+ />
+
+ {EXAMPLES.map((example) => (
+ {
+ setValue(example)
+ setCursor(null)
+ }}
+ size="xs"
+ type="button"
+ variant={value === example ? 'secondary' : 'ghost'}
+ >
+ {example}
+
+ ))}
+
+
+
+
+
+ {MODE_COPY[mode]}
+
+ {summary(reading)}
+
+
+
+
+
setCursor(addMonths(anchor, -1))}
+ size="icon-sm"
+ type="button"
+ variant="ghost"
+ >
+
+
+ {/* The second month is dropped below `sm` rather than scrolled:
+ 248px twice never fits a phone, and a clipped calendar reads as
+ broken where a single month reads as deliberate. */}
+
+ {months.map((month, index) => (
+
0 && 'hidden sm:block',
+ )}
+ key={month.getTime()}
+ >
+ {monthLabel(month)}
+
+ ))}
+
+
setCursor(addMonths(anchor, 1))}
+ size="icon-sm"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+
+ {months.map((month, index) => (
+
0 && 'hidden sm:table',
+ )}
+ key={month.getTime()}
+ >
+ {monthLabel(month)}
+
+
+ {WEEKDAYS.map((day) => (
+
+ {day.slice(0, 2)}
+ {day}
+
+ ))}
+
+
+
+ {Array.from({ length: 6 }, (_, week) => (
+
+ {monthGrid(month)
+ .slice(week * 7, week * 7 + 7)
+ .map((day) => {
+ const outside = day.getMonth() !== month.getMonth()
+ const edge = isEdge(day)
+ const between = inRange(day)
+ return (
+
+ select(day)}
+ onKeyDown={(event) => moveFocus(event, day)}
+ tabIndex={!outside && sameDay(day, tabDay) ? 0 : -1}
+ type="button"
+ >
+
+ {day.toLocaleDateString('en-US', { dateStyle: 'full' })}
+ {edge === 'start'
+ ? ', range start'
+ : edge === 'end'
+ ? ', range end'
+ : between
+ ? ', within range'
+ : ''}
+
+ {day.getDate()}
+
+
+ )
+ })}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/site/src/components/site/completions-demo.tsx b/apps/site/src/components/site/completions-demo.tsx
index dd42847..2c74780 100644
--- a/apps/site/src/components/site/completions-demo.tsx
+++ b/apps/site/src/components/site/completions-demo.tsx
@@ -162,7 +162,7 @@ function RankedAutocompleteDemo() {
Type a partial value
diff --git a/apps/site/src/components/site/coverage-explorer.tsx b/apps/site/src/components/site/coverage-explorer.tsx
index d879255..e556448 100644
--- a/apps/site/src/components/site/coverage-explorer.tsx
+++ b/apps/site/src/components/site/coverage-explorer.tsx
@@ -3,7 +3,7 @@
import { allKinds, lingo, temperatureVocabs } from '@pascal-app/lingo'
import { parseDate, parseDateRange } from '@pascal-app/lingo/date'
import { useMemo, useState } from 'react'
-
+import { SubHeading } from '@/components/site/anchor-heading'
import { DemoFrame } from '@/components/site/demo-frame'
import { DocsPane, DocsSplitPane } from '@/components/site/docs-split-pane'
import { JsonView } from '@/components/site/json-view'
@@ -130,9 +130,7 @@ export function CoverageExplorer() {
return (
-
- Kinds
-
+
Kinds
Built-in lingo unit kinds
diff --git a/apps/site/src/components/site/data-grid-demo.tsx b/apps/site/src/components/site/data-grid-demo.tsx
new file mode 100644
index 0000000..329891b
--- /dev/null
+++ b/apps/site/src/components/site/data-grid-demo.tsx
@@ -0,0 +1,455 @@
+'use client'
+
+import { dateField, type LingoField, quantityField } from '@pascal-app/lingo/ai'
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ useReactTable,
+} from '@tanstack/react-table'
+import { useCallback, useId, useMemo, useState } from 'react'
+
+import { DemoFrame } from '@/components/site/demo-frame'
+import { useHydrated } from '@/components/site/use-hydrated'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+
+/** SSR reference time. After hydration the grid switches to the real clock. */
+const SSR_NOW = new Date(2026, 6, 3, 9, 0, 0)
+
+interface Shipment {
+ id: string
+ mass: string
+ price: string
+ shipBy: string
+ size: string
+ sku: string
+ temp: string
+}
+
+type FieldKey = 'mass' | 'size' | 'temp' | 'price' | 'shipBy'
+
+interface ColumnSpec {
+ field: LingoField | LingoField
+ header: string
+ suffix: string
+}
+
+/**
+ * Each column is a lingo field — the same Standard Schema field you would hand
+ * to an LLM tool. The column declares the canonical unit once; every cell in it
+ * is validated and converted by that declaration. Only the date column depends
+ * on `now`, but the whole record is rebuilt with it so there is one source.
+ */
+function makeColumns(now: Date): Record {
+ return {
+ mass: { field: quantityField({ kind: 'mass', unit: 'kg' }), header: 'Mass → kg', suffix: 'kg' },
+ size: {
+ field: quantityField({ kind: 'length', unit: 'cm' }),
+ header: 'Size → cm',
+ suffix: 'cm',
+ },
+ temp: {
+ field: quantityField({ kind: 'temperature', unit: 'C' }),
+ header: 'Temp → °C',
+ suffix: '°C',
+ },
+ price: {
+ field: quantityField({ kind: 'currency', unit: 'USD' }),
+ header: 'Price → USD',
+ suffix: 'USD',
+ },
+ shipBy: { field: dateField({ now }), header: 'Ship by → date', suffix: '' },
+ }
+}
+
+const HEADERS: Record = {
+ mass: 'Mass → kg',
+ price: 'Price → USD',
+ shipBy: 'Ship by → date',
+ size: 'Size → cm',
+ temp: 'Temp → °C',
+}
+
+const ORDER: FieldKey[] = ['mass', 'size', 'temp', 'price', 'shipBy']
+
+/**
+ * Six notations per column is the point — this is what one paste from a
+ * supplier sheet actually looks like. The £45 cell is deliberate: lingo refuses
+ * to invent an exchange rate rather than guessing one.
+ */
+const SEED: Shipment[] = [
+ {
+ id: '1',
+ mass: '3 lb 4 oz',
+ price: '$12.50',
+ shipBy: 'next friday',
+ size: `5'11"`,
+ sku: 'Rolled steel',
+ temp: '72°F',
+ },
+ {
+ id: '2',
+ mass: '1,2 kg',
+ price: '9,99',
+ shipBy: '2026-08-14',
+ size: '120 mm',
+ sku: 'Bearing set',
+ temp: '20C',
+ },
+ {
+ id: '3',
+ mass: 'half a ton',
+ price: '1.2k',
+ shipBy: 'in 3 weeks',
+ size: '2 m',
+ sku: 'Pallet, oak',
+ temp: '21',
+ },
+ {
+ id: '4',
+ mass: '850g',
+ price: '$8',
+ shipBy: 'tomorrow',
+ size: '30cm',
+ sku: 'Cable spool',
+ temp: '-4 °C',
+ },
+ {
+ id: '5',
+ mass: '12 stone',
+ price: '£45',
+ shipBy: 'end of the month',
+ size: '6 ft 2',
+ sku: 'Crate 12B',
+ temp: '300 K',
+ },
+]
+
+interface Reading {
+ code: string | null
+ display: string
+ message: string | null
+ number: number | null
+ state: 'ok' | 'assumed' | 'error' | 'empty'
+}
+
+const EMPTY: Reading = { code: null, display: '', message: null, number: null, state: 'empty' }
+
+function ymd(date: Date): string {
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+}
+
+/** Four significant digits reads well across 0.85 kg and 453.6 kg alike. */
+function round(value: number): number {
+ return Number(value.toPrecision(4))
+}
+
+function read(key: FieldKey, text: string, columns: Record): Reading {
+ if (text.trim() === '') {
+ return EMPTY
+ }
+ const spec = columns[key]
+ const result = spec.field.safeParse(text)
+ if (result.issues) {
+ const issue = result.issues[0]
+ return {
+ code: issue?.code ?? null,
+ display: '',
+ // Messages arrive as "[CODE] text" for LLM self-correction; the code is
+ // already on the issue, so the badge shows it and the copy stays human.
+ message: (issue?.message ?? 'Unreadable').replace(/^\[[A-Z_]+]\s*/, ''),
+ number: null,
+ state: 'error',
+ }
+ }
+ const warning = result.warnings?.[0] ?? null
+ const value = result.value
+ const display =
+ key === 'shipBy'
+ ? ymd(new Date(value as string))
+ : `${round(value as number)}${spec.suffix === '°C' ? '' : ' '}${spec.suffix}`
+ return {
+ code: warning?.code ?? null,
+ display,
+ message: warning?.message ?? null,
+ number: typeof value === 'number' ? value : null,
+ state: warning ? 'assumed' : 'ok',
+ }
+}
+
+function LingoCell({
+ columnKey,
+ columns,
+ onChange,
+ rowLabel,
+ value,
+}: {
+ columnKey: FieldKey
+ columns: Record
+ onChange: (next: string) => void
+ rowLabel: string
+ value: string
+}) {
+ const reading = useMemo(() => read(columnKey, value, columns), [columnKey, value, columns])
+ const noteId = useId()
+ const failed = reading.state === 'error'
+ const assumed = reading.state === 'assumed'
+
+ // Raw over canonical, rather than side by side: six columns of both on one
+ // line crushes every cell, and stacking is what makes "you typed / we stored"
+ // legible at a glance.
+ return (
+
+ onChange(event.target.value)}
+ spellCheck={false}
+ value={value}
+ />
+ {/* A span, not a p: the global `p { overflow-wrap: break-word }` would
+ split an issue code mid-word and grow the row. */}
+
+ {failed ? (
+ <>
+ {reading.code ?? 'error'}
+ {reading.message}
+ >
+ ) : (
+ <>
+
+ {reading.display}
+
+ {/* The dotted underline and the `title` both need a pointer to read.
+ Assumptions are the honest part of the story, so they have to
+ survive without one. */}
+ {assumed ? . {reading.message} : null}
+ >
+ )}
+
+
+ )
+}
+
+const columnHelper = createColumnHelper()
+
+export function DataGridDemo() {
+ const hydrated = useHydrated()
+ const [rows, setRows] = useState(SEED)
+ const fields = useMemo(() => makeColumns(hydrated ? new Date() : SSR_NOW), [hydrated])
+
+ const update = useCallback((id: string, key: keyof Shipment, next: string) => {
+ setRows((current) => current.map((row) => (row.id === id ? { ...row, [key]: next } : row)))
+ }, [])
+
+ const columnDefs = useMemo(
+ () => [
+ columnHelper.accessor('sku', {
+ cell: (ctx) => (
+ update(ctx.row.original.id, 'sku', event.target.value)}
+ spellCheck={false}
+ value={ctx.getValue()}
+ />
+ ),
+ header: 'Item',
+ }),
+ ...ORDER.map((key) =>
+ columnHelper.accessor(key, {
+ cell: (ctx) => (
+ update(ctx.row.original.id, key, next)}
+ rowLabel={ctx.row.original.sku || `row ${ctx.row.index + 1}`}
+ value={ctx.getValue()}
+ />
+ ),
+ header: HEADERS[key],
+ }),
+ ),
+ ],
+ [update, fields],
+ )
+
+ const table = useReactTable({
+ columns: columnDefs,
+ data: rows,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ const totals = useMemo(() => {
+ let assumed = 0
+ let mass = 0
+ let price = 0
+ let refused = 0
+ let firstRefusal: { code: string | null; message: string; where: string } | null = null
+ for (const [index, row] of rows.entries()) {
+ for (const key of ORDER) {
+ const reading = read(key, row[key], fields)
+ if (reading.state === 'error') {
+ refused += 1
+ firstRefusal ??= {
+ code: reading.code,
+ message: reading.message ?? '',
+ where: `${HEADERS[key].split(' ')[0]}, row ${index + 1}`,
+ }
+ continue
+ }
+ if (reading.state === 'assumed') {
+ assumed += 1
+ }
+ if (reading.number === null) {
+ continue
+ }
+ if (key === 'mass') {
+ mass += reading.number
+ } else if (key === 'price') {
+ price += reading.number
+ }
+ }
+ }
+ return {
+ assumed,
+ firstRefusal,
+ mass: Math.round(mass * 100) / 100,
+ price: price.toFixed(2),
+ refused,
+ }
+ }, [rows])
+
+ return (
+
+
+
+
+
+ Editable shipment rows. Each column parses free text into one canonical unit.
+
+ {/* Price carries the widest cell note (RATE_REQUIRED), so it gets
+ room the other numeric columns don't need. */}
+
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((group) => (
+
+ {group.headers.map((header) => (
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {/* Ruled on both axes: a spreadsheet grid is what tells the
+ reader these cells take typing, without 30 input boxes. */}
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+ {/* A refused cell contributes nothing, so calling the remainder a
+ total would be the exact silent-wrong-answer this demo argues
+ against. Name it for what it is instead. */}
+
+
+ {totals.refused > 0 ? 'Accepted mass' : 'Total mass'}
+
+ {totals.mass} kg
+
+
+
+ {totals.refused > 0 ? 'Accepted price' : 'Total price'}
+
+ {totals.price} USD
+
+
+
Rows
+ {rows.length}
+
+
+
Assumptions
+ {totals.assumed}
+
+
+
0 ? 'text-destructive' : 'text-muted-foreground'}>
+ Refused
+
+ 0 ? 'text-destructive' : 'text-muted-foreground'}>
+ {totals.refused}
+
+
+
+
setRows(SEED)} size="xs" type="button" variant="outline">
+ Reset
+
+
+
+ {totals.firstRefusal ? (
+
+ {totals.firstRefusal.code}
+ · {totals.firstRefusal.where} ·
+ {totals.firstRefusal.message}
+
+ ) : null}
+
+
+ The totals only add up because every cell landed in its column’s canonical unit.
+ Dotted amber marks an assumption lingo made and recorded; red is a refusal. Cross-currency
+ conversion needs a rate, so £45 in a USD column
+ declines rather than inventing one.
+
+
+
+ )
+}
diff --git a/apps/site/src/components/site/docs-nav.tsx b/apps/site/src/components/site/docs-nav.tsx
index 2aea18f..d7195c2 100644
--- a/apps/site/src/components/site/docs-nav.tsx
+++ b/apps/site/src/components/site/docs-nav.tsx
@@ -127,14 +127,14 @@ export function DocsNav({
>
{group.label}
{subtitle ? (
-
+
{subtitle}
) : null}
diff --git a/apps/site/src/components/site/katex-math.tsx b/apps/site/src/components/site/katex-math.tsx
new file mode 100644
index 0000000..9128dac
--- /dev/null
+++ b/apps/site/src/components/site/katex-math.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import 'katex/dist/katex.min.css'
+
+import katex from 'katex'
+import { useMemo } from 'react'
+
+import { cn } from '@/lib/utils'
+
+/**
+ * Typeset a LaTeX expression. Loaded through `next/dynamic` by its callers, so
+ * KaTeX and its stylesheet stay out of the docs route's initial chunk.
+ */
+export function KatexMath({
+ className,
+ display = true,
+ tex,
+}: {
+ className?: string
+ display?: boolean
+ tex: string
+}) {
+ const html = useMemo(
+ () =>
+ katex.renderToString(tex, {
+ displayMode: display,
+ // `html` alone ships only the visual glyph spans, which a screen reader
+ // reads as scattered characters. This pairs them with MathML and hides
+ // the visual layer from the accessibility tree.
+ output: 'htmlAndMathml',
+ throwOnError: false,
+ }),
+ [tex, display],
+ )
+
+ // KaTeX's only HTML output path is a string. `throwOnError: false` makes it
+ // render malformed input as visible error markup rather than raw passthrough,
+ // and every `tex` here is generated by this app from a parsed lingo result.
+ return (
+
+ )
+}
+
+export default KatexMath
diff --git a/apps/site/src/components/site/landing-sections.tsx b/apps/site/src/components/site/landing-sections.tsx
index 5d3084d..782897d 100644
--- a/apps/site/src/components/site/landing-sections.tsx
+++ b/apps/site/src/components/site/landing-sections.tsx
@@ -1,4 +1,5 @@
import { lingo } from '@pascal-app/lingo'
+import { ChevronDownIcon } from 'lucide-react'
import Link from 'next/link'
import { CodeBlock } from '@/components/site/code-block'
@@ -109,8 +110,8 @@ const field = useLingoInput({ kind: 'mass', unit: 'kg', name: 'weight_kg' })
export const landingFaq = [
{
- question: `How do I parse "5'11\\"" into meters in JavaScript?`,
- answer: `parseQuantity("5'11\\"", { kind: "length" }).quantity.to("m").value returns 1.8034. lingo reads compounds (5'11", 2 lb 3 oz, 1h30), unicode (½, μm, ′ ″), number words, and typos with did-you-mean — zero dependencies.`,
+ question: `How do I parse 5'11" into meters in JavaScript?`,
+ answer: `parseQuantity with kind: 'length' reads it as one compound value, and .to('m').value returns 1.8034. The same call handles 2 lb 3 oz, 1h30, unicode (½, μm, ′ ″), number words, and typos with did-you-mean — zero dependencies.`,
href: '/docs/parse',
},
{
@@ -242,8 +243,8 @@ export function LandingSections() {
- Ranges, conversions, tolerances, fuzzy words, typos, and nine languages of number words
- — see everything the parser reads in{' '}
+ Ranges, conversions, tolerances, fuzzy words, typos, and six languages of number words —
+ see everything the parser reads in{' '}
Parse
{' '}
@@ -359,15 +360,19 @@ export function LandingSections() {
{landingFaq.map((item) => (
-
+
{item.question}
+
- {item.answer}
+ {item.answer}
Read more →
diff --git a/apps/site/src/components/site/latex-units-demo.tsx b/apps/site/src/components/site/latex-units-demo.tsx
new file mode 100644
index 0000000..8a94560
--- /dev/null
+++ b/apps/site/src/components/site/latex-units-demo.tsx
@@ -0,0 +1,129 @@
+'use client'
+
+import { lingo } from '@pascal-app/lingo'
+import dynamic from 'next/dynamic'
+import { useMemo, useState } from 'react'
+
+import { CopyButton } from '@/components/site/copy-button'
+import { DemoFrame } from '@/components/site/demo-frame'
+import { JsonView } from '@/components/site/json-view'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { resultToLatex, unitLatex } from '@/lib/latex'
+
+// KaTeX and its stylesheet are ~80 kB gzipped — a separate chunk, fetched when
+// this demo mounts rather than with the docs route.
+const KatexMath = dynamic(() => import('@/components/site/katex-math'), {
+ loading: () =>
,
+ ssr: false,
+})
+
+const EXAMPLES = [
+ '72 in to cm',
+ `5'11"`,
+ '25 m/s to km/h',
+ 'between 5 and 10 kg',
+ '20 °C to °F',
+ '5 kg ± 200 g',
+ '9.8 m/s2',
+ 'at least 5 kg',
+] as const
+
+/** Derived and affine units, where plain text stops being readable. */
+const NOTATION = [
+ { kind: 'speed', unit: 'km/h' },
+ { kind: 'acceleration', unit: 'm/s2' },
+ { kind: 'volume', unit: 'm3' },
+ { kind: 'temperature', unit: 'C' },
+ { kind: 'pressure', unit: 'kg/cm2' },
+ { kind: 'fuel', unit: 'L/100km' },
+] as const
+
+export function LatexUnitsDemo() {
+ const [value, setValue] = useState('25 m/s to km/h')
+ const result = useMemo(() => (value.trim() === '' ? null : lingo(value)), [value])
+ const tex = useMemo(() => resultToLatex(result), [result])
+
+ return (
+ }
+ detailsLabel="Output"
+ stageClassName="min-h-[30rem] justify-start"
+ title="Measurements as notation"
+ >
+
+
+
setValue(e.target.value)}
+ placeholder="25 m/s to km/h"
+ spellCheck={false}
+ value={value}
+ />
+
+ {EXAMPLES.map((example) => (
+ setValue(example)}
+ size="xs"
+ type="button"
+ variant={value === example ? 'secondary' : 'ghost'}
+ >
+ {example}
+
+ ))}
+
+
+
+
+ {tex ? (
+
+ ) : (
+
+ {value.trim() === '' ? 'Awaiting input' : 'No reading to typeset'}
+
+ )}
+
+
+
+
+ LaTeX
+
+
+ {tex ?? '—'}
+
+
+
+
+
+
Derived units
+ {/* Label and notation stay adjacent inside each cell; spacing goes
+ between cells, or the reader pairs a unit with its neighbour. */}
+
+ {NOTATION.map(({ unit, kind }) => (
+
+ {unit}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/site/src/components/site/use-hydrated.ts b/apps/site/src/components/site/use-hydrated.ts
new file mode 100644
index 0000000..3be16a6
--- /dev/null
+++ b/apps/site/src/components/site/use-hydrated.ts
@@ -0,0 +1,22 @@
+'use client'
+
+import { useSyncExternalStore } from 'react'
+
+function subscribe() {
+ return () => {
+ // Hydration is a one-way transition; there is nothing to unsubscribe from.
+ }
+}
+
+const onClient = () => true
+const onServer = () => false
+
+/**
+ * `false` during SSR and the first client render, `true` afterwards. Demos that
+ * parse relative dates use it to hold a fixed reference time through hydration
+ * and then switch to the real clock, so the markup matches but the reading is
+ * not frozen at build time.
+ */
+export function useHydrated(): boolean {
+ return useSyncExternalStore(subscribe, onClient, onServer)
+}
diff --git a/apps/site/src/lib/code-snippets.ts b/apps/site/src/lib/code-snippets.ts
index d21baf4..8e0397d 100644
--- a/apps/site/src/lib/code-snippets.ts
+++ b/apps/site/src/lib/code-snippets.ts
@@ -158,6 +158,10 @@ const slot = parseDateRange("2pm to 4pm", { now }) // { start, end } endpoints
parseDateRange("9-5", { now }) // workday shift → 09:00–17:00
humanizeDateRange(slot) // "2:00 PM to 4:00 PM"
+// Calendar ranges: dated spans and whole periods, first day to last
+parseDateRange("Aug 3 - Aug 9", { now }) // dated span, grain "day"
+parseDateRange("August", { now }) // Aug 1 → Aug 31, not just the 1st
+
parseDuration("1h30").duration.base // 5400 (seconds)
humanizeDuration(5400, { style: "natural" }) // "an hour and a half"`
diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts
index f97136e..5bcbd00 100644
--- a/apps/site/src/lib/docs-catalog.ts
+++ b/apps/site/src/lib/docs-catalog.ts
@@ -70,13 +70,6 @@ export const docsNavGroups: DocsNavGroup[] = [
'ambiguity',
'alternatives',
]),
- page(
- 'parse-find',
- 'Find values in text',
- 'Scan free text for quantities with spans.',
- ['findQuantities', 'scan', 'extract', 'span'],
- { depth: 3, markdownSectionId: 'parse' },
- ),
page(
'parse-completions',
'Autocomplete anything',
@@ -96,6 +89,13 @@ export const docsNavGroups: DocsNavGroup[] = [
],
{ depth: 3, markdownSectionId: 'parse' },
),
+ page(
+ 'parse-find',
+ 'Find values in text',
+ 'Scan free text for quantities with spans.',
+ ['findQuantities', 'scan', 'extract', 'span'],
+ { depth: 3, markdownSectionId: 'parse' },
+ ),
page(
'strictness',
'Strictness',
@@ -122,6 +122,13 @@ export const docsNavGroups: DocsNavGroup[] = [
'web component',
'element',
]),
+ page(
+ 'forms-range-slider',
+ 'Two-way slider',
+ 'Text parses to thumbs; thumbs humanize back to text.',
+ ['slider', 'range', 'keyboard', 'mass', 'two-way'],
+ { depth: 3, markdownSectionId: 'forms' },
+ ),
page(
'forms-element',
'Web component',
@@ -136,13 +143,6 @@ export const docsNavGroups: DocsNavGroup[] = [
['react native', 'textinput', 'mobile', 'useLingoTextInput'],
{ depth: 3, markdownSectionId: 'forms' },
),
- page(
- 'forms-range-slider',
- 'Two-way slider',
- 'Text parses to thumbs; thumbs humanize back to text.',
- ['slider', 'range', 'keyboard', 'mass', 'two-way'],
- { depth: 3, markdownSectionId: 'forms' },
- ),
page(
'integrations',
'Frameworks',
@@ -240,6 +240,13 @@ export const docsNavGroups: DocsNavGroup[] = [
'tool',
'bridge',
]),
+ page(
+ 'one-schema-grid',
+ 'A column is a schema',
+ 'Give a table column a field; every cell normalizes.',
+ ['data grid', 'table', 'tanstack', 'react-table', 'spreadsheet', 'cell', 'column', 'bulk'],
+ { depth: 3, markdownSectionId: 'one-schema' },
+ ),
],
},
{
@@ -256,6 +263,13 @@ export const docsNavGroups: DocsNavGroup[] = [
'best fit',
'delta',
]),
+ page(
+ 'convert-notation',
+ 'Typeset the reading',
+ 'Render canonical readings as LaTeX notation.',
+ ['latex', 'katex', 'mathjax', 'notation', 'scientific', 'typeset', 'superscript'],
+ { depth: 3, markdownSectionId: 'convert' },
+ ),
page('currency', 'Currency', 'Parse symbols and codes; convert with your own rates.', [
'currency',
'usd',
@@ -288,7 +302,25 @@ export const docsNavGroups: DocsNavGroup[] = [
'range',
'parseDateRange',
'humanizeDateRange',
+ 'calendar',
+ 'next week',
+ 'this weekend',
]),
+ page(
+ 'dates-calendar',
+ 'One field, three readings',
+ 'Date-to-date spans, calendar periods, and slots in one input.',
+ [
+ 'calendar',
+ 'date range',
+ 'date picker',
+ 'next week',
+ 'this weekend',
+ 'next month',
+ 'two month',
+ ],
+ { depth: 3, markdownSectionId: 'dates' },
+ ),
page('locales', 'Locales', 'Load tree-shakeable language packs for parsing.', [
'locale',
'locale pack',
diff --git a/apps/site/src/lib/docs.md.ts b/apps/site/src/lib/docs.md.ts
index 8bfd800..3129a41 100644
--- a/apps/site/src/lib/docs.md.ts
+++ b/apps/site/src/lib/docs.md.ts
@@ -1,5 +1,6 @@
import {
aiSnippets,
+ completionsSnippet,
convertSnippet,
currencySnippet,
datesSnippet,
@@ -95,6 +96,14 @@ export const docsMarkdown = [
'- `a few minutes` parses as an approximate duration range.',
'- `it\'s hot` parses as a temperature fuzzy range when `kind: "temperature"` is supplied.',
'',
+ '### Autocomplete anything',
+ '',
+ '`completions(text, opts?)` from `@pascal-app/lingo/complete` returns ranked, fully-parsed canonical readings of partial or ambiguous input — unit-prefix fan-out, unit-ambiguity forks, number alternatives, implied units, and range tails — each a canonical `text` plus a successful quantity/range/conversion result. A completion is none of the other three: it is not a failure `candidate`, a success `alternative`, or an issue `suggestion`.',
+ '',
+ fenced('ts', completionsSnippet),
+ '',
+ 'Inject it into a field with `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })`; the engine is never bundled into `@pascal-app/lingo/dom` or `/react`, and no popup UI ships. Pass `date` to opt into date completions without pulling `@pascal-app/lingo/date` into `/complete`.',
+ '',
'### Find values in text',
'',
'`findQuantities(text, opts?)` scans free text and returns every quantity, range, and conversion it finds, each with a `span` pointing at the exact characters in the original string.',
@@ -193,7 +202,7 @@ export const docsMarkdown = [
'',
'JSON mode can\'t stop `"2kg"` landing in a number field (`Number("2kg")` → `NaN`, `z.coerce.number()` → `NaN` too), `"1,5"` losing its locale, or `new Date("03/04/2025")` silently picking the wrong month. Models are better at emitting `"5\'11\\""` than `1.8034`; `@pascal-app/lingo/ai` makes that string the reliable path.',
'',
- 'Tool-boundary defaults (plan 020), each with a one-line escape hatch: `AMBIGUOUS_NUMBER` escalates to error with a did-you-mean candidate ("1,234 kg" fails; the model re-emits "1234 kg"); `dateField` escalates `TZ_IGNORED` to error and requires an explicit `now` for reference-dependent dates (`"tomorrow"`, `"March 5"`, and `"at 3pm"` fail with `NOW_REQUIRED`; `requireNow: false` opts out by resolving from the wall clock at field validation; fully absolute dates are unaffected); `dateRangeField` applies the same reference-time and timezone guards to a natural-language time slot (`"2pm to 4pm"`, `"9-5"`) and returns `{ start?, end?: ISO }`; `min`/`max` bounds fail with `RANGE_MIN`/`RANGE_MAX` and appear in JSON Schema `minimum`/`maximum` plus the input description; numeric `rangeField` output rejects open-ended inputs with `RANGE_OPEN_BOUND_NOT_ALLOWED`, while `output: "range"` returns full QuantityRange JSON with open bounds, exclusivity, fuzzy/approximate origin, and `baseUnit`; `lingoObject` is closed (`additionalProperties: false`, unknown keys fail, OpenAI strict compatible; `{ passthrough: true }` opts out); absorbed forgiveness (typos, assumed units, ambiguous dates under `dayFirst`) is surfaced as structured `warnings` on success; failures keep Standard Schema `message`/`path` and add lingo `code`, `severity`, field-input `span`, `data`, `suggestions`, and `candidate` when known; canonical numbers are float-safe.',
+ 'Tool-boundary defaults (plan 020), each with a one-line escape hatch: `AMBIGUOUS_NUMBER` escalates to error with a did-you-mean candidate ("1,234 kg" fails; the model re-emits "1234 kg"); `dateField` escalates `TZ_IGNORED` to error and requires an explicit `now` for reference-dependent dates (`"tomorrow"`, `"March 5"`, and `"at 3pm"` fail with `NOW_REQUIRED`; `requireNow: false` opts out by resolving from the wall clock at field validation; fully absolute dates are unaffected); `dateRangeField` applies the same reference-time and timezone guards to any `parseDateRange` shape — a time slot (`"2pm to 4pm"`, `"9-5"`), a dated span (`"Aug 3 - Aug 9"`), or a whole calendar period (`"next week"`, `"August"`) — and returns `{ start?, end?: ISO }`; `min`/`max` bounds fail with `RANGE_MIN`/`RANGE_MAX` and appear in JSON Schema `minimum`/`maximum` plus the input description; numeric `rangeField` output rejects open-ended inputs with `RANGE_OPEN_BOUND_NOT_ALLOWED`, while `output: "range"` returns full QuantityRange JSON with open bounds, exclusivity, fuzzy/approximate origin, and `baseUnit`; `lingoObject` is closed (`additionalProperties: false`, unknown keys fail, OpenAI strict compatible; `{ passthrough: true }` opts out); absorbed forgiveness (typos, assumed units, ambiguous dates under `dayFirst`) is surfaced as structured `warnings` on success; failures keep Standard Schema `message`/`path` and add lingo `code`, `severity`, field-input `span`, `data`, `suggestions`, and `candidate` when known; canonical numbers are float-safe.',
'',
'The docs demo at `/docs#for-ai` canonicalizes editable model JSON such as `{ "weight": "2 lbs", "height": "5\'11\\"", "deliverBy": "next tues", "boxWeight": "3-5 kg" }` with `canonicalizeValues`, `quantityField`, `rangeField`, and `dateField`.',
'',
@@ -275,6 +284,10 @@ useForm({ resolver: standardSchemaResolver(shipment) })`,
'',
'Type `5\'11"` or emit `"5 kg"`; both arrive canonical. (Whole-form resolvers use `lingoObject(shape, { passthrough: true })` for fields lingo doesn\'t own.)',
'',
+ '### A column is a schema',
+ '',
+ 'The same field works per table column. Attach a `quantityField` to a column and a cell can take any notation that column can resolve — `3 lb 4 oz`, `1,2 kg`, `72°F` — normalizing into one canonical unit, so a totals row can add plain numbers. `safeParse` returns the canonical value plus any `warnings` (`UNIT_ASSUMED`, `AMBIGUOUS_UNIT`), so a grid can flag assumptions instead of hiding them; what it cannot resolve, such as a GBP amount in a USD column, comes back as an issue on that cell instead of a guess.',
+ '',
'## Convert & format',
'',
'Convert between units with exact legal factors (temperature deltas included), then render values back with best-fit and compound output. Everything `format()` emits re-parses to the same value.',
@@ -283,6 +296,10 @@ useForm({ resolver: standardSchemaResolver(shipment) })`,
'',
'`convert` throws on a bad pair; `tryConvert` returns a structured issue instead. `convertDelta` converts a difference — a 5 °C rise is a 9 °F rise, not 41.',
'',
+ '### Typeset the reading',
+ '',
+ 'A result keeps the unit id and the numeric value in separate fields, so rendering notation is a pure mapping over the result — `m/s2` becomes a fraction with a superscript, `±` tolerance becomes an interval. lingo ships no renderer; the docs site maps results to LaTeX for KaTeX in about ninety lines.',
+ '',
'## Currency',
'',
'Parse symbols, ISO codes, and slang; format via Intl; convert with rates you supply. lingo never bundles or fetches FX.',
@@ -301,6 +318,12 @@ useForm({ resolver: standardSchemaResolver(shipment) })`,
'',
'Reference-dependent input needs an explicit `now`, so a queued job parses the same date every time. Fully absolute dates never require it. Browse the shorthand it reads under Catalog → Date shorthand and Time slots.',
'',
+ '### One field, three readings',
+ '',
+ '`parseDateRange` reads three shapes: a time slot (`2pm to 4pm`), a date-to-date span (`July 1 to July 5`, `Aug 3 - Aug 9`, `2026-08-03 to 2026-08-09`), and a whole calendar period (`next week`, `this weekend`, `next month`, `August`, `2027`) expanded to its real first and last day. A coarse endpoint widens on the closing side too, so `July to August` ends on August 31 and `until August` does the same, while `from August` still opens on the 1st. `this weekend` read on a Saturday or Sunday is the weekend in progress, not the next one. A `dated` flag on the result says whether the span came from date grammar or clock grammar, which is what lets one input drive a day picker, a two-month range picker, or a slot picker without asking the person to choose a mode first. `humanizeDateRange` renders each shape back in a form that re-parses, and a descending pair such as `2026-08-09 to 2026-08-03` is swapped with a `RANGE_REVERSED` warning rather than handed back backwards.',
+ '',
+ 'Outside the grammar, and returning `UNSUPPORTED_DATE` rather than a guess: quarters (`Q3`, `next quarter` — they need a fiscal-year anchor to mean anything), elliptical right sides (`Aug 3-9`), and ISO dates dash-joined with no spaces (`2026-08-01-2026-08-05` has four dashes and no way to tell which one splits; `2026-08-01 - 2026-08-05` and `2026-08-01 to 2026-08-05` both parse).',
+ '',
'## Locales',
'',
'Locale packs are opt-in and tree-shakeable. English is built in; load overlays with `createLingo({ locales })` and pass `locale` when a field is known, or omit it for auto-detection among loaded packs plus English.',
@@ -326,7 +349,7 @@ lingo.parse('72 in to cm') // locale: 'en'`,
'',
'Kinds include SI bases, pressure units (`psi`, `hPa`, `inH₂O`, `kgf/cm²`), data rates (`Mbps`, `MB/s`), flow rates (`gpm`, `mL/min`), acceleration (`m/s²`, `gee`), torque (`N⋅m`, `lb-ft`), electrical units (`V`, `A`, `Ω`, `C`), substance (`mol`), concentration (`M`, `μM`, `mol/L`), light units (`cd`, `lm`, `lux`, `nit`), radiation units (`Gy`, `Sv`, `Bq`, `Ci`), fuzzy temperature bands, aliases, and date shorthand. Reader-facing unit notation uses real symbols such as `m²`, `ft²`, `in²`, `km²`, `Ω`, and `μ`; canonical JSON keeps separate stable machine ids. The read-only query surface is `@pascal-app/lingo/catalog`.',
'',
- 'Dates live under `@pascal-app/lingo/date`: `parseDate`, `parseDateRange`, `parseDuration`, `humanizeDate`, `humanizeDateRange`, and `humanizeDuration`. Date parsing accepts an explicit `now` option for deterministic results. Covered forms include today, tomorrow, yesterday, relative offsets, weekdays, next/last week/month/year, ISO dates, ambiguous numeric dates with alternatives, times of day (`at 3pm`, `17h`, `quarter past 5`, `5.30pm`, `midi`), timezones (`3pm EST`, `+05:30`, `Europe/Paris` — exposed by default, resolved with `applyZone`), and time slots (`2pm to 4pm`, `between 9 and 5`, `9-5`, `from 3pm`).',
+ 'Dates live under `@pascal-app/lingo/date`: `parseDate`, `parseDateRange`, `parseDuration`, `humanizeDate`, `humanizeDateRange`, and `humanizeDuration`. Date parsing accepts an explicit `now` option for deterministic results. Covered forms include today, tomorrow, yesterday, relative offsets, weekdays, next/last week/month/year, ISO dates, ambiguous numeric dates with alternatives, times of day (`at 3pm`, `17h`, `quarter past 5`, `5.30pm`, `midi`), timezones (`3pm EST`, `+05:30`, `Europe/Paris` — exposed by default, resolved with `applyZone`), time slots (`2pm to 4pm`, `between 9 and 5`, `9-5`, `from 3pm`), and calendar ranges (`Aug 3 - Aug 9`, `next week`, `this weekend`, `August`, `2027`).',
'',
'Captions:',
'',
diff --git a/apps/site/src/lib/latex.ts b/apps/site/src/lib/latex.ts
new file mode 100644
index 0000000..e77d245
--- /dev/null
+++ b/apps/site/src/lib/latex.ts
@@ -0,0 +1,119 @@
+import type { LingoResult, Quantity, QuantityRange } from '@pascal-app/lingo'
+
+/**
+ * Canonical readings rendered as LaTeX. The unit ID is already the machine
+ * form — "m/s2" carries its own solidus and exponent — so the notation is
+ * derived from the parse rather than kept in a lookup table that could drift.
+ */
+
+/** Thin space between value and unit, per SI typesetting. */
+const THIN = '\\,'
+
+function escapeText(text: string): string {
+ return text.replace(/[&%$#_{}]/g, (c) => `\\${c}`)
+}
+
+/** "s2" -> "\mathrm{s}^{2}", "km" -> "\mathrm{km}". */
+function factorLatex(factor: string): string {
+ const m = /^([^\d]+)(\d+)$/.exec(factor)
+ if (!m) {
+ return `\\mathrm{${escapeText(factor)}}`
+ }
+ return `\\mathrm{${escapeText(m[1] as string)}}^{${m[2]}}`
+}
+
+/** Unit ID to LaTeX. Handles the degree sign, currencies, and solidus units. */
+export function unitLatex(unit: string, kind?: string): string {
+ if (kind === 'temperature') {
+ return unit === 'K' ? '\\mathrm{K}' : `{}^{\\circ}\\mathrm{${escapeText(unit)}}`
+ }
+ if (kind === 'currency') {
+ return `\\mathrm{${escapeText(unit)}}`
+ }
+ if (unit === '%') {
+ return '\\%'
+ }
+ const [numerator, ...rest] = unit.split('/')
+ const top = factorLatex(numerator as string)
+ if (rest.length === 0) {
+ return top
+ }
+ return `\\frac{${top}}{${rest.map(factorLatex).join(THIN)}}`
+}
+
+function numberLatex(value: number, significant = 6): string {
+ if (!Number.isFinite(value)) {
+ return String(value)
+ }
+ const rounded = Number(value.toPrecision(significant))
+ if (rounded !== 0 && (Math.abs(rounded) >= 1e6 || Math.abs(rounded) < 1e-4)) {
+ const [mantissa, exponent] = rounded.toExponential().split('e')
+ return `${mantissa} \\times 10^{${Number(exponent)}}`
+ }
+ return String(rounded)
+}
+
+function quantityLatex(q: Quantity): string {
+ // A compound quantity keeps its parts — 5'11" is two terms, not 5.9166 ft.
+ if (q.parts && q.parts.length > 1) {
+ return q.parts
+ .map((part) => `${numberLatex(part.value)}${THIN}${unitLatex(part.unit, q.kind)}`)
+ .join('\\;')
+ }
+ // Percent sets tight against its number; SI units take a thin space.
+ const gap = q.unit === '%' ? '' : THIN
+ return `${numberLatex(q.value)}${gap}${unitLatex(q.unit, q.kind)}`
+}
+
+function rangeLatex(range: QuantityRange): string {
+ const min = range.min()
+ const max = range.max()
+ const center = range.plusMinus ? range.center() : null
+ if (center && max) {
+ return `${numberLatex(center.value)} \\pm ${numberLatex(max.value - center.value)}${THIN}${unitLatex(center.unit, range.kind)}`
+ }
+ if (min && max) {
+ const unit = unitLatex(max.unit, range.kind)
+ const sameUnit = min.unit === max.unit
+ const left = sameUnit
+ ? numberLatex(min.value)
+ : `${numberLatex(min.value)}${THIN}${unitLatex(min.unit, range.kind)}`
+ const leOrLt = range.exclusiveMin ? '<' : '\\le'
+ const geOrGt = range.exclusiveMax ? '<' : '\\le'
+ return `${left} ${leOrLt} x ${geOrGt} ${numberLatex(max.value)}${THIN}${unit}`
+ }
+ if (min) {
+ return `x ${range.exclusiveMin ? '>' : '\\ge'} ${quantityLatex(min)}`
+ }
+ if (max) {
+ return `x ${range.exclusiveMax ? '<' : '\\le'} ${quantityLatex(max)}`
+ }
+ return ''
+}
+
+/** A conversion side is a quantity or, for "between 5 and 10 kg in lb", a range. */
+function sideLatex(side: Quantity | QuantityRange): string {
+ return 'value' in side ? quantityLatex(side) : rangeLatex(side)
+}
+
+/**
+ * A successful reading as a LaTeX expression. Conversions render as equations
+ * so the source value survives — the arithmetic is the interesting part.
+ */
+export function resultToLatex(result: LingoResult | null): string | null {
+ if (!result?.ok) {
+ return null
+ }
+ switch (result.type) {
+ case 'number':
+ return numberLatex(result.value)
+ case 'quantity':
+ return `${result.quantity.approximate ? '\\approx ' : ''}${quantityLatex(result.quantity)}`
+ case 'range':
+ return rangeLatex(result.range)
+ case 'conversion':
+ return `${sideLatex(result.source)} = ${sideLatex(result.converted)}`
+ default:
+ return null
+ }
+}
diff --git a/apps/site/src/lib/llms-index.ts b/apps/site/src/lib/llms-index.ts
index dc48af2..a14c383 100644
--- a/apps/site/src/lib/llms-index.ts
+++ b/apps/site/src/lib/llms-index.ts
@@ -11,7 +11,7 @@ export function buildLlmsIndex() {
const lines = [
'# lingo',
'',
- '> Make forms easier, LLM tools safer. Zero-dependency TypeScript library that parses natural-language quantities, units, dates, and ranges into canonical SI-anchored values; converts, validates, formats, and humanizes. Entries: `@pascal-app/lingo`, `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element`, `@pascal-app/lingo/react`, `@pascal-app/lingo/ai`, `@pascal-app/lingo/mcp`, `@pascal-app/lingo/describe`, `@pascal-app/lingo/catalog`, `@pascal-app/lingo/complete`, `@pascal-app/lingo/schema`, `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}`, `@pascal-app/lingo/core`.',
+ '> Make forms easier, LLM tools safer. Zero-dependency TypeScript library that parses natural-language quantities, units, dates, and ranges into canonical SI-anchored values; converts, validates, formats, and humanizes. Entries: `@pascal-app/lingo`, `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element`, `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai`, `@pascal-app/lingo/mcp`, `@pascal-app/lingo/describe`, `@pascal-app/lingo/catalog`, `@pascal-app/lingo/complete`, `@pascal-app/lingo/schema`, `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}`, `@pascal-app/lingo/core`.',
'',
'Agent fetch order: read this index first, then fetch section markdown at `/docs/.md` for the topic you need, or `/llms-full.txt` for the complete narrative. For offline or compressed reference, fetch `/llms-small.txt` (npm-shipped package reference). Keep user measurements as strings in tool schemas; call lingo to convert, validate, surface spans, and handle ambiguity.',
'',
diff --git a/bun.lock b/bun.lock
index 0c0fd16..c5ea11f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,10 +17,12 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@pascal-app/lingo": "workspace:*",
+ "@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.2",
+ "katex": "^0.18.1",
"lucide-react": "^1.23.0",
"marked": "^18.0.7",
"motion": "^12.42.2",
@@ -32,6 +34,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -637,6 +640,10 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="],
+ "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
+
+ "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
+
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-guuiO2kKc7yUQFU2jhWyM/BrYGD/brqb0+JvJIXTQ/QI1NlWfHZ1id50kkkOWqxmBiDc8DgW2orfY85pQIc7gw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UglEDl/r1/h5Vw6oS6cEE29Jugz2sDLxHCSupNznKatj1fDMRXFqYKFcbvm8RTFhtYPI45NxkrPNo9BqNychBg=="],
@@ -665,6 +672,8 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
+ "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
@@ -1213,6 +1222,8 @@
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
+ "katex": ["katex@0.18.1", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Td8GCYSxDAoMhHOlKmCFMJ/hz5qlAAb71n66Dryw9nfCVfumLo7nhuotbvKom/XPADmrYC3O5QR71EPq4DarJQ=="],
+
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -1815,6 +1826,8 @@
"is-bun-module/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
+ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
+
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md
index 8661338..e2b4684 100644
--- a/packages/lingo/CHANGELOG.md
+++ b/packages/lingo/CHANGELOG.md
@@ -9,6 +9,25 @@ change**, even if the API is untouched.
### Added
+- Calendar ranges in `parseDateRange` (`@pascal-app/lingo/date`), which
+ previously covered only clock slots and anchored durations:
+ - Date to date — `July 1 to July 5`, `Aug 3 - Aug 9`,
+ `between Aug 3 and Aug 9`, `from tomorrow to friday`, `Mon-Fri`,
+ `2026-08-01 to 2026-08-05`, and open ends (`from monday`, `until august 9`).
+ The end resolves against the start, so a pair never reads backwards.
+ - Calendar periods — `next week` spans Mon–Sun, `next month` the 1st to the
+ last, `this year` and `2027` the whole year, `August` the whole month. A
+ coarse endpoint widens when it closes a span too, so `July to August` ends
+ on August 31 and `until August` does the same, while `from August` still
+ opens on the 1st.
+ - Weekends — `this weekend`, `next weekend`, `last weekend` span Saturday
+ through Sunday. `parseDate` gained the `next`/`last` weekend modifiers too.
+ On a Saturday or Sunday, `this weekend` is the weekend in progress.
+ - `humanizeDateRange` renders these as dates rather than clock times, so they
+ round-trip. Time slots are unchanged.
+ - A descending dated pair such as `2026-08-09 to 2026-08-03` is swapped and
+ reported with the existing `RANGE_REVERSED` warning instead of being handed
+ back backwards. Overnight clock slots (`9pm to 5am`) are untouched.
- Chinese and Japanese calendar and clock grammar: numeric dates written with
suffixes (`2026年3月5日`, `3月5日`, and years spelled digit-by-digit as
`二〇二六年`), weekdays (`星期三`, `周三`, `水曜日`), clocks closed by
@@ -17,46 +36,41 @@ change**, even if the API is untouched.
`明日午後3時`).
- Postpositional bound phrases for scripts that put the comparator after the
quantity: `5キロ未満`, `5キロ以上`, `5公斤以下`, `5公斤以内`, `5キロ超`.
- Previously `5キロ未満` parsed as a bare `5 kg`, dropping the bound.
+ These previously parsed as a bare `5 kg`, silently dropping the bound, so
+ they now return a `range` where they used to return a `quantity` — check any
+ code that reads `.quantity` off a zh/ja parse of one of these forms.
- Chinese and Japanese default currencies, so the shared `¥`/`¥` symbol
resolves per locale (`¥100` is CNY under `zh`, JPY under `ja`), plus the
`元`/`圆`/`块`/`人民币` and `円`/`えん` aliases.
-- French long-scale number words (`milliard` = 10^9, `billion` = 10^12),
- Spanish `mil millones`/`billón`, Portuguese `cento`, and Romance ordinal
+- Spanish `billón` (10^12), Portuguese `cento`, and Romance ordinal
day-of-month forms (`le 1er mars`, `el 1º de marzo`).
- A per-locale benchmark suite (`bun run bench`) so multi-language throughput is
measured rather than assumed.
- Per-locale corpus contracts gained 96 rows and the English contract 5,
covering the grammar above plus the number-word fixes below.
-### Fixed
-
-- Number words: hundreds now multiply only the 1..99 group in front of them, so
- French `mille cinq cents` is 1500 (was 100500). A banked smaller scale
- multiplies the next one, so Spanish `mil millones` is 10^9 (was 1,001,000) and
- `dos mil millones` is 2×10^9. Both were silent wrong answers.
-- `between A and B` now keeps the and-word for the range when both sides are
- spelled scale words: `between one thousand and two thousand meters`,
- `entre mille et deux mille`, `entre mil y dos mil` (all previously failed to
- parse). `between five and a half and ten kg` still reads 5.5..10 — the
- fraction tail binds tighter than the range separator.
-- Portuguese `cento e vinte` (120) and `mil e quinhentos` (1500) parse; the
- and-word after a bare scale word links it to its remainder.
-- Portuguese locative contractions are date fillers, so
- `na proxima segunda-feira` resolves.
-- Scripts written without word spaces no longer swallow grammar words glued to a
- quantity (`三至五天`, `5公斤左右`). Unit aliases stay atomic through the split,
- so `一時間半` remains 1.5 hours rather than splitting at the range word `間`.
-
### Changed
+- Loading a locale pack now resolves its currency symbol instead of warning
+ about it: under `zh` or `ja`, `¥100` returns CNY or JPY with no
+ `AMBIGUOUS_UNIT` warning, where it previously returned JPY plus the warning
+ in both. Selecting a locale is the disambiguation. Parsing without a pack
+ loaded is unchanged — bare `¥`, `$`, and `cents` still warn.
+- `dateRangeField()` advertises its real grammar to models. The JSON Schema
+ description promised only time slots, so a model had no reason to emit
+ `"next week"` or `"Aug 3 - Aug 9"` into a field that accepts them.
- Resolved language profiles and locale-detection scans are memoized per pack
set, so repeated parses with `locale`/auto-detection no longer re-merge packs
or re-tokenize for detection on every call.
-- Size budgets recalibrated once for this wave (see D70 in `wiki/decisions.md`
- for the measured numbers and rationale); `scripts/size.mjs` remains the source
- of truth. No previously-pinned corpus interpretation changed — the wave is
- additive.
+- Size budgets recalibrated twice for this wave — once for the locale idiom
+ engine and once for calendar ranges (D70 and D71 in `wiki/decisions.md` carry
+ the measured numbers and rationale); `scripts/size.mjs` remains the source of
+ truth. Every corpus row pinned at 0.3.0 is byte-for-byte unchanged — this
+ release only adds rows. Five readings outside the pinned set do change, all
+ of them silent wrong answers being corrected, and each is called out in its
+ own entry: postpositional bounds, `¥` under a loaded pack, French
+ `mille cinq cents`, French `billion`, and Spanish `dos mil millones`. If you
+ depend on any of those, read them before upgrading.
- README (npm): repo-relative links (`docs/recipes.md`, `plans/`, `wiki/`) now
point at absolute GitHub URLs so they resolve on npmjs.com, and the docs
site plus the agent `llms.txt` index are linked from the top.
@@ -69,6 +83,68 @@ change**, even if the API is untouched.
gained server-rendered parse-example, forms-vs-LLM, tool-boundary, and FAQ
content (FAQPage/TechArticle/BreadcrumbList structured data included); docs
section headings no longer leak kicker text into the extracted HTML outline.
+- Docs site: three demos show what a canonical reading is worth downstream —
+ one date field that picks its own picker (day, two-month range, or time slot)
+ from the shape `parseDateRange` returns, a LaTeX view that typesets a reading
+ as real notation because the unit id and the value are separate fields, and a
+ data grid whose columns are `quantityField`s, so mixed cell notation
+ normalizes into one unit and the totals row can just add numbers. All three
+ are keyboard-operable and the LaTeX view renders MathML for screen readers.
+
+### Fixed
+
+- Agent-facing docs now cover calendar ranges. `llms.txt`, the package README,
+ the `skills/lingo` skill, the site's markdown mirror, and the `parseDateRange`
+ / `dateRangeField` TSDoc all described time slots only, so an agent reading
+ any of them would not have found the feature. Each now states the three
+ shapes, the closing-side widening rule, the `RANGE_REVERSED` swap, and what
+ is deliberately absent (quarters, `Aug 3-9`, dash-joined ISO dates).
+- `/docs/dates.md` (and therefore `/llms-full.txt` and `/llms.md`) documented
+ `2026-08-03..2026-08-09` as a supported span. `..` is not a separator in the
+ grammar and that input returns `UNSUPPORTED_DATE`; the example now uses a
+ form that parses.
+- `/llms.txt` never advertised `@pascal-app/lingo/react-native`, and the site's
+ markdown mirror never mentioned `@pascal-app/lingo/complete`, hiding two
+ published entry points from agents that read only those files.
+- The docs gates now catch that class of drift: `check-docs-sync.mjs` asserts
+ both `llms.txt` copies against `package.json` exports and every issue code
+ and verifies the served copy is not stale, and `check-llms-index.mjs` asserts
+ the generated index advertises every published entry point.
+- The `/docs` sidebar no longer contradicts the page it scrolls: it listed
+ `Find values in text` before `Autocomplete anything`, and `Two-way slider`
+ after the two framework entries, in both cases the reverse of the document
+ order the same nav highlights while scrolling.
+- `/docs` heading levels are visually distinct again. `SectionHeading` (h2) and
+ `SubHeading` (h3) both rendered at 14px, so the outline read flat and
+ disagreed with the 16px/15px scale the standalone `/docs/` pages use
+ for the same content. `Autocomplete anything` also appeared twice in a row
+ (section heading, then demo panel title), and `Kinds` hand-rolled its own h3,
+ leaving `#coverage-kinds` as the only depth-3 anchor with no anchor link.
+- Docs sidebar group labels and their subtitles met WCAG AA: at
+ `text-muted-foreground/75` and `/55` on 11px text they measured 2.9:1 and
+ 2.1:1 in light mode (3.0:1 dark). Both now use the unmodified
+ `--muted-foreground` token — 4.5:1 light, 7.1:1 dark.
+- Number words: hundreds now multiply only the 1..99 group in front of them, so
+ French `mille cinq cents` is 1500 (was 100500). A banked smaller scale
+ multiplies the next one, so Spanish `dos mil millones` is 2×10^9 (was
+ 1,002,000). Both were silent wrong answers.
+- French `billion` is 10^12, the long scale French actually uses; it read as
+ 10^9 before, a silent factor-of-1000 error. `milliard` was already correct.
+- Scale words joined by `de`/`e` parse instead of failing on the connector:
+ Spanish `mil millones de metros`, French `un milliard de mètres`, Portuguese
+ `mil e quinhentos`.
+- `between A and B` now keeps the and-word for the range when both sides are
+ spelled scale words: `between one thousand and two thousand meters`,
+ `entre mille et deux mille`, `entre mil y dos mil` (all previously failed to
+ parse). `between five and a half and ten kg` still reads 5.5..10 — the
+ fraction tail binds tighter than the range separator.
+- Portuguese `cento e vinte` (120) and `mil e quinhentos` (1500) parse; the
+ and-word after a bare scale word links it to its remainder.
+- Portuguese locative contractions are date fillers, so
+ `na proxima segunda-feira` resolves.
+- Scripts written without word spaces no longer swallow grammar words glued to a
+ quantity (`三至五天`, `5公斤左右`). Unit aliases stay atomic through the split,
+ so `一時間半` remains 1.5 hours rather than splitting at the range word `間`.
## [0.3.0] - 2026-07-21
diff --git a/packages/lingo/README.md b/packages/lingo/README.md
index 92e8d19..32bc638 100644
--- a/packages/lingo/README.md
+++ b/packages/lingo/README.md
@@ -364,6 +364,28 @@ parseDateRange('from 3pm', { now }) // open-ended (no end)
humanizeDateRange(slot) // "2:00 PM to 4:00 PM" — re-parseable
```
+`parseDateRange` reads calendar ranges too, so one field can take a slot, a
+dated span, or a whole period without a mode toggle:
+
+```ts
+parseDateRange('Aug 3 - Aug 9', { now }) // dated span, day grain
+parseDateRange('July 1 to July 5', { now }) // the end resolves against the start
+parseDateRange('next week', { now }) // Monday through Sunday
+parseDateRange('August', { now }) // Aug 1 → Aug 31, the whole month
+parseDateRange('until August', { now }) // open start, ends Aug 31
+parseDateRange('this weekend', { now }) // Sat–Sun; on a Sat/Sun, the one in progress
+parseDateRange('2026-08-09 to 2026-08-03', { now })
+ // swapped + RANGE_REVERSED warning
+```
+
+A coarse endpoint widens on the *closing* side, which is why `July to August`
+ends on August 31 while `from August` still opens on the 1st. The result carries
+a `dated` flag saying which grammar matched, and `humanizeDateRange` renders
+dated ranges as dates (`"2026-08-01 to 2026-08-31"`) rather than clock times.
+Quarters (`Q3`), elliptical right sides (`Aug 3-9`), and dash-joined ISO dates
+with no spaces (`2026-08-01-2026-08-05`) are not in the grammar and return
+`UNSUPPORTED_DATE` instead of a guess.
+
The humanizer's output is guaranteed re-parseable by the parser (round-trip tested):
`parseDate(humanizeDate(d, { now }), { now })` lands within one display-grain of `d`.
@@ -683,9 +705,10 @@ a person can read the hint. A tool argument has no human in the loop, so the
like `"under 10 kg"` fails with RANGE_OPEN_BOUND_NOT_ALLOWED there; use
`output: 'range'` when the tool should receive full QuantityRange JSON with
open bounds, exclusivity, fuzzy/approximate origin, and `baseUnit`.
-- `dateRangeField()` canonicalizes a natural-language time slot (`"2pm to 4pm"`,
- `"between 9am and 5pm"`, `"9-5"`, `"from 3pm"`) to `{ start?, end?: ISO }` with
- the same reference-time and timezone guards as `dateField` (`applyZone`
+- `dateRangeField()` canonicalizes any `parseDateRange` shape — a time slot
+ (`"2pm to 4pm"`, `"9-5"`, `"from 3pm"`), a dated span (`"Aug 3 - Aug 9"`), or a
+ whole calendar period (`"next week"`, `"August"`) — to `{ start?, end?: ISO }`
+ with the same reference-time and timezone guards as `dateField` (`applyZone`
resolves real instants; `TZ_IGNORED` escalates to error).
- `lingoObject` is **closed**: `additionalProperties: false`, unknown keys
fail, and the shape matches OpenAI strict structured outputs
diff --git a/packages/lingo/llms.txt b/packages/lingo/llms.txt
index 650dd0e..5f2eb66 100644
--- a/packages/lingo/llms.txt
+++ b/packages/lingo/llms.txt
@@ -97,6 +97,8 @@ import { parseDate, parseDateRange, humanizeDate, parseDuration } from "@pascal-
const now = new Date("2026-07-08T12:00:00Z")
parseDate("three days ago", { now }) // grain "day"; reference-dependent inputs need explicit now
parseDateRange("2pm to 4pm", { now }) // { start, end } civil endpoints
+parseDateRange("Aug 3 - Aug 9", { now }) // dated span, day grain, dated: true
+parseDateRange("August", { now }) // whole period: Aug 1 → Aug 31
parseDuration("1h30").duration.base // 5400 seconds
humanizeDate(d, { now }) // "3 days ago" — always re-parses within one grain
```
@@ -105,6 +107,10 @@ humanizeDate(d, { now }) // "3 days ago" — always re-parses within one grain
- Trailing timezones detected on `.zone`; pass `applyZone: true` to resolve the real UTC instant.
- `parseDateRange`, `humanizeDateRange`, `humanizeDuration`.
+`parseDateRange` reads three shapes, in this order: a **time slot** (`2pm to 4pm`, `between 9am and 5pm`, `9-5`, `from 3pm`), a **date-to-date span** (`July 1 to July 5`, `Aug 3 - Aug 9`, `from tomorrow to friday`, `Mon-Fri`, `2026-08-01 to 2026-08-05`), and a **whole calendar period** expanded to its real first and last day (`next week` → Mon–Sun, `next month` → 1st–last, `2027` → Jan 1–Dec 31, `August`, `this weekend` → Sat–Sun). A coarse endpoint widens on the CLOSING side too: `July to August` ends Aug 31 and `until August` ends Aug 31, while `from August` opens on the 1st. `this weekend` on a Saturday or Sunday means the weekend in progress. Two absolute dates given backwards (`2026-08-09 to 2026-08-03`) are swapped with a `RANGE_REVERSED` warning; overnight clock slots (`9pm to 5am`) pass through untouched. A runtime-only `dated` flag says which grammar matched, so one field can drive a day picker, a range picker, or a slot picker; `humanizeDateRange` renders dated ranges as dates (`2026-08-01 to 2026-08-31`) and slots as clock phrases, both re-parseable.
+
+Not supported: quarters (`Q3`, `next quarter`), elliptical right sides (`Aug 3-9`), and dash-joined ISO dates with no spaces (`2026-08-01-2026-08-05`) — all return `UNSUPPORTED_DATE`. Use a spaced dash or `to` between ISO dates.
+
## DOM (`@pascal-app/lingo/dom`)
```ts
@@ -249,7 +255,7 @@ Fields implement Standard Schema (validate + jsonSchema). Input JSON Schema is `
- `toJSONSchema(field, { io?, target? })`, `repairTextWith(spec)`, `repairToolCallWith(specsByTool)`.
- `canonicalizeValues(value, spec)`, `quantityMatch`, `dateMatch`.
-Tool-boundary defaults: `AMBIGUOUS_NUMBER` → error + candidate; `dateField` escalates `TZ_IGNORED` and requires explicit `now` for reference-dependent dates; `lingoObject` is closed (`additionalProperties: false`; `{passthrough:true}` opts out).
+Tool-boundary defaults: `AMBIGUOUS_NUMBER` → error + candidate; `dateField` escalates `TZ_IGNORED` and requires explicit `now` for reference-dependent dates; `lingoObject` is closed (`additionalProperties: false`; `{passthrough:true}` opts out). `dateRangeField` shares those guards and accepts every `parseDateRange` shape — slots, dated spans, and calendar periods — returning `{ start?, end?: ISO }`.
## MCP (`@pascal-app/lingo/mcp`)
@@ -325,13 +331,15 @@ The `/docs#forms-ux` section and `/docs/forms-ux.md` markdown mirror show the sa
| TZ_IGNORED | Timezone detected but not applied | Pass `applyZone:true` or remove zone from input |
| TYPO_CORRECTED | Typo auto-fixed | Use `strictness:'confirm'` to fail with candidate |
| RANGE_MIN / RANGE_MAX | Value outside bounds | Re-emit within min/max advertised in field description |
+| RANGE_REVERSED | Bounds given high-to-low; parser swapped them | Emit low-to-high, or accept the swap |
+| UNSUPPORTED_DATE | Date/range shape not in the grammar (`Q3`, `Aug 3-9`) | Re-emit a supported shape or an explicit `start to end` pair |
| RATE_REQUIRED | Cross-currency without rates | Call `convertCurrency` with injected rates |
Full list: EMPTY, NO_VALUE, UNKNOWN_UNIT, KIND_MISMATCH, RANGE_KIND_MISMATCH, CONVERSION_KIND_MISMATCH, RATE_REQUIRED, TRAILING_INPUT, SINGLE_VALUE_EXPECTED, APPROX_NOT_ALLOWED, UNIT_REQUIRED, CONVERSION_NOT_ALLOWED, NUMBER_FORMAT, NONFINITE, LOCALE_NOT_LOADED, RANGE_MIN, RANGE_MAX, RANGE_OPEN_BOUND_NOT_ALLOWED, REQUIRED, UNSUPPORTED_DATE, NOW_REQUIRED, TYPO_CORRECTED, AMBIGUOUS_NUMBER, AMBIGUOUS_UNIT, AMBIGUOUS_DATE, RANGE_REVERSED, COMPOUND_OVERFLOW, CIVIL_AVERAGE, UNIT_ASSUMED, WEEKDAY_ASSUMED_NEXT, SLANG_UNIT, TZ_IGNORED, AMBIGUOUS_TIMEZONE. Override copy via `messages` option map.
## Canonical examples (input → essence)
-"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol.
+"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "Aug 3 - Aug 9" → dated date-range 2026-08-03..2026-08-09 · "August" → dated date-range 2026-08-01..2026-08-31 (whole month) · "next week" → Mon..Sun · "this weekend" → Sat..Sun (the one in progress on Sat/Sun) · "2026-08-09 to 2026-08-03" → swapped 08-03..08-09 + RANGE_REVERSED · "Q3" → ok:false UNSUPPORTED_DATE (quarters need a fiscal-year anchor; not supported) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol.
## Docs (online)
diff --git a/packages/lingo/package.json b/packages/lingo/package.json
index beb379a..611164d 100644
--- a/packages/lingo/package.json
+++ b/packages/lingo/package.json
@@ -14,6 +14,8 @@
"form",
"date",
"duration",
+ "i18n",
+ "locale",
"humanize",
"quantity",
"typescript",
diff --git a/packages/lingo/scripts/changelog-section.mjs b/packages/lingo/scripts/changelog-section.mjs
new file mode 100644
index 0000000..0166b4b
--- /dev/null
+++ b/packages/lingo/scripts/changelog-section.mjs
@@ -0,0 +1,56 @@
+// Extract one version's section from CHANGELOG.md for GitHub release notes.
+//
+// node scripts/changelog-section.mjs 0.4.0 # → stdout
+// node scripts/changelog-section.mjs 0.4.0 --out f.md # → file
+//
+// The body is everything between that version's `## [x.y.z]` heading and the
+// next `## [` heading, heading line excluded. Exits 1 when the section is
+// missing or empty so the release workflow can fall back to --generate-notes
+// instead of publishing a release with a blank body.
+import { readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+const [version, ...rest] = process.argv.slice(2)
+if (!version) {
+ process.stderr.write(
+ 'usage: changelog-section.mjs [--out ] [--changelog ]\n',
+ )
+ process.exit(2)
+}
+
+const flag = (name) => {
+ const i = rest.indexOf(name)
+ return i === -1 ? undefined : rest[i + 1]
+}
+
+const changelogPath = resolve(flag('--changelog') ?? 'CHANGELOG.md')
+const source = readFileSync(changelogPath, 'utf8')
+
+// Match `## [1.2.3]` with any trailing date/suffix. The version is escaped
+// because it reaches us as an argument, not a literal.
+const heading = new RegExp(
+ String.raw`^## \[${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\][^\n]*$`,
+ 'm',
+)
+const start = source.match(heading)
+if (!start) {
+ process.stderr.write(`changelog-section: no "## [${version}]" heading in ${changelogPath}\n`)
+ process.exit(1)
+}
+
+const bodyStart = start.index + start[0].length
+const next = source.slice(bodyStart).search(/^## \[/m)
+const body = source.slice(bodyStart, next === -1 ? undefined : bodyStart + next).trim()
+
+if (!body) {
+ process.stderr.write(`changelog-section: "## [${version}]" section is empty\n`)
+ process.exit(1)
+}
+
+const out = flag('--out')
+if (out) {
+ writeFileSync(out, `${body}\n`)
+ process.stderr.write(`changelog-section: wrote ${body.length} chars to ${out}\n`)
+} else {
+ process.stdout.write(`${body}\n`)
+}
diff --git a/packages/lingo/scripts/check-docs-sync.mjs b/packages/lingo/scripts/check-docs-sync.mjs
index 5db401d..6f4cc33 100644
--- a/packages/lingo/scripts/check-docs-sync.mjs
+++ b/packages/lingo/scripts/check-docs-sync.mjs
@@ -1,27 +1,59 @@
import { existsSync, readFileSync } from 'node:fs'
import { allKinds } from '../dist/index.js'
+import { ISSUE_CODES } from '../dist/schema/index.js'
const ROOT = new URL('../../..', import.meta.url)
const files = [
new URL('packages/lingo/llms.txt', ROOT),
new URL('apps/site/public/llms-small.txt', ROOT),
]
+const pkg = JSON.parse(readFileSync(new URL('packages/lingo/package.json', ROOT), 'utf8'))
const expectedKinds = allKinds.map((kind) => kind.kind).join(' ')
+const expectedCodes = Array.isArray(ISSUE_CODES) ? ISSUE_CODES : Object.keys(ISSUE_CODES)
+// Locale packs are advertised as one braced group, not one line per language.
+const expectedEntries = Object.keys(pkg.exports)
+ .filter((entry) => entry !== './package.json' && !entry.startsWith('./locales/'))
+ .map((entry) => (entry === '.' ? '@pascal-app/lingo' : `@pascal-app/lingo${entry.slice(1)}`))
let failed = false
for (const file of files) {
if (!existsSync(file)) {
- console.error(`Docs sync check failed: missing ${relative(file)}`)
- failed = true
+ fail(`missing ${relative(file)}`)
continue
}
const text = readFileSync(file, 'utf8')
+
const actualKinds = text.match(/^Kinds: ([^.]+)\./m)?.[1]
if (actualKinds !== expectedKinds) {
- console.error(
- `Docs sync check failed: ${relative(file)} Kinds line is ${JSON.stringify(actualKinds)}, expected ${JSON.stringify(expectedKinds)}`,
+ fail(
+ `${relative(file)} Kinds line is ${JSON.stringify(actualKinds)}, expected ${JSON.stringify(expectedKinds)}`,
+ )
+ }
+
+ // Every published entry point must be reachable from the agent docs, or an
+ // agent reading only llms.txt never learns the entry exists.
+ const missingEntries = expectedEntries.filter((entry) => !text.includes(entry))
+ if (missingEntries.length > 0) {
+ fail(`${relative(file)} never mentions published entries: ${missingEntries.join(', ')}`)
+ }
+ if (!text.includes('@pascal-app/lingo/locales/')) {
+ fail(`${relative(file)} never mentions the locale pack entries`)
+ }
+
+ const missingCodes = expectedCodes.filter((code) => !text.includes(code))
+ if (missingCodes.length > 0) {
+ fail(`${relative(file)} omits issue codes: ${missingCodes.join(', ')}`)
+ }
+}
+
+// `bun run site:sync` copies llms.txt verbatim, so any drift means the served
+// copy is stale — the exact failure mode a presence check above cannot see.
+if (files.every((file) => existsSync(file))) {
+ const [source, served] = files.map((file) => readFileSync(file, 'utf8'))
+ if (source !== served) {
+ fail(
+ `${relative(files[1])} is out of date with ${relative(files[0])} — run \`bun run site:sync\``,
)
- failed = true
}
}
@@ -29,7 +61,14 @@ if (failed) {
process.exit(1)
}
-console.log('Docs sync gate ok: llms.txt kind lists match allKinds.')
+console.log(
+ `Docs sync gate ok: llms.txt files match allKinds (${allKinds.length}), ${expectedEntries.length} entry points, and ${expectedCodes.length} issue codes.`,
+)
+
+function fail(message) {
+ console.error(`Docs sync check failed: ${message}`)
+ failed = true
+}
function relative(url) {
return url.pathname.replace(ROOT.pathname, '')
diff --git a/packages/lingo/scripts/check-llms-index.mjs b/packages/lingo/scripts/check-llms-index.mjs
index 7692207..a5156c9 100644
--- a/packages/lingo/scripts/check-llms-index.mjs
+++ b/packages/lingo/scripts/check-llms-index.mjs
@@ -1,4 +1,22 @@
-import { assertLlmsIndexLinks } from '../../../apps/site/src/lib/llms-index.ts'
+import { readFileSync } from 'node:fs'
+import { assertLlmsIndexLinks, buildLlmsIndex } from '../../../apps/site/src/lib/llms-index.ts'
assertLlmsIndexLinks()
-console.log('llms.txt index link integrity ok')
+
+// The served index advertises the entry points an agent is allowed to import.
+// Adding an export without listing it here makes the entry invisible to every
+// agent that reads /llms.txt and nothing else.
+const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
+const index = buildLlmsIndex()
+const missing = Object.keys(pkg.exports)
+ .filter((entry) => entry !== './package.json' && !entry.startsWith('./locales/'))
+ .map((entry) => (entry === '.' ? '@pascal-app/lingo' : `@pascal-app/lingo${entry.slice(1)}`))
+ .filter((entry) => !index.includes(`\`${entry}\``))
+if (!index.includes('@pascal-app/lingo/locales/')) {
+ missing.push('@pascal-app/lingo/locales/*')
+}
+if (missing.length > 0) {
+ throw new Error(`llms.txt index omits published entry points: ${missing.join(', ')}`)
+}
+
+console.log('llms.txt index link integrity ok; every published entry point is advertised')
diff --git a/packages/lingo/scripts/size.mjs b/packages/lingo/scripts/size.mjs
index 254f838..433d480 100644
--- a/packages/lingo/scripts/size.mjs
+++ b/packages/lingo/scripts/size.mjs
@@ -320,7 +320,10 @@ if (has('src/date/index.ts')) {
// 43.3 (was 41.0): D70 — suffix-delimited date/clock grammar (date/suffix.ts,
// date/numeral.ts): 年月日 numeric dates, 点/時/分/秒 clocks, day periods,
// unspaced date+time splitting, glued affix matching. Measured 43.01.
- check('./date (standalone, incl. engine)', dateAlone, 43_300)
+ // 43.8 (was 43.3): D71 — calendar ranges. Date endpoints reuse the existing
+ // splitter and single-date parser, so the whole capability (date-to-date,
+ // period spans, weekends, the humanize date branch) is 450 B. Measured 43.46.
+ check('./date (standalone, incl. engine)', dateAlone, 43_800)
const withDate = await bundleStdin(
`export * from './src/index.ts'; export * from './src/date/index.ts'`,
)
@@ -344,7 +347,9 @@ if (has('src/date/index.ts')) {
// the date module (see standalone note). Measured 14.29 after golfing.
// 15.7 (was 14.4): D70 — the suffix date/clock grammar lands entirely in the
// date module (see standalone note). Measured 15.46.
- check('./date (marginal over full)', withDate - full, 15_700)
+ // 16.2 (was 15.7): D71 — calendar ranges land entirely in the date module
+ // (see standalone note). Measured 15.90.
+ check('./date (marginal over full)', withDate - full, 16_200)
}
if (has('src/dom/index.ts')) {
@@ -477,7 +482,10 @@ if (has('src/ai/index.ts')) {
// bundled date module (see ./date notes). Measured 17.27 after golfing.
// 18.6 (was 17.4): D70 — the suffix date/clock grammar cascades through the
// bundled date module; no /ai code changed. Measured 18.39.
- check('./ai (marginal over full)', withAi - full, 18_600) // D30: +notation in shared renderNumber
+ // 19.1 (was 18.6): D71 — calendar ranges cascade through the bundled date
+ // module; no /ai code changed. dateRangeField gains the capability for free.
+ // Measured 18.84.
+ check('./ai (marginal over full)', withAi - full, 19_100) // D30: +notation in shared renderNumber
if (has('src/mcp/index.ts')) {
const withMcp = await bundleStdin(
`export * from './src/index.ts'; export * from './src/ai/index.ts'; export * from './src/mcp/index.ts'`,
diff --git a/packages/lingo/src/ai/date-range-field.ts b/packages/lingo/src/ai/date-range-field.ts
index 2ac38b7..476b40b 100644
--- a/packages/lingo/src/ai/date-range-field.ts
+++ b/packages/lingo/src/ai/date-range-field.ts
@@ -26,11 +26,13 @@ export type DateRangeFieldOptions = DateOptions & {
}
/**
- * A Standard Schema + JSON Schema field that canonicalizes a natural-language
- * time slot ("2pm to 4pm", "between 9am and 5pm", "9-5", "from 3pm") into
- * `{ start?, end?: ISO 8601 }`. Same tool-boundary defaults as `dateField`
- * (plan 020): the slot is reference-dependent, so it fails with NOW_REQUIRED
- * unless `now` is passed, and an unapplied TZ_IGNORED escalates to error —
+ * A Standard Schema + JSON Schema field that canonicalizes any
+ * `parseDateRange()` shape — a time slot ("2pm to 4pm", "between 9am and 5pm",
+ * "9-5", "from 3pm"), a dated span ("Aug 3 - Aug 9"), or a whole calendar
+ * period ("next week", "August") — into `{ start?, end?: ISO 8601 }`, with
+ * periods widened to their real first and last day. Same tool-boundary defaults
+ * as `dateField` (plan 020): the range is reference-dependent, so it fails with
+ * NOW_REQUIRED unless `now` is passed, and an unapplied TZ_IGNORED escalates —
* downgrade via `escalate: { TZ_IGNORED: 'warning' }`, or resolve real instants
* with `applyZone: true`.
* @example
@@ -90,5 +92,5 @@ function rangeInputDescription(opts: DateRangeFieldOptions): string {
if (opts.description) {
return opts.description
}
- return 'A natural-language time slot like "2pm to 4pm", "between 9am and 5pm", or "9-5".'
+ return 'A natural-language time slot like "2pm to 4pm" or "9-5", a date span like "Aug 3 - Aug 9", or a whole period like "next week" or "August".'
}
diff --git a/packages/lingo/src/date/absolute.ts b/packages/lingo/src/date/absolute.ts
index 4c672ca..c465e47 100644
--- a/packages/lingo/src/date/absolute.ts
+++ b/packages/lingo/src/date/absolute.ts
@@ -205,6 +205,6 @@ function buildYearless(p: P, month: number, day: number, year: number | undefine
return date
}
-function formatShortDate(date: Date): string {
+export function formatShortDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
diff --git a/packages/lingo/src/date/humanize.ts b/packages/lingo/src/date/humanize.ts
index fdcf1f1..3440f58 100644
--- a/packages/lingo/src/date/humanize.ts
+++ b/packages/lingo/src/date/humanize.ts
@@ -309,7 +309,8 @@ export interface HumanizeDateRangeOptions {
/**
* Render a time slot (from `parseDateRange()`) as a clock-time phrase —
- * "2:00 PM to 4:00 PM", "from 9:00 AM", "until 5:00 PM". The inverse of
+ * "2:00 PM to 4:00 PM", "from 9:00 AM", "until 5:00 PM". Calendar ranges render
+ * as dates instead — "2026-07-01 to 2026-07-05". The inverse of
* `parseDateRange()`: the emitted string re-parses to the same civil times.
* @example
* ```ts
@@ -319,7 +320,12 @@ export interface HumanizeDateRangeOptions {
* ```
*/
export function humanizeDateRange(
- range: { anchored?: boolean; start?: { date: Date }; end?: { date: Date } },
+ range: {
+ anchored?: boolean
+ dated?: boolean
+ end?: { date: Date; grain?: DateGrain }
+ start?: { date: Date; grain?: DateGrain }
+ },
opts?: HumanizeDateRangeOptions,
): string {
if (range.anchored && range.start && range.end) {
@@ -329,8 +335,19 @@ export function humanizeDateRange(
}
}
const hour12 = opts?.hour12 ?? true
- const start = range.start ? formatClock(range.start.date, hour12) : undefined
- const end = range.end ? formatClock(range.end.date, hour12) : undefined
+ // A calendar range must carry its dates, or "July 1 to July 5" renders as
+ // "12:00 AM to 12:00 AM" and re-parses to today. Clock slots stay bare.
+ const render = (ep: { date: Date; grain?: DateGrain }): string => {
+ if (!range.dated) {
+ return formatClock(ep.date, hour12)
+ }
+ const day = formatMonthDayYear(ep.date)
+ return ep.grain === 'hour' || ep.grain === 'minute' || ep.grain === 'second'
+ ? `${day} ${formatClock(ep.date, hour12)}`
+ : day
+ }
+ const start = range.start ? render(range.start) : undefined
+ const end = range.end ? render(range.end) : undefined
if (start && end) {
return `${start} to ${end}`
}
diff --git a/packages/lingo/src/date/parse.ts b/packages/lingo/src/date/parse.ts
index 7de8f62..217b70a 100644
--- a/packages/lingo/src/date/parse.ts
+++ b/packages/lingo/src/date/parse.ts
@@ -6,6 +6,7 @@ import type { LocalePack } from '../locale/types'
import { normalizeInput, toSourceSpan } from '../parse/normalize'
import type { SerializedResult } from '../parse/serialize'
import { tokenize } from '../parse/tokenize'
+import { formatShortDate } from './absolute'
import { addCalendar } from './civil'
import { parseUnitDuration } from './duration'
import {
@@ -387,6 +388,12 @@ export interface DateRange {
*/
anchored?: boolean
confidence: number
+ /**
+ * True when the endpoints came from the calendar grammar rather than the
+ * clock, so `humanizeDateRange()` renders dates instead of bare times.
+ * Runtime-only provenance; never serialized.
+ */
+ dated?: boolean
end?: DateRangeEndpoint
issues: LingoIssue[]
ok: true
@@ -409,14 +416,30 @@ export interface DateRangeFail {
}
/**
- * Parse a time range / slot: `2pm to 4pm`, `between 9am and 5pm`, `9-5`,
- * `from 3pm`, `until 5`, with am/pm inference across the pair and cross-midnight
- * rollover. Time-of-day today (next day when past, like `parseDate`).
+ * Parse a range in one of three shapes, tried in that order:
+ *
+ * 1. a time slot — `2pm to 4pm`, `between 9am and 5pm`, `9-5`, `from 3pm`,
+ * `until 5`, with am/pm inference across the pair and cross-midnight
+ * rollover; time-of-day today (next day when past, like `parseDate`);
+ * 2. a dated span — `July 1 to July 5`, `Aug 3 - Aug 9`, `Mon-Fri`,
+ * `2026-08-01 to 2026-08-05`, where the end resolves against the start
+ * rather than `now`, and two absolute dates given backwards are swapped
+ * with `RANGE_REVERSED`;
+ * 3. a whole calendar period — `next week`, `next month`, `August`, `2027`,
+ * `this weekend` — widened to its real first and last day. A coarse
+ * endpoint widens on the closing side too, so `July to August` ends
+ * August 31 while `from August` opens on the 1st.
+ *
+ * Shapes 2 and 3 set `dated`, which is what `humanizeDateRange()` reads to
+ * render dates instead of clock times.
* @example
* ```ts
* import { parseDateRange } from '@pascal-app/lingo/date'
* const r = parseDateRange('2pm to 4pm', { now: new Date('2026-07-03T09:00:00') })
* r.ok && [r.start?.date.getHours(), r.end?.date.getHours()] // [14, 16]
+ *
+ * const august = parseDateRange('August', { now: new Date('2026-07-03T09:00:00') })
+ * august.ok && [august.start?.date.getDate(), august.end?.date.getDate()] // [1, 31]
* ```
*/
export function parseDateRange(text: string, opts?: DateOptions): DateRange | DateRangeFail {
@@ -534,9 +557,182 @@ function parseDateRangeImpl(text: string, opts?: DateOptions): DateRange | DateR
}
return finishRange(p, span, zoneSpan, issues, startEp, endEp)
}
+
+ // Clock endpoints did not take. Retry the same splits against the full date
+ // grammar — "July 1 to July 5", "from tomorrow to friday". Order matters:
+ // "2pm" parses as a date too (time-only), so the clock pass must win first.
+ const dated = parseDateToDateRange(p, source, spanStart, span, zoneSpan, issues, rangeZone)
+ if (dated) {
+ return dated
+ }
return fail()
}
+const SPACED_DASH = /^(.+?)\s+[-–—]\s+(.+)$/d
+
+/**
+ * Widen a coarse endpoint to the last day it covers. "August" ends on the 31st
+ * whether it stands alone or closes "July to August", and a weekend is named by
+ * its Saturday but runs one day longer. A day-grained endpoint widens to itself,
+ * which is what makes this safe to run over every closing endpoint.
+ */
+function widenToPeriodEnd(core: CoreDate, text: string): CoreDate {
+ const last = /weekend$/i.test(text)
+ ? addCalendar(core.date, { days: 1 })
+ : periodEnd(core.date, core.grain)
+ return last ? { ...core, date: last, grain: 'day', known: knownFor('day') } : core
+}
+
+/** Last day of the period a coarse-grained date names, or null if it names a single day. */
+function periodEnd(date: Date, grain: DateGrain): Date | null {
+ if (grain === 'week') {
+ return addCalendar(date, { days: 6 })
+ }
+ if (grain === 'month') {
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0)
+ }
+ if (grain === 'year') {
+ return new Date(date.getFullYear(), 11, 31)
+ }
+ return null
+}
+
+/** Build a range endpoint from a parsed date, resolving the zone like the anchored path. */
+function dateEndpoint(core: CoreDate, rangeZone: DateZone | undefined): DateRangeEndpoint {
+ const zone = core.zone ?? rangeZone
+ const out: DateRangeEndpoint = {
+ date: zone?.applied ? applyZoneToCivil(core.date, zone) : core.date,
+ grain: core.grain,
+ known: [...new Set(core.known)],
+ }
+ if (zone) {
+ out.zone = zone
+ }
+ return out
+}
+
+function parseDateToDateRange(
+ p: P,
+ source: string,
+ normStart: number,
+ span: Span,
+ zoneSpan: Span,
+ issues: LingoIssue[],
+ rangeZone?: DateZone,
+): DateRange | DateRangeFail | null {
+ // A date coarser than a day already IS a period — "next week" resolves to its
+ // Monday, "August" to the 1st. Spanning it needs no separate grammar: parse
+ // the whole slot as one date and widen it to the period it names.
+ const whole = parseWithOptionalTime(p, normStart, normStart + source.length)
+ // A weekend is named by its Saturday at day grain, so it needs the explicit
+ // widening the coarse grains get for free.
+ const wholeEnd = whole && widenToPeriodEnd(whole, source)
+ if (whole && wholeEnd !== whole) {
+ return finishDateRange(p, span, zoneSpan, [...issues, ...whole.issues], whole.ref === true, {
+ end: dateEndpoint(wholeEnd as CoreDate, rangeZone),
+ start: dateEndpoint(whole, rangeZone),
+ })
+ }
+
+ // The lazy dash rule splits "2026-07-01 - 2026-07-05" at the ISO date's own
+ // dash. When the input carries one, demand a SPACED dash instead, which no
+ // ISO date contains. "2026-07-01-2026-07-05" stays unparseable, as it should.
+ const hasIsoDash = /\d{4}-\d{2}/.test(source)
+ for (const { re, open, dash } of RANGE_SPLITS) {
+ const m = (dash && hasIsoDash ? SPACED_DASH : re).exec(source)
+ if (!m?.indices) {
+ continue
+ }
+ const at = (group: number): [number, number] => {
+ const [s, e] = m.indices![group]!
+ return [normStart + s, normStart + e]
+ }
+ const text = (group: number): string => {
+ const [s, e] = m.indices![group]!
+ return source.slice(s, e)
+ }
+ if (open) {
+ const only = parseWithOptionalTime(p, ...at(1))
+ if (!only) {
+ continue
+ }
+ // "until August" has to reach the end of August; "from August" opens at
+ // its first day, so only the closing side widens.
+ const ep = dateEndpoint(open === 'end' ? only : widenToPeriodEnd(only, text(1)), rangeZone)
+ return finishDateRange(p, span, zoneSpan, [...issues, ...only.issues], only.ref === true, {
+ ...(open === 'end' ? { start: ep } : { end: ep }),
+ })
+ }
+ const startCore = parseWithOptionalTime(p, ...at(1))
+ if (!startCore) {
+ continue
+ }
+ // Each endpoint rolls forward off `now` independently, so a July-3 reference
+ // reads "July 1 to July 5" as 2027-07-01 → 2026-07-05 — descending. Anchor
+ // the end to the start instead, so the pair always reads left to right.
+ const endCore = parseWithOptionalTime({ ...p, now: startCore.date }, ...at(2))
+ if (!endCore) {
+ continue
+ }
+ return finishDateRange(
+ p,
+ span,
+ zoneSpan,
+ [...issues, ...startCore.issues, ...endCore.issues],
+ startCore.ref === true || endCore.ref === true,
+ {
+ end: dateEndpoint(widenToPeriodEnd(endCore, text(2)), rangeZone),
+ start: dateEndpoint(startCore, rangeZone),
+ },
+ )
+ }
+ return null
+}
+
+/**
+ * Absolute date endpoints need no reference time, so they bypass `finishRange`'s
+ * blanket NOW_REQUIRED (which exists for clock endpoints). Reference-dependent
+ * ones — "tomorrow", "next friday" — still demand an explicit `now`.
+ */
+function finishDateRange(
+ p: P,
+ span: Span,
+ zoneSpan: Span,
+ issues: LingoIssue[],
+ needsNow: boolean,
+ ends: { end?: DateRangeEndpoint; start?: DateRangeEndpoint },
+): DateRange | DateRangeFail {
+ if (p.opts.now === undefined && needsNow) {
+ return {
+ ok: false,
+ type: 'date-range-failure',
+ text: p.src,
+ issues: [...issues, makeIssue('NOW_REQUIRED', {}, span, p.opts.messages)],
+ }
+ }
+ let { start, end } = ends
+ const all = [...issues]
+ // A dated pair has no overnight reading the way a clock slot does, so
+ // descending endpoints are a typo. Swap and say so, the way a reversed
+ // quantity range already does, rather than handing back start > end.
+ if (start && end && start.date.getTime() > end.date.getTime()) {
+ ;[start, end] = [end, start]
+ all.push(
+ makeIssue(
+ 'RANGE_REVERSED',
+ { fixed: `${formatShortDate(start.date)} to ${formatShortDate(end.date)}` },
+ span,
+ p.opts.messages,
+ ),
+ )
+ }
+ const range = finishRange(p, span, zoneSpan, all, start, end, true)
+ if (range.ok) {
+ range.dated = true
+ }
+ return range
+}
+
type CalendarDelta = Parameters[1]
function parseAnchoredDurationRange(
diff --git a/packages/lingo/src/date/range.test.ts b/packages/lingo/src/date/range.test.ts
index 9cada5f..5873699 100644
--- a/packages/lingo/src/date/range.test.ts
+++ b/packages/lingo/src/date/range.test.ts
@@ -402,3 +402,188 @@ describe('anchored duration range trailing zone (F3)', () => {
expect(r.issues.filter((i) => i.code === 'TZ_IGNORED')).toEqual([])
})
})
+
+/** Local calendar day as `YYYY-MM-DD`, so assertions read host-independently. */
+function ymd(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+function span(text: string, now: Date = NOW): [string, string] {
+ const r = parseDateRange(text, { now })
+ if (!(r.ok && r.start && r.end)) {
+ throw new Error(`expected a closed range for ${text}`)
+ }
+ return [ymd(r.start.date), ymd(r.end.date)]
+}
+
+describe('parseDateRange date-to-date', () => {
+ it('parses date endpoints across every word separator', () => {
+ expect(span('Aug 3 to Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ expect(span('Aug 3 - Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ expect(span('between Aug 3 and Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ expect(span('from Aug 3 to Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ expect(span('Aug 3 through Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ })
+
+ it('anchors the end to the start so the pair never reads backwards', () => {
+ // Each endpoint rolling forward off `now` independently would give
+ // 2027-07-01 → 2026-07-05, because July 1 is already past on July 3.
+ expect(span('July 1 to July 5')).toEqual(['2027-07-01', '2027-07-05'])
+ })
+
+ it('splits ISO endpoints on a spaced dash and refuses an unspaced one', () => {
+ expect(span('2026-08-01 to 2026-08-05')).toEqual(['2026-08-01', '2026-08-05'])
+ expect(span('2026-08-01 - 2026-08-05')).toEqual(['2026-08-01', '2026-08-05'])
+ // Genuinely ambiguous — four dashes, no way to know which one splits.
+ expect(parseDateRange('2026-08-01-2026-08-05', { now: NOW }).ok).toBe(false)
+ })
+
+ it('keeps open ends open', () => {
+ const from = parseDateRange('from monday', { now: NOW })
+ expect(from.ok && [ymd(from.start!.date), from.end]).toEqual(['2026-07-06', undefined])
+ const until = parseDateRange('until august 9', { now: NOW })
+ expect(until.ok && [until.start, ymd(until.end!.date)]).toEqual([undefined, '2026-08-09'])
+ })
+
+ it('needs an explicit now only for reference-dependent endpoints', () => {
+ expect(parseDateRange('2026-08-01 to 2026-08-05').ok).toBe(true)
+ const relative = parseDateRange('tomorrow to friday')
+ expect(relative.ok).toBe(false)
+ expect(relative.issues.some((i) => i.code === 'NOW_REQUIRED')).toBe(true)
+ })
+
+ it('leaves clock slots to the clock grammar', () => {
+ const clock = parseDateRange('2pm to 4pm', { now: NOW })
+ expect(clock.ok && clock.dated).toBeUndefined()
+ // A lone date is not a range; only a period is.
+ expect(parseDateRange('tomorrow', { now: NOW }).ok).toBe(false)
+ expect(parseDateRange('2pm', { now: NOW }).ok).toBe(false)
+ })
+})
+
+describe('parseDateRange calendar periods', () => {
+ it('spans the period a coarse-grained date names', () => {
+ expect(span('next week')).toEqual(['2026-07-06', '2026-07-12'])
+ expect(span('this month')).toEqual(['2026-07-01', '2026-07-31'])
+ expect(span('next month')).toEqual(['2026-08-01', '2026-08-31'])
+ expect(span('this year')).toEqual(['2026-01-01', '2026-12-31'])
+ expect(span('August')).toEqual(['2026-08-01', '2026-08-31'])
+ expect(span('2027')).toEqual(['2027-01-01', '2027-12-31'])
+ })
+
+ it('spans a weekend Saturday through Sunday', () => {
+ expect(span('this weekend')).toEqual(['2026-07-04', '2026-07-05'])
+ expect(span('next weekend')).toEqual(['2026-07-11', '2026-07-12'])
+ expect(span('last weekend')).toEqual(['2026-06-27', '2026-06-28'])
+ })
+
+ // Sunday is the case that breaks a naive "round forward to Saturday": the
+ // weekend in progress is behind you, so "this weekend" has to look back.
+ it.each([
+ ['Sunday', new Date(2026, 5, 28), ['2026-06-27', '2026-06-28']],
+ ['Monday', new Date(2026, 5, 29), ['2026-07-04', '2026-07-05']],
+ ['Tuesday', new Date(2026, 5, 30), ['2026-07-04', '2026-07-05']],
+ ['Wednesday', new Date(2026, 6, 1), ['2026-07-04', '2026-07-05']],
+ ['Thursday', new Date(2026, 6, 2), ['2026-07-04', '2026-07-05']],
+ ['Friday', new Date(2026, 6, 3), ['2026-07-04', '2026-07-05']],
+ ['Saturday', new Date(2026, 6, 4), ['2026-07-04', '2026-07-05']],
+ ])('reads "this weekend" from a %s as the weekend that contains it', (_day, now, expected) => {
+ expect(span('this weekend', now)).toEqual(expected)
+ })
+
+ it('keeps next and last a clean week either side on a weekend day', () => {
+ for (const now of [new Date(2026, 6, 4), new Date(2026, 6, 5)]) {
+ expect(span('this weekend', now)).toEqual(['2026-07-04', '2026-07-05'])
+ expect(span('next weekend', now)).toEqual(['2026-07-11', '2026-07-12'])
+ expect(span('last weekend', now)).toEqual(['2026-06-27', '2026-06-28'])
+ }
+ })
+
+ it('widens a coarse endpoint that closes a span', () => {
+ // The end of "July to August" is the end of August, not its first morning.
+ expect(span('July to August')).toEqual(['2027-07-01', '2027-08-31'])
+ expect(span('2026 to 2027')).toEqual(['2026-01-01', '2027-12-31'])
+ expect(span('this weekend to next weekend')).toEqual(['2026-07-04', '2026-07-12'])
+ expect(span('next week to next month')).toEqual(['2026-07-06', '2026-08-31'])
+ })
+
+ it('widens only the closing side of an open range', () => {
+ const until = parseDateRange('until August', { now: NOW })
+ expect(until.ok && ymd(until.end!.date)).toBe('2026-08-31')
+ const from = parseDateRange('from August', { now: NOW })
+ expect(from.ok && ymd(from.start!.date)).toBe('2026-08-01')
+ })
+
+ it('swaps a descending pair and says so', () => {
+ const r = parseDateRange('2026-08-09 to 2026-08-03', { now: NOW })
+ expect(r.ok && [ymd(r.start!.date), ymd(r.end!.date)]).toEqual(['2026-08-03', '2026-08-09'])
+ expect(r.issues.some((i) => i.code === 'RANGE_REVERSED')).toBe(true)
+ })
+
+ it('leaves an overnight clock slot alone', () => {
+ // 9pm to 5am is a real slot, not a reversal — only dated pairs swap.
+ const r = parseDateRange('9pm to 5am', { now: NOW })
+ expect(r.ok && r.issues.some((i) => i.code === 'RANGE_REVERSED')).toBe(false)
+ })
+
+ it.each([
+ ['leap February', new Date(2028, 0, 15), 'February', ['2028-02-01', '2028-02-29']],
+ ['common February', new Date(2027, 0, 15), 'February', ['2027-02-01', '2027-02-28']],
+ ['30-day April', new Date(2026, 0, 15), 'April', ['2026-04-01', '2026-04-30']],
+ ['December rollover', new Date(2026, 0, 15), 'December', ['2026-12-01', '2026-12-31']],
+ ['week over new year', new Date(2026, 11, 30), 'next week', ['2027-01-04', '2027-01-10']],
+ ])('gets the last day right for %s', (_name, now, text, expected) => {
+ expect(span(text, now)).toEqual(expected)
+ })
+})
+
+describe('humanizeDateRange round-trips calendar ranges', () => {
+ // Every reference here is deliberate: SUNDAY catches weekend phrasing that
+ // only holds mid-week, and YEAR_END catches periods that cross into January.
+ const SUNDAY = new Date(2026, 5, 28, 9, 0, 0)
+ const YEAR_END = new Date(2026, 11, 30, 9, 0, 0)
+
+ const cases: [string, Date][] = [
+ ['July 1 to July 5', NOW],
+ ['next week', NOW],
+ ['this weekend', NOW],
+ ['next month', NOW],
+ ['August', NOW],
+ ['from monday', NOW],
+ ['until august 9', NOW],
+ ['2026-08-01 to 2026-08-05', NOW],
+ ['July 1 3pm to July 2 5pm', NOW],
+ ['this weekend', SUNDAY],
+ ['next weekend', SUNDAY],
+ ['last weekend', SUNDAY],
+ ['this weekend', new Date(2026, 6, 4, 9, 0, 0)],
+ ['July to August', NOW],
+ ['until August', NOW],
+ ['from August', NOW],
+ ['2026 to 2027', NOW],
+ ['this weekend to next weekend', NOW],
+ ['next week', YEAR_END],
+ ['next month', YEAR_END],
+ ['Dec 28 to Jan 3', NOW],
+ ['2026-08-09 to 2026-08-03', NOW],
+ ['February', new Date(2028, 0, 15, 9, 0, 0)],
+ ['3 days starting monday', NOW],
+ ]
+
+ it.each(cases)('%s', (text, now) => {
+ const first = parseDateRange(text, { now })
+ expect(first.ok).toBe(true)
+ if (!first.ok) {
+ return
+ }
+ const again = parseDateRange(humanizeDateRange(first), { now })
+ expect(again.ok).toBe(true)
+ if (!again.ok) {
+ return
+ }
+ expect([again.start?.date.getTime(), again.end?.date.getTime()]).toEqual([
+ first.start?.date.getTime(),
+ first.end?.date.getTime(),
+ ])
+ })
+})
diff --git a/packages/lingo/src/date/range.ts b/packages/lingo/src/date/range.ts
index 631bb10..1a62039 100644
--- a/packages/lingo/src/date/range.ts
+++ b/packages/lingo/src/date/range.ts
@@ -70,14 +70,22 @@ function isNamedTime(p: P, text: string): boolean {
return aliases[text.toLowerCase()] !== undefined
}
-export const RANGE_SPLITS: { re: RegExp; open?: 'start' | 'end' }[] = [
- { re: /^between\s+(.+?)\s+and\s+(.+)$/i },
- { re: /^from\s+(.+?)\s+(?:to|till|til|until|through|thru|[-–—])\s+(.+)$/i },
- { re: /^(.+?)\s+(?:to|till|til|until|through|thru)\s+(.+)$/i },
- { re: /^(.+?)\s*[-–—]\s*(.+)$/i },
- { re: /^from\s+(.+?)(?:\s+onwards?)?$/i, open: 'end' },
- { re: /^(.+?)\s+onwards?$/i, open: 'end' },
- { re: /^(?:until|till|til|before|by)\s+(.+)$/i, open: 'start' },
+/**
+ * Whole-string splitters, tried in order. The `d` flag is load-bearing: the
+ * date-endpoint pass needs exact group offsets to slice `p.text`, because a
+ * leading frame word ("between ", "from ") means the left group does not start
+ * at offset 0.
+ */
+export const RANGE_SPLITS: { dash?: true; open?: 'start' | 'end'; re: RegExp }[] = [
+ { re: /^between\s+(.+?)\s+and\s+(.+)$/di },
+ { re: /^from\s+(.+?)\s+(?:to|till|til|until|through|thru|[-–—])\s+(.+)$/di },
+ { re: /^(.+?)\s+(?:to|till|til|until|through|thru)\s+(.+)$/di },
+ // Lazy, so it splits at the FIRST dash. Harmless for clocks ("9-5"), but an
+ // ISO date is full of dashes — the date pass skips this via `dash`.
+ { re: /^(.+?)\s*[-–—]\s*(.+)$/di, dash: true },
+ { re: /^from\s+(.+?)(?:\s+onwards?)?$/di, open: 'end' },
+ { re: /^(.+?)\s+onwards?$/di, open: 'end' },
+ { re: /^(?:until|till|til|before|by)\s+(.+)$/di, open: 'start' },
]
export function endpointDate(p: P, ep: Endpoint, baseDay: Date): DateRangeEndpoint {
diff --git a/packages/lingo/src/date/relative.ts b/packages/lingo/src/date/relative.ts
index 3952aae..75cc0d5 100644
--- a/packages/lingo/src/date/relative.ts
+++ b/packages/lingo/src/date/relative.ts
@@ -453,15 +453,19 @@ function parseCalendarPeriod(p: P, start: number, end: number): CoreDate | null
return monthPeriodCore(p, mod, month, start, end)
}
}
- if (source === 'this weekend') {
+ // The weekend is named by its Saturday, at day grain — `parseDateRange` widens
+ // it through Sunday. Bare "weekend" reads as "this weekend".
+ const weekend = /^(?:(this|next|last)\s+)?weekend$/.exec(source)
+ if (weekend) {
const today = startOfDay(p.now)
- return core(
- addCalendar(today, { days: forwardDiff(today.getDay(), 6) }),
- 'day',
- knownFor('day'),
- start,
- end,
- )
+ const weekday = today.getDay()
+ // Sunday still belongs to the weekend that began the day before. Rounding
+ // forward would put "this weekend" a week out and leave the weekend the
+ // reader is standing in reachable only as "last weekend".
+ const toSaturday = weekday === 0 ? -1 : forwardDiff(weekday, 6)
+ const saturday = addCalendar(today, { days: toSaturday })
+ const shift = weekend[1] === 'next' ? 7 : weekend[1] === 'last' ? -7 : 0
+ return core(addCalendar(saturday, { days: shift }), 'day', knownFor('day'), start, end)
}
const edge =
/^(?:the\s+)?(beginning|start|end|middle|mid)(?:\s+of)?(?:\s+the)?(?:\s+(this|next|last))?\s+(.+)$/.exec(
diff --git a/packages/lingo/tests/corpus/contract-v1.json b/packages/lingo/tests/corpus/contract-v1.json
index c516420..240258c 100644
--- a/packages/lingo/tests/corpus/contract-v1.json
+++ b/packages/lingo/tests/corpus/contract-v1.json
@@ -5812,6 +5812,46 @@
},
"confidence": 1
},
+ "next weekend": {
+ "input": "next weekend",
+ "type": "date",
+ "kind": "date",
+ "base": {
+ "year": 2026,
+ "month": 7,
+ "day": 11,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "unit": "day",
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "last weekend": {
+ "input": "last weekend",
+ "type": "date",
+ "kind": "date",
+ "base": {
+ "year": 2026,
+ "month": 6,
+ "day": 27,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "unit": "day",
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
"beginning of the week": {
"input": "beginning of the week",
"type": "date",
@@ -7346,6 +7386,1062 @@
"end": 14
},
"confidence": 0.9
+ },
+ "July 1 to July 5 [now:2026,6,3,9,0,0]": {
+ "input": "July 1 to July 5",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2027,
+ "month": 7,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2027,
+ "month": 7,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 16
+ },
+ "confidence": 1
+ },
+ "Aug 3 - Aug 9 [now:2026,6,3,9,0,0]": {
+ "input": "Aug 3 - Aug 9",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 3,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 9,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 13
+ },
+ "confidence": 1
+ },
+ "from tomorrow to friday [now:2026,6,3,9,0,0]": {
+ "input": "from tomorrow to friday",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 4,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 10,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [
+ "WEEKDAY_ASSUMED_NEXT"
+ ],
+ "span": {
+ "start": 0,
+ "end": 23
+ },
+ "confidence": 0.9
+ },
+ "2026-08-01 to 2026-08-05 [now:2026,6,3,9,0,0]": {
+ "input": "2026-08-01 to 2026-08-05",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 24
+ },
+ "confidence": 1
+ },
+ "2026-08-01 - 2026-08-05 [now:2026,6,3,9,0,0]": {
+ "input": "2026-08-01 - 2026-08-05",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 23
+ },
+ "confidence": 1
+ },
+ "Mon-Fri [now:2026,6,3,9,0,0]": {
+ "input": "Mon-Fri",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 6,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 10,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [
+ "WEEKDAY_ASSUMED_NEXT",
+ "WEEKDAY_ASSUMED_NEXT"
+ ],
+ "span": {
+ "start": 0,
+ "end": 7
+ },
+ "confidence": 0.8
+ },
+ "from monday [now:2026,6,3,9,0,0]": {
+ "input": "from monday",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 6,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": null
+ },
+ "unit": {
+ "start": "day",
+ "end": null
+ },
+ "issues": [
+ "WEEKDAY_ASSUMED_NEXT"
+ ],
+ "span": {
+ "start": 0,
+ "end": 11
+ },
+ "confidence": 0.9
+ },
+ "until august 9 [now:2026,6,3,9,0,0]": {
+ "input": "until august 9",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": null,
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 9,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": null,
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 14
+ },
+ "confidence": 1
+ },
+ "next week [now:2026,6,3,9,0,0]": {
+ "input": "next week",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 6,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 12,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "week",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 9
+ },
+ "confidence": 1
+ },
+ "this month [now:2026,6,3,9,0,0]": {
+ "input": "this month",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "month",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 10
+ },
+ "confidence": 1
+ },
+ "next month [now:2026,6,3,9,0,0]": {
+ "input": "next month",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "month",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 10
+ },
+ "confidence": 1
+ },
+ "this year [now:2026,6,3,9,0,0]": {
+ "input": "this year",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 1,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 12,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "year",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 9
+ },
+ "confidence": 1
+ },
+ "August [now:2026,6,3,9,0,0]": {
+ "input": "August",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "month",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 6
+ },
+ "confidence": 1
+ },
+ "this weekend [now:2026,6,3,9,0,0]": {
+ "input": "this weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 4,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "next weekend [now:2026,6,3,9,0,0]": {
+ "input": "next weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 11,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 12,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "July 1 3pm to July 2 5pm [now:2026,6,3,9,0,0]": {
+ "input": "July 1 3pm to July 2 5pm",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2027,
+ "month": 7,
+ "day": 1,
+ "hour": 15,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2027,
+ "month": 7,
+ "day": 2,
+ "hour": 17,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "hour",
+ "end": "hour"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 24
+ },
+ "confidence": 1
+ },
+ "July to August [now:2026,6,3,9,0,0]": {
+ "input": "July to August",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2027,
+ "month": 7,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2027,
+ "month": 8,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "month",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 14
+ },
+ "confidence": 1
+ },
+ "until August [now:2026,6,3,9,0,0]": {
+ "input": "until August",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": null,
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": null,
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "from August [now:2026,6,3,9,0,0]": {
+ "input": "from August",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": null
+ },
+ "unit": {
+ "start": "month",
+ "end": null
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 11
+ },
+ "confidence": 1
+ },
+ "2026 to 2027 [now:2026,6,3,9,0,0]": {
+ "input": "2026 to 2027",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 1,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2027,
+ "month": 12,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "year",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "this weekend to next weekend [now:2026,6,3,9,0,0]": {
+ "input": "this weekend to next weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 4,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 12,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 28
+ },
+ "confidence": 1
+ },
+ "2026-08-09 to 2026-08-03 [now:2026,6,3,9,0,0]": {
+ "input": "2026-08-09 to 2026-08-03",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 3,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 9,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [
+ "RANGE_REVERSED"
+ ],
+ "span": {
+ "start": 0,
+ "end": 24
+ },
+ "confidence": 1
+ },
+ "this weekend [now:2026,5,28,9,0,0]": {
+ "input": "this weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 5,
+ 28,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 6,
+ "day": 27,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 6,
+ "day": 28,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "next weekend [now:2026,5,28,9,0,0]": {
+ "input": "next weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 5,
+ 28,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 4,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "last weekend [now:2026,5,28,9,0,0]": {
+ "input": "last weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 5,
+ 28,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 6,
+ "day": 20,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 6,
+ "day": 21,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
}
}
}
diff --git a/packages/lingo/tests/corpus/source.mjs b/packages/lingo/tests/corpus/source.mjs
index eeff959..09758cf 100644
--- a/packages/lingo/tests/corpus/source.mjs
+++ b/packages/lingo/tests/corpus/source.mjs
@@ -385,6 +385,8 @@ export const dateRows = [
['next year'],
['last year'],
['this weekend'],
+ ['next weekend'],
+ ['last weekend'],
['beginning of the week'],
['start of next month'],
['end of week'],
@@ -445,6 +447,8 @@ export const dateRows = [
// Time slots (plan 030). A morning `now` keeps afternoon slots on the same
// civil day; civil endpoints read back host-independently (local wall-clock).
const MORNING = [2026, 6, 3, 9, 0, 0]
+/** A Sunday, so weekend phrasing is pinned on the day that used to read wrong. */
+const SUNDAY = [2026, 5, 28, 9, 0, 0]
export const dateRangeRows = [
['9-5', { now: MORNING }],
@@ -460,6 +464,39 @@ export const dateRangeRows = [
// A trailing zone applies to the whole slot; civil endpoints are kept (no
// applyZone) so the row is host-independent, and both TZ issues ride along.
['9am to 5pm EST', { now: MORNING }],
+ // Calendar ranges. Date endpoints reuse the same separators as clock slots;
+ // the end anchors to the start, so "July 1 to July 5" cannot read backwards.
+ ['July 1 to July 5', { now: MORNING }],
+ ['Aug 3 - Aug 9', { now: MORNING }],
+ ['from tomorrow to friday', { now: MORNING }],
+ ['2026-08-01 to 2026-08-05', { now: MORNING }],
+ ['2026-08-01 - 2026-08-05', { now: MORNING }],
+ ['Mon-Fri', { now: MORNING }],
+ ['from monday', { now: MORNING }],
+ ['until august 9', { now: MORNING }],
+ // A date coarser than a day names a period, so the range spans it.
+ ['next week', { now: MORNING }],
+ ['this month', { now: MORNING }],
+ ['next month', { now: MORNING }],
+ ['this year', { now: MORNING }],
+ ['August', { now: MORNING }],
+ ['this weekend', { now: MORNING }],
+ ['next weekend', { now: MORNING }],
+ ['July 1 3pm to July 2 5pm', { now: MORNING }],
+ // A coarse endpoint widens on the closing side too, so these end on the last
+ // day of the period rather than its first (D72).
+ ['July to August', { now: MORNING }],
+ ['until August', { now: MORNING }],
+ ['from August', { now: MORNING }],
+ ['2026 to 2027', { now: MORNING }],
+ ['this weekend to next weekend', { now: MORNING }],
+ // Absolute endpoints given backwards swap and warn; an overnight clock slot
+ // is left alone, which is why 10pm-to-2am sits above without an issue.
+ ['2026-08-09 to 2026-08-03', { now: MORNING }],
+ // Sunday is the reference that breaks a naive round-forward-to-Saturday.
+ ['this weekend', { now: SUNDAY }],
+ ['next weekend', { now: SUNDAY }],
+ ['last weekend', { now: SUNDAY }],
]
export function buildContract({ lingo, parseDate, parseDateRange }) {
diff --git a/plans/005-dates-and-durations.md b/plans/005-dates-and-durations.md
index 2e7699d..4feaf7f 100644
--- a/plans/005-dates-and-durations.md
+++ b/plans/005-dates-and-durations.md
@@ -3,7 +3,7 @@ id: 005
title: Dates & durations
status: approved
created: 2026-07-03
-updated: 2026-07-08
+updated: 2026-07-29
---
# Dates & durations (`@pascal-app/lingo/date`)
@@ -60,8 +60,9 @@ wall-clock time).
interpretation (soonest occurrence) attached with its date; `last ` mirror.
- `next week/month/year` → grain-sized result (Monday of next week etc.);
`next ` / `last ` → the named month strictly after/before
- the current month period; `this weekend` → next Saturday (grain day), `end of
- (the) month/week/year`, `beginning/start of …`, `mid-` → 15th.
+ the current month period; `weekend` / `this|next|last weekend` → that week's
+ Saturday (grain day), `end of (the) month/week/year`, `beginning/start of …`,
+ `mid-` → 15th.
**Absolute**: ISO `2026-07-03`, `2026-07-03T14:30(:ss)`, compact `20260703` NOT
supported (ambiguity with big ints). `7/3/2026`, `7/3` — slash/dot/dash numeric
@@ -89,9 +90,39 @@ French `midi demain`, `le mois prochain`, `dans 3 jours`; Spanish `mañana`
Spanish ambiguity policy: bare `mañana` is tomorrow, while morning requires a
prepositional frame, so `mañana por la mañana` is tomorrow morning.
-**Date ranges**: `parseDateRange` covers time slots per plan 030 plus duration
-ranges anchored by a date/time: `N starting ` (including glued
-duration units like `3days starting tomorrow`) → `[anchor, anchor + duration)`.
+**Date ranges**: `parseDateRange` covers time slots per plan 030, duration
+ranges anchored by a date/time (`N starting `, including glued
+duration units like `3days starting tomorrow`) → `[anchor, anchor + duration)`,
+and calendar ranges per D71:
+
+- **Date to date** — the same separators as time slots, with date endpoints:
+ `July 1 to July 5`, `Aug 3 - Aug 9`, `between Aug 3 and Aug 9`,
+ `from tomorrow to friday`, `Mon-Fri`, `2026-08-01 to 2026-08-05`, plus open
+ ends (`from monday`, `until august 9`). The clock pass runs first, so `2pm to
+ 4pm` stays a time slot. The end parses against the *start* as its reference,
+ never `now`, so a relative pair cannot read backwards. Two absolute endpoints
+ can still be given in the wrong order — `2026-08-09 to 2026-08-03` is swapped
+ and reported with `RANGE_REVERSED`, per D72. Only the dated path swaps; `9pm
+ to 5am` is a real overnight slot. A `\d{4}-\d{2}` run in the input disables
+ the lazy dash split and demands a spaced dash — `2026-08-01 - 2026-08-05`
+ parses, `2026-08-01-2026-08-05` does not.
+- **Calendar periods** — any date coarser than a day names a period, and the
+ range spans it: `next week` → Mon–Sun, `this month`/`next month` → 1st–last,
+ `this year`/`2027` → Jan 1–Dec 31, `August`/`next August` → the whole month.
+ No separate period-range grammar; it widens the single-date result. The same
+ widening applies to a coarse endpoint that *closes* a span, so `July to
+ August` ends August 31 and `until August` ends August 31, while `from August`
+ opens on the 1st (D72).
+- **Weekends** — `weekend`, `this weekend`, `next weekend`, `last weekend` →
+ Saturday through Sunday. Day-grained, so widened explicitly. On a Saturday or
+ Sunday, `this weekend` is the weekend in progress rather than the next one.
+- **Not covered** — elliptical right sides (`Aug 3–9`) and quarters (`Q3`); see
+ `plans/backlog.md`.
+
+`humanizeDateRange` renders calendar ranges as dates (`2026-07-01 to
+2026-07-05`, `from 2026-07-06`, `until 2026-08-09`) rather than clock phrases,
+carrying the time only when an endpoint is hour-grained or finer. Provenance
+rides on a runtime-only `dated` flag, like `anchored`.
**Durations**: `90 min`, `1h30`, `1:30` (with kind duration: h:mm; warns of mm:ss
alternative), `1 h 30 min`, `an hour and a half`, `2 hours 15 minutes`, `three quarters
diff --git a/plans/012-agent-and-llm-friendliness.md b/plans/012-agent-and-llm-friendliness.md
index 7c782b5..00b3ded 100644
--- a/plans/012-agent-and-llm-friendliness.md
+++ b/plans/012-agent-and-llm-friendliness.md
@@ -3,7 +3,7 @@ id: 012
title: Agent & LLM friendliness
status: done
created: 2026-07-03
-updated: 2026-07-21
+updated: 2026-07-23
---
# Agent & LLM friendliness
diff --git a/plans/029-schema-reference-and-adapters.md b/plans/029-schema-reference-and-adapters.md
index a11664f..ece9c17 100644
--- a/plans/029-schema-reference-and-adapters.md
+++ b/plans/029-schema-reference-and-adapters.md
@@ -40,7 +40,7 @@ on `type` over `quantity | range | conversion | failure`, plus reusable
definitions: `Span` (`{start,end,text}`), `Issue` (`{code,severity,message,span,
suggestions?,data?}`), `QuantityValue`, `RangeValue`, and enums `Kind`,
`UnitSystem` (`metric|us|imperial|shared`), `Severity` (`error|warning|info`),
-`IssueCode` (31 codes). Exported as a plain object `lingoJsonSchema` and a
+`IssueCode` (33 codes). Exported as a plain object `lingoJsonSchema` and a
`resultJsonSchema()`/`valueJsonSchema()` accessor. Each property carries a
`description` and, where bounded, an `enum`.
diff --git a/plans/031-locale-packs.md b/plans/031-locale-packs.md
index f403d92..2fb561a 100644
--- a/plans/031-locale-packs.md
+++ b/plans/031-locale-packs.md
@@ -3,7 +3,7 @@ id: 031
title: Locale packs
status: in-progress
created: 2026-07-08
-updated: 2026-07-08
+updated: 2026-07-27
goal: "Ship Phase 0 multi-language infrastructure: loaded locale packs, resolved language profiles, and parser plumbing with zero English corpus drift."
success_criteria:
- "English parsing is unchanged -> packages/lingo/src/parse/corpus.test.ts and corpus-diff gate"
diff --git a/plans/032-input-calculations.md b/plans/032-input-calculations.md
index d6dd8c4..86cfc93 100644
--- a/plans/032-input-calculations.md
+++ b/plans/032-input-calculations.md
@@ -3,94 +3,286 @@ id: 032
title: Input calculations (quantity arithmetic)
status: draft
created: 2026-07-08
-updated: 2026-07-08
-goal: "Decide whether (and how) lingo evaluates arithmetic typed into fields — '2+3 kg', '10% off 50', '2 * 1h30' — without becoming a CAS or busting budgets."
+updated: 2026-07-29
+goal: "Decide whether (and how) lingo evaluates arithmetic typed into fields — '=2+3 kg', '10% off $50', '12 * 0.75 kg' — without becoming a CAS or busting budgets, and close the operator/range collision that already ships."
success_criteria:
- "Go/no-go decision recorded as a D-entry -> wiki/decisions.md"
- - "If go: expression grammar spec'd here with locked node union and corpus rows -> this plan + tests/corpus"
+ - "Bare-mode `2+3 kg` no longer silently returns a range -> packages/lingo/tests/corpus + parse tests"
+ - "If go: node union and operator table locked here, corpus rows added -> this plan + tests/corpus"
+ - "If go: `./calc` marginal budget assigned and green -> packages/lingo/scripts/size.mjs"
---
# Input calculations (quantity arithmetic)
Driver: owner request 2026-07-08 — users type light math into quantity fields
("2+3 kg", "half of 10 L", "10% off $50") and expect the field (and
-autocomplete) to resolve it. Prior art to evaluate:
-[mathjs expression trees](https://mathjs.org/docs/expressions/expression_trees.html).
+autocomplete) to resolve it. Re-scoped 2026-07-29 after a prior-art pass over
+[mathjs expressions](https://mathjs.org/docs/expressions/index.html) plus a
+probe of what the current parser actually does with arithmetic. Two things
+changed: the collision this plan worried about is already producing wrong
+answers, and the strongest argument for the feature turned out to be the LLM
+boundary, not the form field.
## Design principle
-**Calculator, not CAS.** Evaluate a closed, tiny arithmetic surface over
-already-parsed same-kind quantities. No symbols, no variables, no dimensional
-algebra (D2 stands; kg·m/s² stays out of scope), no assignment, no functions.
-If an expression needs a scope object, it's out of scope.
+**Calculator, not CAS — and the model shows its work, lingo does the math.**
-## Prior-art evaluation: mathjs expression trees (studied 2026-07-08)
+Evaluate a closed, tiny arithmetic surface over already-parsed same-kind
+quantities. No symbols, no variables, no dimensional algebra (D2 stands;
+kg·m/s² is out of scope), no assignment, no functions. If an expression needs a
+scope object, it's out of scope.
-What mathjs does: `math.parse(expr)` → typed node AST (`ConstantNode`,
-`OperatorNode`, `SymbolNode`, `FunctionNode`, …), each node supporting
-`evaluate(scope)`, `transform`, `traverse`, `toString`. Powerful and general —
-and exactly the shape we must NOT ship wholesale:
+The second half is the reason the feature is worth its bytes. An LLM asked for
+`weight_kg: number` must do the multiplication and the unit conversion in its
+head, and nothing downstream can tell a correct `0.907` from a hallucinated
+one — the failure mode plan 019's research pass documented as `value_error`.
+A field that accepts `"12 * 0.75 kg"` and evaluates it deterministically moves
+the arithmetic out of the model and into the library. Models are unreliable at
+computing and reliable at *setting up* a computation; this exploits exactly
+that split.
-- **Take:** the small typed node union as the internal representation
- (`value | quantity | op(+,-,*,/) | percent-of | paren`), the
+## What ships today (probed 2026-07-29)
+
+Not hypothetical future risk — current `lingo()` behavior on inputs users type:
+
+| Input | Today | Verdict |
+|---|---|---|
+| `2+3 kg` | range 2–3 kg, `confidence: 1`, **zero issues** | Silent wrong answer |
+| `2+4 kg` | `TRAILING_INPUT` | Inconsistent with the row above |
+| `5 kg - 2 kg` | range 2–5 kg + `RANGE_REVERSED` | Wrong, but warned |
+| `20°C + 5°C` | 25 °C, zero issues | Right math, unstated semantics |
+| `0°C + 100°F` | 55.56 °C, zero issues | Right math, unstated semantics |
+
+`2+3 kg` is the one that matters. It contradicts D4 more directly than anything
+else in the parser: a deterministic wrong reading at full confidence with no
+alternative and no warning.
+
+The cause is `tryAdjacentCjkRange` (`parse/range.ts`), the path behind D68's
+CJK adjacent-number implicit ranges (`七八天` = 7–8 days). Despite the name,
+nothing in it is CJK-specific: it checks only that the next token has no
+preceding space and that a re-parse from that token yields exactly `a + 1`. For
+`2+3 kg` the `+` is consumed as a compound-plus sign, the re-parse returns 3,
+and 3 === 2+1, so a range is built. That's why `2+3` and `3+4` become ranges
+while `2+4` and `9+10` fail — the guard is `value >= 1 && value < 9`, not
+anything about script. D68's own rule ("三三 stays rejected") was only ever
+meant to fire on CJK juxtaposition.
+
+The signal it should be consulting already exists one layer down:
+`parseCjkNumberText` (`number/cjk.ts`) returns an explicit `adjacentRange: true`
+flag on genuine CJK juxtaposition. The fix is to gate the range path on that
+flag instead of on a script-blind re-parse.
+
+**This gets fixed regardless of the go/no-go below.** Bare mode does no
+arithmetic, uniformly: `2+3 kg` joins `2+4 kg` as `TRAILING_INPUT`. Corpus drift
+here is a previously-`ok` row turning into a failure, so `corpus-diff.mjs` will
+class it BREAKING — it needs an explicit owner acknowledgement in the same
+change rather than a reclassification.
+
+## Prior-art evaluation
+
+### mathjs expression trees (studied 2026-07-08, revisited 2026-07-29)
+
+- **Take:** the small typed node union as the internal representation, the
parse-then-evaluate split (grammar produces a tree; evaluation is a separate
pure fold), and `toString` discipline (canonical emission that re-parses —
- maps to our two-way guarantee).
+ our hard rule 4). Also its **percentage operators**: `100 + 3%` → `103` is a
+ dedicated operator distinct from modulus, and that family is the highest-value
+ slice for forms (see phase 1).
- **Reject:** symbols/scopes, function nodes, matrices/objects/ranges as
- expression citizens, `compile()` codegen, implicit multiplication. mathjs is
- ~500 kB+; our entire full bundle budget is 33 kB. Zero-deps rule means this
- is a study, never a dependency.
-- **Hazards mathjs itself documents:** temperature arithmetic footguns
- (°C/°F sums are wrong under affine units — we already split
- `convert`/`convertDelta`; expression eval must refuse or delta-convert
- affine-unit arithmetic), case traps (`C`=coulomb), unit-symbol collisions
- with operator tokens (`in` reads as inches, `-` is both minus and a range
- separator).
-
-## Candidate scope (not locked)
-
-| Expression | Result | Notes |
+ expression citizens, `compile()` codegen, implicit multiplication (`2 pi`),
+ BigNumber/Fraction numeric types, symbolic simplify/derivative, physical
+ constants. mathjs is ~500 kB+; the zero-deps rule (D1) means this is a study,
+ never a dependency.
+- **Security is a design input, not an afterthought.** mathjs ships a whole
+ security page because it evaluates arbitrary code. At an LLM tool boundary
+ that is an attack surface. A closed node union has none — the grammar cannot
+ express a side effect, a property access, or a call. That guarantee is worth
+ stating in the docs, not just holding internally.
+- **Hazards mathjs documents:** case traps (`C` = coulomb — we already emit
+ `AMBIGUOUS_UNIT`), unit symbols colliding with operator tokens (`in` reads as
+ inches, `-` is also a range separator), and affine-unit arithmetic, on which
+ its own advice is "avoid calculations using celsius and fahrenheit."
+
+### The `=` escape hatch (Excel / Sheets / Notion / Airtable)
+
+The most-used form software on earth resolves exactly our `-`-means-range
+collision with a leading `=` mode switch. Soulver, Numi, Raycast and Spotlight
+do the same thing with a dedicated surface. `=2+3 kg` currently fails with
+`NO_VALUE`, so the prefix is free.
+
+### Pint (Python) `delta_degC`
+
+Prior art for making affine-delta semantics explicit in the type rather than
+warning users off the operation. We already have the `convert`/`convertDelta`
+split (from js-quantities, per `wiki/inspiration.md`); the compound path just
+never said which one it was using.
+
+## Design (proposed — not locked; gated on the go/no-go)
+
+### The compound/arithmetic discriminator
+
+The rule that keeps this additive:
+
+- **Both operands carry units** → compound accumulation. Existing behavior,
+ unchanged: `2 ft + 3 in`, `2 kg + 500 g`, `2 m minus 10 cm`.
+- **A bare operand, or any non-additive operator** → arithmetic, which lives in
+ `./calc` and never in `lingo()`.
+
+So `lingo('2+3 kg')` fails and `calc('2+3 kg')` gives 5 kg, and no input changes
+meaning based on which entries a consumer imported.
+
+### `=` is a field-level mode switch, not grammar
+
+`calc()` accepts an expression with or without the prefix. The prefix matters
+only where one text box feeds both parsers — `completions()` and the DOM
+controller — so that bare input keeps today's range-first semantics with zero
+corpus churn:
+
+```ts
+interface CalcOptions extends LingoOptions {
+ /** '=' (default): only treat input as an expression when prefixed. */
+ trigger?: '=' | 'always'
+}
+
+function calc(input: string, opts?: CalcOptions): CalcResult | CalcFail
+```
+
+### Node union and operators
+
+Closed union, five node types, no extension point:
+
+```ts
+type CalcNode =
+ | { type: 'number'; value: number; span: Span }
+ | { type: 'quantity'; value: Quantity; span: Span }
+ | { type: 'group'; node: CalcNode; span: Span }
+ | { type: 'percent'; of: CalcNode; percent: CalcNode; mode: 'of' | 'add' | 'off'; span: Span }
+ | { type: 'op'; op: '+' | '-' | '*' | '/'; left: CalcNode; right: CalcNode; span: Span }
+```
+
+Every node carries a span into the ORIGINAL input (hard rule 3), so issues point
+at the offending operand and a UI can highlight it.
+
+Operand rules, and the issue code when they're violated:
+
+| Form | Result | Rule |
|---|---|---|
-| `2+3 kg` | 5 kg | bare left side inherits unit (matches range policy) |
-| `2 kg + 500 g` | 2.5 kg | same-kind, unit-mixed sum |
-| `2 * 1h30` | 3 h | scalar × quantity |
-| `10 L / 4` | 2.5 L | quantity ÷ scalar |
-| `10% off $50` | $45.00 | percent-of family, incl. `off` / `of` |
-| `half of 10 L` | 5 L | word multipliers reuse the number-word lexicon |
-| `5 kg + 3 m` | KIND_MISMATCH | never cross-kind |
-| `20°C + 5°C` | refuse or delta (decide) | affine-unit hazard |
-
-Grammar collision to resolve first: `-` and `to` already mean *range*
-(`5-10 kg`), and `x` could mean multiplication or a typo. Proposal: ranges win
-every ambiguous read; arithmetic requires an unambiguous operator context
-(`+`, `*`, `/`, `% off`) — subtraction may need to be excluded or
-parenthesized-only, which is fine for form inputs.
-
-## Where it would land
-
-- New entry (`@pascal-app/lingo/calc` or folded into `./complete` fan-out as a
- `'calc'` completion source) — NOT the main entry; main budget stays flat.
-- Autocomplete integration: `completions('2+3 kg')` → `5 kg` ranked first with
- the expression tree attached for UI explanation.
-- Spans: every node carries `[start, end)` offsets like every other result
- (hard rule 3); issues point at the offending operand.
+| `q + q`, `q - q` | quantity | Same kind only, else `EXPRESSION_KIND_MISMATCH` |
+| `n + q`, `q + n` | quantity | Bare operand inherits the other side's unit |
+| `q * n`, `n * q` | quantity | Exactly one operand may be a quantity, else `SCALAR_EXPECTED` |
+| `q / n` | quantity | Divisor must be scalar |
+| `q / q` | number | Same-kind division cancels to a dimensionless ratio (phase 3) |
+| `q * q` | rejected | `SCALAR_EXPECTED` — this is dimensional algebra (D2) |
+| `x / 0` | rejected | `DIVISION_BY_ZERO` |
+
+`q * q` being refused is load-bearing: it's the line that keeps this a
+calculator instead of the start of a unit algebra.
+
+### Affine arithmetic — resolved, not open
+
+The plan previously listed "refuse or delta-convert" as an open question. The
+probe answers it: **lingo already delta-converts**, and correctly.
+`0°C + 100°F` → 55.56 °C is 100 Fahrenheit-*degrees* of rise, not 100 °F
+converted absolutely. That's the right reading and better than mathjs, which
+tells you not to try.
+
+The defect is silence, not math. Additive arithmetic on an affine unit emits a
+new warning: `AFFINE_DELTA_ASSUMED` (past-tense, per the applied-forgiveness
+naming convention), carrying the operand span and the delta reading in `data`.
+This applies to the **existing compound path too**, so it lands even if the
+go/no-go comes back no.
+
+### Phasing
+
+1. **Percent-of family.** `10% off $50`, `$60 + 20% tip`, `15% of 60 kg`,
+ `$100 + 8.875% tax`. No collision with ranges at all — `%` plus `of`/`off`/
+ `on` are unambiguous markers — and it's the arithmetic people actually type
+ into money forms. Shippable without the general expression grammar.
+2. **Operators + grouping.** `+ - * /`, parentheses, precedence.
+3. **Ratios and word multipliers.** `q / q` → number; `half of 10 L`, `twice
+ 3 kg`, `double`, reusing the existing number-word lexicon.
+
+### Where it lands
+
+- New entry `@pascal-app/lingo/calc`. The main entry does not grow — no import,
+ no re-export, budget stays flat. `./calc` gets its own marginal budget line in
+ `size.mjs` at implementation time.
+- `./complete` gains a `'calc'` `CompletionSource`, fed by an **injected**
+ evaluator exactly like plan 031 injects `date` — `./complete` never imports
+ `./calc` at runtime. Evaluated results surface as a ranked completion
+ (`= 45 USD`) with the tree attached for the UI to explain, rather than
+ silently committing into the field.
+- `./ai`: `quantityField` and `rangeField` accept an injected `calc` evaluator
+ under the same rule, so `./ai`'s budget doesn't move either:
+
+```ts
+import { calc } from '@pascal-app/lingo/calc'
+
+quantityField({ unit: 'kg', calc }) // accepts "12 * 0.75 kg" -> 9
+```
+
+ When `calc` is injected, the emitted JSON Schema `description` must tell the
+ model it may submit an expression — a capability the model can't use if it
+ doesn't know it has it. `CalcResult` carries the evaluated tree so a tool can
+ log or display the work; the field itself still returns a plain number
+ (or `QuantityJSON` under `output: 'quantity'`), so the wire shape at the tool
+ boundary is unchanged.
+
+### Vocabulary
+
+`expression`, `node`, and `calc` are new nouns. If this ships, `CONTEXT.md`
+gains entries for them in the same change, with the *Avoid* list naming
+"formula" and "AST", and an explicit note that a calc `node`'s `span` is a span
+(the range/span collision `CONTEXT.md` already flags as the one that bites).
+
+## Changes
+
+1. `packages/lingo/src/parse/range.ts` — gate `tryAdjacentCjkRange` on the
+ `adjacentRange` flag already returned by `parseCjkNumberText`
+ (`number/cjk.ts`) instead of a script-blind `a + 1` re-parse (the `2+3 kg`
+ fix). Keep D68's `七八天` corpus rows green.
+2. `packages/lingo/src/parse/quantity.ts` — emit `AFFINE_DELTA_ASSUMED` on
+ additive compounds over affine units.
+3. `packages/lingo/src/core/types.ts` — new issue codes (add-only, per
+ conventions): `AFFINE_DELTA_ASSUMED`, `EXPRESSION_KIND_MISMATCH`,
+ `SCALAR_EXPECTED`, `DIVISION_BY_ZERO`, each with a typed `IssueDataMap` entry.
+4. `packages/lingo/src/calc/` — grammar (tree), evaluator (pure fold),
+ `toString` canonical emission.
+5. `packages/lingo/package.json` + `tsup.config.ts` — `./calc` entry.
+6. `packages/lingo/src/complete/` — `'calc'` completion source, injected.
+7. `packages/lingo/src/ai/` — injected `calc` option + schema description.
+8. `packages/lingo/scripts/size.mjs` — `./calc` marginal budget.
+9. Tests (incl. two-way for every emitted result), corpus rows, `ai-eval.mjs`
+ category for expression-valued tool arguments, CHANGELOG, README, llms.txt,
+ `wiki/inspiration.md`, `CONTEXT.md`.
## Non-goals
-- Dimensional algebra / compound-dimension arithmetic (D2, vision plan).
-- Variables, scopes, functions, matrices, multi-statement blocks.
+- Dimensional algebra / compound-dimension arithmetic (D2, plan 000).
+- Variables, scopes, assignment, functions, matrices, multi-statement blocks,
+ constants, trig/log/statistics, arbitrary-precision numerics.
- Shipping or depending on mathjs.
+- Changing any bare-input reading other than the `2+3 kg` bug fix.
+- Rates with non-unit denominators (`£45 per night`, `50 kg per person`) — a
+ bigger form win, but a wire-schema change and a separate plan. Parked in
+ `backlog.md`.
## Open questions
-- Go/no-go at all — is a calculator inside a form field a feature or a trap?
- Needs owner call + a D-entry either way.
-- Affine-unit arithmetic policy (refuse vs delta-convert).
-- Subtraction vs range `-`: excluded, parenthesized-only, or space-sensitive.
-- Entry placement and budget (new `./calc` vs `./complete` growth).
+- **Go/no-go on phases 2–3.** Is a general calculator inside a form field a
+ feature or a trap? Owner call + a D-entry either way. Phase 1 (percent-of) and
+ the two defect fixes stand on their own and could land first.
+- **Corpus classification for the `2+3 kg` fix.** BREAKING by the script's
+ definition; needs owner acknowledgement, not a reclassification.
+- **Does `q / q` → number earn its bytes?** Useful ("how many 2 L bottles in
+ 10 L"), but it's the only rule that changes result *type*, which complicates
+ the `./ai` field contract.
## Acceptance
-Decision D-entry exists. If go: grammar locked here, corpus rows added
-(ADDITIVE), size budget assigned in `size.mjs`, two-way tests for every
-emitted result.
+Decision D-entry exists. The `2+3 kg` and `AFFINE_DELTA_ASSUMED` fixes ship with
+corpus coverage regardless of that decision. If go: node union and operator
+table locked here, corpus rows added, `./calc` budget assigned in `size.mjs` and
+green, two-way tests for every emitted result, and an `ai-eval.mjs` category
+showing expression-valued arguments beat naive number-valued ones on silent-wrong
+rate.
diff --git a/plans/README.md b/plans/README.md
index 9ced4b3..66bde1c 100644
--- a/plans/README.md
+++ b/plans/README.md
@@ -102,6 +102,7 @@ Driver: , unit conversion, and format/humanize round-trips. Zero-dependency TypeScript, deterministic.
metadata:
author: pascalorg
- version: "1.0.0"
+ version: "1.1.0"
---
# lingo — natural-language quantities, units, dates & ranges
@@ -56,7 +56,7 @@ Import only what you need; each subpath is tree-shakeable.
| Entry | Use for |
|-------|---------|
| `@pascal-app/lingo` | core parse/convert: `lingo`, `parseQuantity`, `parseRange`, `convert` |
-| `@pascal-app/lingo/date` | dates & durations: `parseDate`, `parseDuration`, `humanizeDate` |
+| `@pascal-app/lingo/date` | dates, ranges & durations: `parseDate`, `parseDateRange`, `parseDuration`, `humanizeDate` |
| `@pascal-app/lingo/ai` | Standard Schema fields for LLM tools: `quantityField`, `dateField`, `lingoObject` |
| `@pascal-app/lingo/mcp` | `lingoTool()` — MCP tool with validation before the handler |
| `@pascal-app/lingo/dom` | headless `lingoInput()` controller for any ` ` |
@@ -68,6 +68,7 @@ Import only what you need; each subpath is tree-shakeable.
| `@pascal-app/lingo/catalog` | query units/kinds/currencies |
| `@pascal-app/lingo/describe` | rich human/agent-readable views of a value or result |
| `@pascal-app/lingo/schema` | JSON Schema / OpenAPI generated from the v3 wire types |
+| `@pascal-app/lingo/core` | copy-free core — same parse/convert API with no bundled issue messages |
## Pattern 1 — parse, validate, convert (core)
@@ -142,6 +143,36 @@ MCP: wrap the same fields with `lingoTool({ name, description, input, handler })
from `@pascal-app/lingo/mcp`; validation runs before `handler`, and failures
return `{ isError: true }` with `[CODE]`-prefixed text.
+## Pattern 4 — one date field, three readings
+
+`parseDateRange` reads a **time slot**, a **dated span**, or a **whole calendar
+period**, so a single input can drive a slot picker, a day picker, or a
+two-month range picker with no mode toggle for the person typing:
+
+```ts
+import { parseDateRange } from "@pascal-app/lingo/date"
+
+parseDateRange("2pm to 4pm", { now }) // slot → 14:00–16:00, no dated flag
+parseDateRange("Aug 3 - Aug 9", { now }) // span → Aug 3 → Aug 9, dated: true
+parseDateRange("next week", { now }) // period→ Monday through Sunday
+parseDateRange("August", { now }) // period→ Aug 1 → Aug 31, not just the 1st
+parseDateRange("until August", { now }) // open start, ends Aug 31
+```
+
+Read `.dated` to know which grammar matched: `true` on date grammar, absent on
+clock grammar (runtime-only; never serialized — test it truthy, not `=== false`).
+Coarse endpoints widen on the *closing* side, so `July to August` ends Aug 31
+while `from August` opens on the 1st; `this weekend` on a Sat/Sun is the weekend
+in progress. Backwards absolute pairs (`2026-08-09 to 2026-08-03`) are swapped
+with `RANGE_REVERSED` rather than handed back inverted, and overnight slots
+(`9pm to 5am`) are left alone. `humanizeDateRange` round-trips both shapes.
+
+**Not in the grammar**, returning `UNSUPPORTED_DATE` instead of guessing:
+quarters (`Q3`, `next quarter`), elliptical right sides (`Aug 3-9`), and ISO
+dates dash-joined with no spaces (`2026-08-01-2026-08-05` — use a spaced dash
+or `to`). At an LLM boundary use `dateRangeField()`, which accepts all three
+shapes and returns `{ start?, end?: ISO }`.
+
## Rules that keep you out of trouble
1. **Keep measurements as strings in tool/form schemas.** Let lingo convert,
@@ -167,8 +198,10 @@ return `{ isError: true }` with `[CODE]`-prefixed text.
Common issue codes to handle: `UNKNOWN_UNIT`, `KIND_MISMATCH`, `UNIT_REQUIRED`,
`AMBIGUOUS_NUMBER`, `AMBIGUOUS_UNIT`, `TYPO_CORRECTED`, `RANGE_MIN`/`RANGE_MAX`,
-`NOW_REQUIRED`, `TZ_IGNORED`, `RATE_REQUIRED`. Override any message via the
-`messages` option.
+`RANGE_REVERSED`, `NOW_REQUIRED`, `TZ_IGNORED`, `UNSUPPORTED_DATE`,
+`RATE_REQUIRED`, `LOCALE_NOT_LOADED`. That's the handful you'll actually branch
+on; the full set of 33 stable codes is in `llms.txt`. Override any message via
+the `messages` option.
## Full reference
diff --git a/wiki/architecture.md b/wiki/architecture.md
index 15d72dd..796659c 100644
--- a/wiki/architecture.md
+++ b/wiki/architecture.md
@@ -107,7 +107,10 @@ resolver + detector; the packs themselves live in `src/locales/`).
## Verification
`bun run check` is the gate: tsc strict + `noUncheckedIndexedAccess`, the full
-vitest suite (600+ tests), build, size budgets (`scripts/size.mjs` — the only
-place budget numbers live), corpus compatibility (`scripts/corpus-diff.mjs`),
-llms.txt docs sync, zero-dependency check, and schema artifact sync. CI runs
-the same gates plus a built-dist smoke test on every supported Node version.
+vitest suite (1,000+ tests), build, size budgets (`scripts/size.mjs` — the only
+place budget numbers live), corpus compatibility across the English and
+per-locale contracts (`scripts/corpus-diff.mjs`), llms.txt docs sync
+(`check-docs-sync.mjs`, both copies), entry-point coverage in the generated
+agent index (`check-llms-index.mjs`), zero-dependency check, and schema
+artifact sync. CI runs the same gates plus a built-dist smoke test on every
+supported Node version.
diff --git a/wiki/decisions.md b/wiki/decisions.md
index d358ad7..f7a627d 100644
--- a/wiki/decisions.md
+++ b/wiki/decisions.md
@@ -595,3 +595,87 @@ locative fillers (`na proxima segunda-feira`), and a per-locale benchmark suite
so multi-language throughput is tracked rather than assumed. Revisit if a pack
wants glued splitting for a *Latin* script — the current filter is deliberately
`!/[a-z]/i`, on the theory that spaced scripts already tokenize correctly.
+
+**D71 · 2026-07-29 · Calendar ranges reuse the single-date parser instead of
+growing a date-range grammar.** `parseDateRange` covered clock slots and
+anchored durations; every calendar phrasing failed `UNSUPPORTED_DATE` —
+`July 1 to July 5`, `next week`, `this weekend`. The implementation is a second
+pass, not a second grammar: the clock pass runs unchanged, and only when it
+declines do the same `RANGE_SPLITS` separators get retried with
+`parseWithOptionalTime` on each side. Ordering is load-bearing — `2pm` parses as
+a date too (time-only), so a date-first pass would silently reclassify every
+existing slot.
+
+Three trade-offs worth recording:
+
+(1) **The end anchors to the start, not to `now`.** Endpoints resolved
+independently read `July 1 to July 5` on a July-3 reference as 2027-07-01 →
+2026-07-05: descending, because `forwardDates` rolls the start into next year
+and leaves the end in this one. Parsing the end against the start as its
+reference makes the pair always read left to right. Rejected: emitting a
+reversed-range issue, which describes a defect the parser caused itself.
+
+(2) **A period is a coarse-grained date, so it needs no period grammar.**
+`next week` already resolves to its Monday at `week` grain and `August` to the
+1st at `month` grain. Spanning them is a widening of a date the parser already
+produces, which is why the whole calendar-period feature is one `periodEnd`
+switch rather than a parallel set of range phrases — and why `2027` and
+`next August` work without being enumerated anywhere. Weekends are the
+exception: they resolve at `day` grain, so they widen explicitly. `weekend` also
+gained `next`/`last` modifiers, which it had never had.
+
+(3) **The lazy dash rule is disabled by ISO dates, not repaired.**
+`(.+?)\s*[-–—]\s*(.+)` splits `2026-08-01 - 2026-08-05` at the first ISO dash.
+When the input contains a `\d{4}-\d{2}` run the pass demands a *spaced* dash
+instead, which no ISO date contains. `2026-08-01-2026-08-05` stays unparseable —
+four dashes with no way to know which one splits, and guessing is exactly the
+silent wrong answer this library exists to avoid. `Mon-Fri` and `9-5` keep the
+lazy rule.
+
+`humanizeDateRange` gained a `dated` provenance flag alongside the existing
+`anchored` one, for the same reason it exists: without it a calendar range
+renders `"12:00 AM to 12:00 AM"` and re-parses to today, breaking the two-way
+guarantee. Runtime-only, never serialized.
+
+Budgets recalibrate for the capability (D19 escalation honored): `./date`
+standalone 43.3 → 43.8, `./date` marginal 15.7 → 16.2, `./ai` marginal
+18.6 → 19.1 (cascade only, no `/ai` code changed — `dateRangeField` gains the
+capability for free). The main entry is untouched at 39.18: the date module is
+not in it. Total cost 450 B gzip for date-to-date ranges, period spans,
+weekends, and the humanize branch. Every existing corpus row is unchanged; the
+18 new rows are additive. Revisit if multi-date lists (`today and tomorrow`)
+land — those need a new result type rather than a third endpoint, and the
+`dated` flag would likely generalize into it.
+
+**D72 · 2026-07-29 · A weekend contains the Sunday it ends on, a coarse endpoint
+widens on the closing side, and a descending dated pair swaps.**
+
+Three semantics D71 left wrong, all found by an adversarial review pass against
+the plan-032 branch and all fixed with tests before shipping.
+
+(1) **`this weekend` on a Sunday meant *next* weekend.** The Saturday was found
+by rounding `now` forward (`forwardDiff(day, 6)`), which on Sunday skips the
+weekend in progress. The reader standing in a weekend could only reach it as
+`last weekend` — the one phrasing nobody would try. Sunday now looks back a day;
+every other weekday still rounds forward. This is why the round-trip matrix now
+runs the full seven references instead of a single Friday.
+
+(2) **A coarse endpoint only meant its first day inside a compound range.**
+D71 widened `August` standing alone but not `July to August`, which ended on
+August 1 — the whole-period promise held for one shape and quietly failed for
+the other. `widenToPeriodEnd` is now shared by the standalone path, the closing
+side of a split range, and the `until X` open form. The opening side is
+deliberately left alone: `from August` starts on the 1st, `until August` ends on
+the 31st, and that asymmetry is the point.
+
+(3) **Descending dated pairs came back descending.** `2026-08-09 to
+2026-08-03` returned `start > end` with no issue, while D71's own prose claimed
+"a pair never reads backwards". Dated pairs now swap and warn with the existing
+`RANGE_REVERSED` code — the same treatment `10-5 kg` already gets, so no new
+vocabulary. The swap is scoped to the dated path on purpose: `9pm to 5am` is a
+legitimate overnight slot, not a typo, and clock ranges keep passing through
+untouched.
+
+Not fixed here, logged in `plans/backlog.md`: `humanizeDateRange` drops seconds
+(pre-existing, verified against the commit before D71), elliptical right sides
+(`Aug 3–9`), and quarters (needs a `fiscalYearStart` option to mean anything).
diff --git a/wiki/inspiration.md b/wiki/inspiration.md
index 98a9583..89927cd 100644
--- a/wiki/inspiration.md
+++ b/wiki/inspiration.md
@@ -22,7 +22,8 @@ license obligation; we do not copy code without noting it here explicitly.
| [js-quantities](https://github.com/gentooboontoo/js-quantities) | MIT | Temperature absolute-vs-delta separation (tempC vs degC) — our `convert`/`convertDelta` split. |
| [unitmath](https://github.com/ericman314/UnitMath) | Apache-2.0 | Formatting options design; custom unit definition ergonomics. |
| mathjs unit system | Apache-2.0 | Prefix handling and parsing grammar cautionary study (powerful but heavyweight — we deliberately stay non-algebraic). |
-| [mathjs expression trees](https://mathjs.org/docs/expressions/expression_trees.html) | Apache-2.0 | Typed node AST + parse/evaluate split studied for plan 032 (input calculations); we'd take the tiny node union and canonical `toString`, reject scopes/functions/CAS. |
+| [mathjs expression trees](https://mathjs.org/docs/expressions/expression_trees.html) + [expression syntax](https://mathjs.org/docs/expressions/syntax.html) | Apache-2.0 | Typed node AST + parse/evaluate split studied for plan 032 (input calculations); we'd take the tiny node union and canonical `toString`, reject scopes/functions/CAS. Deeper pass 2026-07-29 added two things: its **percentage operators** (`100 + 3%` → `103` as a dedicated operator distinct from modulus) as the model for our phase-1 percent-of family, and its [security page](https://mathjs.org/docs/expressions/security.html) as the cautionary framing — an expression evaluator needs one only because it evaluates arbitrary code, so our closed node union's inability to express a call or a side effect is a guarantee to advertise at the LLM tool boundary, not just an internal constraint. |
+| [Pint](https://github.com/hgrecco/pint) (Python) | BSD-3-Clause | `delta_degC` / `delta_degF` as prior art for making affine-delta semantics *explicit* rather than warning users off the operation (mathjs's own advice is "avoid calculations using celsius and fahrenheit"). Motivated plan 032's `AFFINE_DELTA_ASSUMED` warning: lingo's compound path already delta-converts correctly, it just never said so. |
| ECMA-402 / Intl.NumberFormat unit style | spec | Sanctioned unit identifier list; free locale-aware formatting we lean on instead of shipping CLDR. |
## Dates domain
@@ -52,6 +53,8 @@ license obligation; we do not copy code without noting it here explicitly.
| [Radix Primitives](https://www.radix-ui.com/primitives) / [Headless UI](https://headlessui.com) | MIT | Zero shipped CSS; one `data-*` attribute per state (not combined strings); namespaced CSS custom properties. |
| [downshift](https://github.com/downshift-js/downshift) | MIT | Prop-getter pattern for the React hook adapter. |
| [flatpickr](https://flatpickr.js.org) | MIT | `altInput` pattern: pretty text visible, hidden canonical input submits — our `name` option. |
+| Excel / Google Sheets / Notion / Airtable formula entry (studied 2026-07-29) | convention | The leading-`=` mode switch as the answer to plan 032's hardest open question. One text box serves two grammars: bare input keeps its literal meaning, `=` opts into arithmetic. Lets lingo add expressions where `-` currently means *range* without changing a single existing reading — the feature becomes purely additive instead of a corpus-breaking reinterpretation. |
+| [Soulver](https://soulver.app) · [Numi](https://numi.app) · Raycast/Spotlight calculators (studied 2026-07-29) | commercial, studied not copied | The "calculator in a text field" UX bar for plan 032: unit-aware natural-language lines (`$50 with 20% off`, `5 hours in minutes`) resolved inline with the result shown beside the input rather than replacing it. Confirms our choice to surface evaluated expressions through `./complete` as a ranked completion instead of silently committing a computed value into the field. |
| WAI / W3C forms tutorials & WAI-ARIA APG | W3C | Polite-while-typing vs alert-on-commit announcement split; debounce guidance; error summary + focus management. |
| MDN Constraint Validation, `:user-invalid` | CC / spec | Post-interaction error timing semantics; `setCustomValidity` interop. |
diff --git a/wiki/research/utility-opportunities.md b/wiki/research/utility-opportunities.md
index 3f204b2..dd92eb6 100644
--- a/wiki/research/utility-opportunities.md
+++ b/wiki/research/utility-opportunities.md
@@ -47,6 +47,34 @@ cost and bundle risk run from 1 (small) to 5 (large).
No new backlog entries result from this pass: every deferred opportunity above
already appears in a numbered plan or `plans/backlog.md`.
+### Addendum 2026-07-29 — input calculations re-scored
+
+A prior-art pass over mathjs plus a probe of live parser behavior changed three
+of the inputs behind the `./calc` row, and plan 032 was rewritten accordingly.
+
+- **Audience is wrong, not just incomplete.** Scored as "Forms"; the stronger
+ case is the LLM tool boundary. A model asked for `weight_kg: number` must do
+ the arithmetic in its head and nothing downstream can audit it, whereas a
+ field accepting `"12 * 0.75 kg"` moves the computation into deterministic
+ library code. That is a `value_error` mitigation, which raises
+ differentiation from 3 — no other Standard Schema library offers it.
+- **Cost and bundle risk drop with the `=` escape hatch.** Borrowing the
+ Excel/Sheets mode switch makes the feature purely additive instead of a
+ reinterpretation of existing readings, and the injected-evaluator pattern
+ (already used for `./complete` → `./date`) keeps both the main entry and
+ `./ai` budgets flat. Phase 1 (percent-of only) is a much smaller first slice
+ than the full expression grammar that was costed here.
+- **"Resolve plan 032 first" now has a prerequisite of its own.** The probe
+ found that `2+3 kg` already returns a range at `confidence: 1` with zero
+ issues — a D4 violation shipping today, independent of any calc decision.
+ That fix and the `AFFINE_DELTA_ASSUMED` warning land regardless of the
+ go/no-go.
+
+Two adjacent opportunities surfaced by the same pass went to `plans/backlog.md`
+rather than this matrix: rates with non-unit denominators (`£45 per night`),
+which ranks above input calculations on form value, and multiplier/count words
+(`twice 3 kg`, `a dozen eggs`).
+
## Shipped first slice: React completions
`@pascal-app/lingo/complete` already returns ranked, fully parsed completions,