From 3746ca2ae4109b3b526d8c43af2bde0313f60d0d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 14:28:56 +0200 Subject: [PATCH 01/15] docs(plans): rewrite 032 around the = escape hatch and LLM arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical probing turned up two things the plan had to answer before any grammar work: `2+3 kg` is silently read as a range with full confidence (no issue, contradicting D4's honest-ambiguity rule), and additive compounds over affine units already delta-convert correctly but never say so. Both are recorded as fixes that stand on their own. The plan now takes the leading-`=` mode switch from spreadsheet formula entry, which makes expressions purely additive: bare input keeps every reading it has today, `=` opts into arithmetic. It also reframes the value around LLM tool calls — models set arithmetic up well and execute it badly, so having the model emit the expression and lingo evaluate it deterministically is the safety story, not a calculator feature. Co-authored-by: Cursor --- plans/032-input-calculations.md | 318 ++++++++++++++++++++----- plans/backlog.md | 24 ++ wiki/inspiration.md | 5 +- wiki/research/utility-opportunities.md | 28 +++ 4 files changed, 311 insertions(+), 64 deletions(-) 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/backlog.md b/plans/backlog.md index f75dd31..cb65350 100644 --- a/plans/backlog.md +++ b/plans/backlog.md @@ -27,6 +27,20 @@ surfaces mid-task, add it here and keep going — don't act on it. - **Dimensional expressions** — arbitrary unit algebra (beyond the declared flow_rate/concentration/torque/… kinds) needs a separate kind/model decision rather than ad hoc aliases (algebra deferred per D2). +- **Rates with non-unit denominators** — `£45 per night`, `50 kg per person`, + `$12/hour`, `3 per box` all fail with `TRAILING_INPUT` today. This is *not* + dimensional algebra (D2 stands): the denominator is a countable domain noun, + not a unit, so no unit algebra is involved — it wants a `per` slot on the + quantity. Covers pricing, dosing, capacity, and rate fields, and gives LLM + tools a clean shape to emit (`{amount: 45, currency: 'EUR', per: 'night'}`). + Ranked above the general expression grammar on form value, but it changes the + v3 wire shape, so it needs its own plan rather than folding into 032. + (Surfaced by the mathjs/plan-032 prior-art pass 2026-07-29.) +- **Multiplier and count words** — `twice 3 kg`, `double 3 kg`, `3 boxes of + 2 kg`, `3 @ 2.5 kg`, `5 kg each`, `a dozen eggs` all fail today. Reuses the + existing number-word lexicon; overlaps plan 032 phase 3, but the unit-less + count forms (`a dozen eggs`) are a quantity-grammar question, not arithmetic. + (Same pass.) - **Range span excludes the frame word** — `from 5 to 10 kg` and `between 5 and 10 kg` spans start at the first value, not at `from`/`between`. `buildRange` (`range.ts`) could extend the span to cover the leading frame. @@ -324,3 +338,13 @@ suffix clock grammar. Still open: `百`/`千` positional composition (`三百五十日` as a day-of-month is nonsense, yet `二千二十六年` is a legal year spelling). Unify with the CJK number walker in `number/` instead of growing a second reader. + +## Test-suite maintenance + +- **`suggest.test.ts` alias-probe timeout** — "matches an unpruned reference + across generated alias probes" builds a full registry and walks every alias in + `allKinds`, taking ~7 s against vitest's 5 s default. It fails in isolation on + a mid-range laptop and intermittently under a loaded pool, so `bun run check` + is flaky through no fault of the change under test. Either sample the alias + space, hoist the registry out of the timed body, or give the case its own + timeout — pick one deliberately rather than raising the global default. 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, From 68c3eb17dd4af890ff1309a5880ee0a63805ccc6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 14:29:11 +0200 Subject: [PATCH 02/15] feat(date): read date-to-date spans and calendar periods as ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseDateRange only understood clock slots, so "July 1 to July 5" and "next week" both came back UNSUPPORTED_DATE — which is most of what people type into a date field. A second pass now reuses the single-date parser on each side of the existing separators, and expands a coarse single date (week, month, quarter, year, weekend) into its real first and last day rather than a midnight instant. Two details the implementation forced. The bare-dash split has to stay off when the input contains an ISO date, or "2026-08-03" splits itself; that is now a flag on the separator table instead of a special case at the call site. And the result carries a `dated` flag, because humanizeDateRange otherwise renders a day span as "12:00 AM to 12:00 AM" and breaks the two-way guarantee. The date entry and the marginal ai/date budgets move up ~450 B; D71 records the measurement and the case for spending it. Co-authored-by: Cursor --- packages/lingo/CHANGELOG.md | 12 + packages/lingo/scripts/size.mjs | 14 +- packages/lingo/src/date/humanize.ts | 25 +- packages/lingo/src/date/parse.ts | 148 ++++ packages/lingo/src/date/range.test.ts | 110 +++ packages/lingo/src/date/range.ts | 24 +- packages/lingo/src/date/relative.ts | 15 +- packages/lingo/tests/corpus/contract-v1.json | 721 +++++++++++++++++++ packages/lingo/tests/corpus/source.mjs | 21 + plans/005-dates-and-durations.md | 32 +- wiki/decisions.md | 51 ++ 11 files changed, 1145 insertions(+), 28 deletions(-) diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index 8661338..3597e4c 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -9,6 +9,18 @@ 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. + - Weekends — `this weekend`, `next weekend`, `last weekend` span Saturday + through Sunday. `parseDate` gained the `next`/`last` weekend modifiers too. + - `humanizeDateRange` renders these as dates rather than clock times, so they + round-trip. Time slots are unchanged. - 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 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/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..57fb3e4 100644 --- a/packages/lingo/src/date/parse.ts +++ b/packages/lingo/src/date/parse.ts @@ -387,6 +387,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 @@ -534,9 +540,151 @@ 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 + +/** 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 && + (/weekend$/i.test(source) + ? addCalendar(whole.date, { days: 1 }) + : periodEnd(whole.date, whole.grain)) + if (whole && wholeEnd) { + return finishDateRange(p, span, zoneSpan, [...issues, ...whole.issues], whole.ref === true, { + end: dateEndpoint( + { ...whole, date: wholeEnd, grain: 'day', known: knownFor('day') }, + 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] + } + if (open) { + const only = parseWithOptionalTime(p, ...at(1)) + if (!only) { + continue + } + const ep = dateEndpoint(only, 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(endCore, 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)], + } + } + const range = finishRange(p, span, zoneSpan, issues, ends.start, ends.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..75abab4 100644 --- a/packages/lingo/src/date/range.test.ts +++ b/packages/lingo/src/date/range.test.ts @@ -402,3 +402,113 @@ 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']) + }) + + it('handles a February in a leap year', () => { + expect(span('February', new Date(2028, 0, 15))).toEqual(['2028-02-01', '2028-02-29']) + }) +}) + +describe('humanizeDateRange round-trips calendar ranges', () => { + const cases = [ + 'July 1 to July 5', + 'next week', + 'this weekend', + 'next month', + 'August', + 'from monday', + 'until august 9', + '2026-08-01 to 2026-08-05', + 'July 1 3pm to July 2 5pm', + ] + + it.each(cases)('%s', (text) => { + const first = parseDateRange(text, { now: NOW }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + const again = parseDateRange(humanizeDateRange(first), { now: 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..2f9f665 100644 --- a/packages/lingo/src/date/relative.ts +++ b/packages/lingo/src/date/relative.ts @@ -453,15 +453,14 @@ 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 saturday = addCalendar(today, { days: forwardDiff(today.getDay(), 6) }) + 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..e7d32ba 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,687 @@ "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 } } } diff --git a/packages/lingo/tests/corpus/source.mjs b/packages/lingo/tests/corpus/source.mjs index eeff959..ef8af6e 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'], @@ -460,6 +462,25 @@ 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 }], ] export function buildContract({ lingo, parseDate, parseDateRange }) { diff --git a/plans/005-dates-and-durations.md b/plans/005-dates-and-durations.md index 2e7699d..0ae211d 100644 --- a/plans/005-dates-and-durations.md +++ b/plans/005-dates-and-durations.md @@ -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,30 @@ 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 pair cannot read backwards. 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. +- **Weekends** — `weekend`, `this weekend`, `next weekend`, `last weekend` → + Saturday through Sunday. Day-grained, so widened explicitly. + +`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/wiki/decisions.md b/wiki/decisions.md index d358ad7..89425e5 100644 --- a/wiki/decisions.md +++ b/wiki/decisions.md @@ -595,3 +595,54 @@ 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. From 940652eb6cdbe55ea376a327cfaa49979c34ab70 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 14:29:26 +0200 Subject: [PATCH 03/15] feat(site): adaptive calendar, LaTeX notation, and normalizing grid demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three demos for the shapes people actually build, each answering a question the prose alone leaves open. The date field shows that one input can serve a day picker, a two-month range picker, and a time slot, because the reading says which shape it found — no mode toggle for the person typing. It is the visible payoff of the calendar-range work in the previous commit. The notation demo makes the case that a canonical reading is structured, not just cleaned up: unit id and value are separate fields, so mapping a result to LaTeX is ~90 lines with no renderer in the library. KaTeX and its stylesheet load in their own chunk on mount. The grid puts a quantityField on a table column and lets every cell take whatever people paste, which is the same field that guards a tool call — "a column is a schema". It surfaces the honest-ambiguity story where it is easiest to feel: totals add up only because the column has one unit, assumptions are marked, and a GBP cell in a USD column refuses rather than inventing a rate. Shared useHydrated hook so both date-dependent demos hold a fixed reference time through hydration and then switch to the real clock. Co-authored-by: Cursor --- apps/site/package.json | 3 + apps/site/src/app/docs/page.tsx | 36 ++ .../components/site/calendar-field-demo.tsx | 349 ++++++++++++++ .../src/components/site/data-grid-demo.tsx | 435 ++++++++++++++++++ apps/site/src/components/site/katex-math.tsx | 44 ++ .../src/components/site/latex-units-demo.tsx | 129 ++++++ apps/site/src/components/site/use-hydrated.ts | 22 + apps/site/src/lib/docs-catalog.ts | 32 ++ apps/site/src/lib/docs.md.ts | 12 + apps/site/src/lib/latex.ts | 119 +++++ bun.lock | 13 + 11 files changed, 1194 insertions(+) create mode 100644 apps/site/src/components/site/calendar-field-demo.tsx create mode 100644 apps/site/src/components/site/data-grid-demo.tsx create mode 100644 apps/site/src/components/site/katex-math.tsx create mode 100644 apps/site/src/components/site/latex-units-demo.tsx create mode 100644 apps/site/src/components/site/use-hydrated.ts create mode 100644 apps/site/src/lib/latex.ts 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/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index 4c1ea90..c8ed774 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 { @@ -832,6 +835,16 @@ 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 every cell in it accepts whatever people paste — + pounds and ounces, a comma decimal, Fahrenheit — while the column keeps one + canonical unit, so the totals row can just add numbers. +

+
+
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 widgets +

+ 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. +

+
+
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 + const clock = !range.dated + return { end, mode: clock ? 'slot' : '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' }) +} + +function summary(reading: Reading): string { + const { start, end, mode } = reading + if (mode === 'none' || !(start || end)) { + return 'No reading' + } + if (mode === 'single' && start) { + return 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 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 = + cursor ?? + (start + ? new Date(start.getFullYear(), start.getMonth(), 1) + : new Date(now.getFullYear(), now.getMonth(), 1)) + const months = twoUp ? [anchor, addMonths(anchor, 1)] : [anchor] + + 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) => ( + + ))} +
+
+ +
+ + {MODE_COPY[mode]} + + {summary(reading)} +
+ +
+
+ + {/* 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)} +
+ ))} +
+ +
+
+ {months.map((month, index) => ( + 0 && 'hidden sm:table', + )} + key={month.getTime()} + > + + + + {WEEKDAYS.map((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 ( + + ) + })} + + ))} + +
{monthLabel(month)}
+ {day.slice(0, 2)} + {day} +
+ +
+ ))} +
+
+
+
+ ) +} 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..31ef9fd --- /dev/null +++ b/apps/site/src/components/site/data-grid-demo.tsx @@ -0,0 +1,435 @@ +'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, + rowIndex, + value, +}: { + columnKey: FieldKey + columns: Record + onChange: (next: string) => void + rowIndex: number + 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} + + )} + +
+ ) +} + +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)} + rowIndex={ctx.row.index} + 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 ( + +
+
+ + + + + + + + + + + + {table.getHeaderGroups().map((group) => ( + + {group.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ Editable shipment rows. Each column parses free text into one canonical unit. +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ +
+
+
+
Total mass
+
{totals.mass} kg
+
+
+
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} +
+
+
+ +
+ + {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/katex-math.tsx b/apps/site/src/components/site/katex-math.tsx new file mode 100644 index 0000000..9fde691 --- /dev/null +++ b/apps/site/src/components/site/katex-math.tsx @@ -0,0 +1,44 @@ +'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, + output: 'html', + 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/latex-units-demo.tsx b/apps/site/src/components/site/latex-units-demo.tsx new file mode 100644 index 0000000..0b4cd96 --- /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) => ( + + ))} +
+
+ +
+ {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/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts index f97136e..bd4b84a 100644 --- a/apps/site/src/lib/docs-catalog.ts +++ b/apps/site/src/lib/docs-catalog.ts @@ -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 widgets', + '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..02908de 100644 --- a/apps/site/src/lib/docs.md.ts +++ b/apps/site/src/lib/docs.md.ts @@ -275,6 +275,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 every cell accepts whatever people paste — `3 lb 4 oz`, `1,2 kg`, `72°F` — while the column keeps 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.', + '', '## 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 +287,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 +309,10 @@ 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 widgets', + '', + '`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..2026-08-09`), and a whole calendar period (`next week`, `this weekend`, `next month`, `next quarter`) expanded to its real first and last day. 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.', + '', '## 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.', 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/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=="], From 6b22dce459e33216afbfbd7c0bdabfac6e12401e Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 14:53:32 +0200 Subject: [PATCH 04/15] fix(site): show the clock in the date reading, rule the grid, publish the column code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-testing the three demos turned up three places where the surface undersold or obscured what is actually happening. The date field dropped the time. "tomorrow at 3pm" parses to an instant with grain=hour, but the summary rendered dateStyle:'full' and showed "Thursday, July 30, 2026" — the field looked less precise than the reading. It now prints the clock when one was pinned, and `tomorrow` sits beside `tomorrow at 3pm` in the examples so the difference is visible without typing. The grid's cells did not look editable: transparent inputs with no resting border read as static text, so the one thing to do in the demo was invisible. Vertical rules turn it into a spreadsheet, which is the metaphor that already means "type here" — cheaper than 30 boxes. Price also gains width, because RATE_REQUIRED was truncating to RATE_REQUIR…. The grid was the only demo with no source panel, and it is the one whose whole claim is the field declaration. Its columns.ts snippet now shows the four fields and the safeParse branch that separates a refusal from an assumption. Co-authored-by: Cursor --- apps/site/src/app/docs/page.tsx | 18 +++++++++++++ .../components/site/calendar-field-demo.tsx | 17 +++++++++++- .../src/components/site/data-grid-demo.tsx | 27 ++++++++++++------- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index c8ed774..9c1cddc 100644 --- a/apps/site/src/app/docs/page.tsx +++ b/apps/site/src/app/docs/page.tsx @@ -192,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) @@ -844,6 +861,7 @@ export default async function Home() { canonical unit, so the totals row can just add numbers.

+
diff --git a/apps/site/src/components/site/calendar-field-demo.tsx b/apps/site/src/components/site/calendar-field-demo.tsx index fa27ec7..5e6d98f 100644 --- a/apps/site/src/components/site/calendar-field-demo.tsx +++ b/apps/site/src/components/site/calendar-field-demo.tsx @@ -21,11 +21,14 @@ import { cn } from '@/lib/utils' /** SSR reference time. After hydration the field switches to the real clock. */ const SSR_NOW = new Date(2026, 6, 3, 9, 0, 0) +/** `tomorrow` and `tomorrow at 3pm` sit next to each other on purpose: the pair + * is the shortest way to show that a clock time survives the reading. */ const EXAMPLES = [ 'next week', 'this weekend', 'Aug 3 - Aug 9', 'tomorrow', + 'tomorrow at 3pm', '3 days starting monday', '2pm to 4pm', ] as const @@ -100,13 +103,25 @@ 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) { - return start.toLocaleDateString('en-US', { dateStyle: 'full' }) + // "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) diff --git a/apps/site/src/components/site/data-grid-demo.tsx b/apps/site/src/components/site/data-grid-demo.tsx index 31ef9fd..0222f9a 100644 --- a/apps/site/src/components/site/data-grid-demo.tsx +++ b/apps/site/src/components/site/data-grid-demo.tsx @@ -206,7 +206,7 @@ function LingoCell({ aria-invalid={failed} aria-label={`${HEADERS[columnKey]}, row ${rowIndex + 1}`} className={cn( - 'h-6 w-full min-w-0 rounded-[4px] bg-transparent px-1.5 font-mono text-[12px] outline-none', + 'h-6 w-full min-w-0 cursor-text rounded-[4px] bg-transparent px-1.5 font-mono text-[12px] outline-none', 'transition-[background-color,box-shadow] duration-[var(--motion-fast)] ease-[var(--ease-out)]', 'hover:bg-foreground/[0.045] focus-visible:bg-background focus-visible:shadow-[var(--surface-ring)]', failed && 'text-destructive', @@ -259,7 +259,7 @@ export function DataGridDemo() { cell: (ctx) => ( update(ctx.row.original.id, 'sku', event.target.value)} spellCheck={false} value={ctx.getValue()} @@ -339,24 +339,26 @@ export function DataGridDemo() { >
- +
+ {/* 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) => ( {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) => ( - ))} From a4dd686261154b7e835b29c4d42d1ecfa24c106c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 15:11:32 +0200 Subject: [PATCH 05/15] fix(date): a weekend contains its Sunday, coarse ends widen, reversed pairs swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the calendar-range work found three wrong semantics, all shipped by the previous commit and all now covered by tests. "this weekend" rounded forward to the coming Saturday, so on a Sunday it meant next weekend and the weekend the reader was standing in could only be reached as "last weekend". Sunday now looks back a day. A coarse endpoint only widened when it stood alone, so "August" spanned the month but "July to August" ended on August 1. The widening is now shared by the standalone path, the closing side of a split range, and "until X" — while "from August" still opens on the 1st, which is the asymmetry we want. "2026-08-09 to 2026-08-03" came back descending with no issue, contradicting the claim that a pair never reads backwards. Dated pairs now swap and warn with the existing RANGE_REVERSED code. Overnight clock slots are untouched: 9pm to 5am is a real slot, not a typo. Tests go from 56 to 87: the weekend matrix runs all seven weekday references instead of one Friday, and the round-trip cases add Sunday, year-end, leap and common Februaries, and the reversed pair. Nine additive corpus rows, zero breaking. Budgets unchanged. Backlogged rather than fixed here: humanizeDateRange drops seconds (pre-existing, verified against the commit before this feature), elliptical right sides like "Aug 3-9", and quarters (needs a fiscalYearStart option to mean anything). Co-authored-by: Cursor --- packages/lingo/CHANGELOG.md | 9 +- packages/lingo/src/date/absolute.ts | 2 +- packages/lingo/src/date/parse.ts | 58 ++- packages/lingo/src/date/range.test.ts | 105 +++++- packages/lingo/src/date/relative.ts | 7 +- packages/lingo/tests/corpus/contract-v1.json | 375 +++++++++++++++++++ packages/lingo/tests/corpus/source.mjs | 16 + plans/005-dates-and-durations.md | 19 +- plans/backlog.md | 19 + wiki/decisions.md | 33 ++ 10 files changed, 607 insertions(+), 36 deletions(-) diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index 3597e4c..a897de7 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -16,11 +16,18 @@ change**, even if the API is untouched. `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. + 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 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/parse.ts b/packages/lingo/src/date/parse.ts index 57fb3e4..8b762fa 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 { @@ -553,6 +554,19 @@ function parseDateRangeImpl(text: string, opts?: DateOptions): DateRange | DateR 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') { @@ -596,17 +610,10 @@ function parseDateToDateRange( 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 && - (/weekend$/i.test(source) - ? addCalendar(whole.date, { days: 1 }) - : periodEnd(whole.date, whole.grain)) - if (whole && wholeEnd) { + const wholeEnd = whole && widenToPeriodEnd(whole, source) + if (whole && wholeEnd !== whole) { return finishDateRange(p, span, zoneSpan, [...issues, ...whole.issues], whole.ref === true, { - end: dateEndpoint( - { ...whole, date: wholeEnd, grain: 'day', known: knownFor('day') }, - rangeZone, - ), + end: dateEndpoint(wholeEnd as CoreDate, rangeZone), start: dateEndpoint(whole, rangeZone), }) } @@ -624,12 +631,18 @@ function parseDateToDateRange( 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 } - const ep = dateEndpoint(only, rangeZone) + // "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 }), }) @@ -651,7 +664,10 @@ function parseDateToDateRange( zoneSpan, [...issues, ...startCore.issues, ...endCore.issues], startCore.ref === true || endCore.ref === true, - { end: dateEndpoint(endCore, rangeZone), start: dateEndpoint(startCore, rangeZone) }, + { + end: dateEndpoint(widenToPeriodEnd(endCore, text(2)), rangeZone), + start: dateEndpoint(startCore, rangeZone), + }, ) } return null @@ -678,7 +694,23 @@ function finishDateRange( issues: [...issues, makeIssue('NOW_REQUIRED', {}, span, p.opts.messages)], } } - const range = finishRange(p, span, zoneSpan, issues, ends.start, ends.end, true) + 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 } diff --git a/packages/lingo/src/date/range.test.ts b/packages/lingo/src/date/range.test.ts index 75abab4..5873699 100644 --- a/packages/lingo/src/date/range.test.ts +++ b/packages/lingo/src/date/range.test.ts @@ -477,31 +477,106 @@ describe('parseDateRange calendar periods', () => { expect(span('last weekend')).toEqual(['2026-06-27', '2026-06-28']) }) - it('handles a February in a leap year', () => { - expect(span('February', new Date(2028, 0, 15))).toEqual(['2028-02-01', '2028-02-29']) + // 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', () => { - const cases = [ - 'July 1 to July 5', - 'next week', - 'this weekend', - 'next month', - 'August', - 'from monday', - 'until august 9', - '2026-08-01 to 2026-08-05', - 'July 1 3pm to July 2 5pm', + // 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) => { - const first = parseDateRange(text, { now: 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: NOW }) + const again = parseDateRange(humanizeDateRange(first), { now }) expect(again.ok).toBe(true) if (!again.ok) { return diff --git a/packages/lingo/src/date/relative.ts b/packages/lingo/src/date/relative.ts index 2f9f665..75cc0d5 100644 --- a/packages/lingo/src/date/relative.ts +++ b/packages/lingo/src/date/relative.ts @@ -458,7 +458,12 @@ function parseCalendarPeriod(p: P, start: number, end: number): CoreDate | null const weekend = /^(?:(this|next|last)\s+)?weekend$/.exec(source) if (weekend) { const today = startOfDay(p.now) - const saturday = addCalendar(today, { days: forwardDiff(today.getDay(), 6) }) + 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) } diff --git a/packages/lingo/tests/corpus/contract-v1.json b/packages/lingo/tests/corpus/contract-v1.json index e7d32ba..240258c 100644 --- a/packages/lingo/tests/corpus/contract-v1.json +++ b/packages/lingo/tests/corpus/contract-v1.json @@ -8067,6 +8067,381 @@ "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 ef8af6e..09758cf 100644 --- a/packages/lingo/tests/corpus/source.mjs +++ b/packages/lingo/tests/corpus/source.mjs @@ -447,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 }], @@ -481,6 +483,20 @@ export const dateRangeRows = [ ['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 0ae211d..d8d3276 100644 --- a/plans/005-dates-and-durations.md +++ b/plans/005-dates-and-durations.md @@ -100,15 +100,24 @@ and calendar ranges per D71: `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 pair cannot read backwards. 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. + 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. + 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. + 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, diff --git a/plans/backlog.md b/plans/backlog.md index cb65350..f04e260 100644 --- a/plans/backlog.md +++ b/plans/backlog.md @@ -90,6 +90,25 @@ surfaces mid-task, add it here and keep going — don't act on it. drift in the autocomplete display string, not in `format`/`humanize`). Slicing to 13 (`…T15`) fixes it only if the date parser accepts a bare `THH` — verify that round-trips before changing. Surfaced by the 0.2.0 completions review. +- **`humanizeDateRange` drops seconds** — `formatClock` (`date/humanize.ts`) + never emits a seconds field, so `9:00:30am to 5:00:45pm` humanizes to + `9:00 AM to 5:00 PM` and re-parses a minute off. This predates the D71 + calendar-range work (verified against the commit before it) and breaks the + two-way guarantee for second-grain endpoints. Render seconds whenever an + endpoint carries `second` grain; check the anchored-duration phrase at the + same time, since it shares the clock formatter. Surfaced by the plan-032 + adversarial review pass. +- **Elliptical range right sides** — `Aug 3–9`, `July 1-5`, `July 1 through 5` + all fail: `parseDateToDateRange` parses each endpoint independently, so a bare + day number on the right has no month to attach to. The right side should + inherit month and year from the left when it reads as a valid day. This is the + Latin-script twin of the CJK `3月5日~10日` entry above — do both with one + elliptical-endpoint rule rather than two special cases. +- **Quarters (`Q3`, `next quarter`, `Q3 2026`)** — not a `DateGrain`, so + `periodEnd` has nothing to widen and the whole family reads as + `UNSUPPORTED_DATE`. Fiscal-year offsets are the reason this isn't free: `Q3` + means different months to different companies, so it needs a `fiscalYearStart` + option before it can be more than a calendar-quarter guess. ## Wire schema & types diff --git a/wiki/decisions.md b/wiki/decisions.md index 89425e5..f7a627d 100644 --- a/wiki/decisions.md +++ b/wiki/decisions.md @@ -646,3 +646,36 @@ 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). From e11782c2b5d8d92fdaabc8d2011dbe700be0e66f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 15:11:34 +0200 Subject: [PATCH 06/15] fix(site): stop overclaiming in the demos, and make them usable without a mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar demo classified anything undated as a time slot, so "3 days starting monday" — one of its own example chips — showed a single-day time slot instead of a three-day span. Day-grained endpoints now count as a span, and the exclusive end of an anchored duration is normalized to the last day covered so the highlight and the day count agree. Copy the reviewers could disprove: - "next quarter" was advertised; quarters parse as UNSUPPORTED_DATE. - "lingo emits the source" sat under the LaTeX demo, while the same page says lingo ships no renderer. The renderer is site code, and the caption now says what actually makes it possible: value and unit id come back separately. - "three widgets" promised a slot editor that does not exist — three readings. - "accepts whatever people paste" is false for the GBP cell the demo deliberately refuses two paragraphs later. Accessibility, all of it reachable without a pointer: - The calendar was 84 tab stops with no arrow keys. One roving tab stop now, with arrows, Home/End, PageUp/PageDown, and paging when focus leaves the visible months. In-range days report as pressed, endpoints say which end. - KaTeX rendered html only, so a screen reader got loose glyphs. Paired with MathML, visual layer hidden. - Grid assumptions ("assuming short ton") lived in a title attribute; they are in the described text now. Cell labels name the row item, not "row 1". - Totals read "Accepted mass/price" while a cell is refused, since a sum that silently drops a refusal is the exact failure the demo argues against. Co-authored-by: Cursor --- apps/site/src/app/docs/page.tsx | 9 +- .../components/site/calendar-field-demo.tsx | 110 ++++++++++++++++-- .../src/components/site/data-grid-demo.tsx | 31 +++-- apps/site/src/components/site/katex-math.tsx | 5 +- .../src/components/site/latex-units-demo.tsx | 2 +- apps/site/src/lib/docs-catalog.ts | 2 +- apps/site/src/lib/docs.md.ts | 6 +- 7 files changed, 135 insertions(+), 30 deletions(-) diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index 9c1cddc..5c5ace5 100644 --- a/apps/site/src/app/docs/page.tsx +++ b/apps/site/src/app/docs/page.tsx @@ -856,9 +856,10 @@ export default async function Home() { A column is a schema

The same idea scales past a single field. Give a table column a{' '} - quantityField and every cell in it accepts whatever people paste — - pounds and ounces, a comma decimal, Fahrenheit — while the column keeps one - canonical unit, so the totals row can just add numbers. + 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.

@@ -931,7 +932,7 @@ export default async function Home() { .

- One field, three widgets + One field, three readings

parseDateRange also reads date-to-date spans ( Aug 3 - Aug 9) and whole calendar periods (next week,{' '} diff --git a/apps/site/src/components/site/calendar-field-demo.tsx b/apps/site/src/components/site/calendar-field-demo.tsx index 5e6d98f..3107928 100644 --- a/apps/site/src/components/site/calendar-field-demo.tsx +++ b/apps/site/src/components/site/calendar-field-demo.tsx @@ -8,7 +8,14 @@ import { parseDateRange, } from '@pascal-app/lingo/date' import { ChevronLeft, ChevronRight } from 'lucide-react' -import { useCallback, useMemo, useState } from 'react' +import { + type KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { DemoFrame } from '@/components/site/demo-frame' import { JsonView } from '@/components/site/json-view' @@ -89,8 +96,18 @@ function read(text: string, now: Date): Reading { if (range.ok) { const start = range.start?.date ?? null const end = range.end?.date ?? null - const clock = !range.dated - return { end, mode: clock ? 'slot' : 'range', result: range, start } + // `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) { @@ -151,18 +168,79 @@ export function CalendarFieldDemo() { 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 = - cursor ?? - (start - ? new Date(start.getFullYear(), start.getMonth(), 1) - : new Date(now.getFullYear(), now.getMonth(), 1)) - const months = twoUp ? [anchor, addMonths(anchor, 1)] : [anchor] + 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) => { @@ -293,7 +371,7 @@ export function CalendarFieldDemo() {

-
+
{months.map((month, index) => (
Editable shipment rows. Each column parses free text into one canonical unit.
@@ -369,8 +371,13 @@ export function DataGridDemo() {
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
diff --git a/apps/site/src/components/site/data-grid-demo.tsx b/apps/site/src/components/site/data-grid-demo.tsx index 0222f9a..329891b 100644 --- a/apps/site/src/components/site/data-grid-demo.tsx +++ b/apps/site/src/components/site/data-grid-demo.tsx @@ -182,13 +182,13 @@ function LingoCell({ columnKey, columns, onChange, - rowIndex, + rowLabel, value, }: { columnKey: FieldKey columns: Record onChange: (next: string) => void - rowIndex: number + rowLabel: string value: string }) { const reading = useMemo(() => read(columnKey, value, columns), [columnKey, value, columns]) @@ -204,7 +204,7 @@ function LingoCell({ {reading.message} ) : ( - - {reading.display} - + <> + + {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} + )} @@ -274,7 +280,7 @@ export function DataGridDemo() { columnKey={key} columns={fields} onChange={(next) => update(ctx.row.original.id, key, next)} - rowIndex={ctx.row.index} + rowLabel={ctx.row.original.sku || `row ${ctx.row.index + 1}`} value={ctx.getValue()} /> ), @@ -389,12 +395,19 @@ export function DataGridDemo() {
+ {/* 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. */}
-
Total mass
+
+ {totals.refused > 0 ? 'Accepted mass' : 'Total mass'} +
{totals.mass} kg
-
Total price
+
+ {totals.refused > 0 ? 'Accepted price' : 'Total price'} +
{totals.price} USD
diff --git a/apps/site/src/components/site/katex-math.tsx b/apps/site/src/components/site/katex-math.tsx index 9fde691..9128dac 100644 --- a/apps/site/src/components/site/katex-math.tsx +++ b/apps/site/src/components/site/katex-math.tsx @@ -24,7 +24,10 @@ export function KatexMath({ () => katex.renderToString(tex, { displayMode: display, - output: 'html', + // `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], diff --git a/apps/site/src/components/site/latex-units-demo.tsx b/apps/site/src/components/site/latex-units-demo.tsx index 0b4cd96..8a94560 100644 --- a/apps/site/src/components/site/latex-units-demo.tsx +++ b/apps/site/src/components/site/latex-units-demo.tsx @@ -46,7 +46,7 @@ export function LatexUnitsDemo() { return ( } detailsLabel="Output" stageClassName="min-h-[30rem] justify-start" diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts index bd4b84a..0d4aeb2 100644 --- a/apps/site/src/lib/docs-catalog.ts +++ b/apps/site/src/lib/docs-catalog.ts @@ -308,7 +308,7 @@ export const docsNavGroups: DocsNavGroup[] = [ ]), page( 'dates-calendar', - 'One field, three widgets', + 'One field, three readings', 'Date-to-date spans, calendar periods, and slots in one input.', [ 'calendar', diff --git a/apps/site/src/lib/docs.md.ts b/apps/site/src/lib/docs.md.ts index 02908de..55769b2 100644 --- a/apps/site/src/lib/docs.md.ts +++ b/apps/site/src/lib/docs.md.ts @@ -277,7 +277,7 @@ useForm({ resolver: standardSchemaResolver(shipment) })`, '', '### A column is a schema', '', - 'The same field works per table column. Attach a `quantityField` to a column and every cell accepts whatever people paste — `3 lb 4 oz`, `1,2 kg`, `72°F` — while the column keeps 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.', + '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', '', @@ -309,9 +309,9 @@ 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 widgets', + '### 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..2026-08-09`), and a whole calendar period (`next week`, `this weekend`, `next month`, `next quarter`) expanded to its real first and last day. 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.', + '`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..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. 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.', '', '## Locales', '', From bc426608afb26d0810968cdbb63c92b859c7e1bb Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 18:16:04 +0200 Subject: [PATCH 07/15] fix(site): unmangle the FAQ question and give the rows an affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first question rendered as: How do I parse "5'11\"" into meters — a literal backslash on the page. Inside a template literal \\" emits a backslash and a quote, and the value needs no escaping at all; line 20 of this same file already writes 5'11" correctly. Dropped the wrapping quotes too, since nesting them around an inch mark is what invited the escape. The answer had the same escape buried in a code expression. Rewrote it as prose that names the option and the result without a nested string delimiter — the 1.8034 figure is verified against the parser. The rows hid the native disclosure marker and put nothing in its place, so six cards sat there with no sign they opened. Added a chevron that rotates on open, a hover tint, and the site's standard focus ring, all on the existing motion tokens. Co-authored-by: Cursor --- .../src/components/site/landing-sections.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/site/src/components/site/landing-sections.tsx b/apps/site/src/components/site/landing-sections.tsx index 5d3084d..0b14de7 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', }, { @@ -359,15 +360,19 @@ export function LandingSections() {
{landingFaq.map((item) => (
- + {item.question} + -

{item.answer}

+

{item.answer}

Read more → From b0b4ffbd827e959120c4254bd75a9c9ccf5f5637 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 18:37:17 +0200 Subject: [PATCH 08/15] fix(ai): tell models the whole date-range grammar, not just time slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dateRangeField accepts everything parseDateRange does — slots, dated spans, and whole calendar periods — but its JSON Schema description advertised only "a natural-language time slot like 2pm to 4pm". That description is the entire prompt the model gets for the field, so a model had no reason to emit "next week" or "Aug 3 - Aug 9" into a field that canonicalizes both. Verified against the built field: all four shapes return a value, "Q3" still rejects with UNSUPPORTED_DATE, and a missing now still fails with NOW_REQUIRED. The TSDoc made the same omission, which is what an agent reads through the .d.ts rather than the site. Co-authored-by: Cursor --- packages/lingo/src/ai/date-range-field.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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".' } From d4881a6d09538657433dfaf6009738aeaf311d8c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 18:37:43 +0200 Subject: [PATCH 09/15] docs: the agent-facing surfaces never mentioned calendar ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar ranges shipped two commits ago, but every surface an agent actually reads still described parseDateRange as a clock-slot parser: llms.txt, the package README, the skills/lingo skill, the site's markdown mirror, and the TSDoc. An agent reading any one of them would conclude "next week" and "Aug 3 - Aug 9" are unsupported and hand-roll a parser instead. Each now states the three shapes, the closing-side widening asymmetry ("July to August" ends Aug 31, "from August" opens on the 1st), the RANGE_REVERSED swap, and — just as important — what is deliberately absent, so an agent stops rather than retries: quarters, elliptical right sides, and dash-joined ISO dates with no spaces. Copy the reviewers can disprove, all executed against the built library: - /docs/dates.md advertised "2026-08-03..2026-08-09" as a supported span. ".." is not a separator in RANGE_SPLITS and that input returns UNSUPPORTED_DATE. Replaced with a form that parses, plus the spaced-dash rule that explains why the ISO case is special. - /llms.txt never listed @pascal-app/lingo/react-native, so the served index advertised 12 of 13 published entry points. - The markdown mirror had no completions section at all, while the HTML page has one — @pascal-app/lingo/complete appeared in neither /llms-full.txt nor /docs/parse.md. - The skill's entry table omitted ./core and its issue-code list read as exhaustive while naming 12 of 33; it now says which handful you branch on and points at llms.txt for the rest. Bumped to 1.1.0. - The root README's layout table never mentioned skills/, so the skill was undiscoverable from the repo's front door. Backlogged rather than fixed: UNSUPPORTED_DATE still suggests "try 2pm to 4pm" to someone who typed "Q3", which sends them the wrong way. Changing that copy moves corpus rows, so it wants its own change. Co-authored-by: Cursor --- README.md | 1 + apps/site/public/llms-small.txt | 12 +++++++-- apps/site/src/lib/code-snippets.ts | 4 +++ apps/site/src/lib/docs.md.ts | 17 ++++++++++--- apps/site/src/lib/llms-index.ts | 2 +- packages/lingo/CHANGELOG.md | 23 +++++++++++++++++ packages/lingo/README.md | 29 +++++++++++++++++++--- packages/lingo/llms.txt | 12 +++++++-- packages/lingo/src/date/parse.ts | 22 +++++++++++++--- plans/backlog.md | 6 +++++ skills/lingo/SKILL.md | 40 +++++++++++++++++++++++++++--- 11 files changed, 150 insertions(+), 18 deletions(-) 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/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/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.md.ts b/apps/site/src/lib/docs.md.ts index 55769b2..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`.', '', @@ -311,7 +320,9 @@ useForm({ resolver: standardSchemaResolver(shipment) })`, '', '### 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..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. 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.', + '`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', '', @@ -338,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/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/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index a897de7..20a8b41 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -48,8 +48,31 @@ change**, even if the API is untouched. - Per-locale corpus contracts gained 96 rows and the English contract 5, covering the grammar above plus the number-word fixes below. +### Changed + +- `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. + ### 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. - 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 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/src/date/parse.ts b/packages/lingo/src/date/parse.ts index 8b762fa..217b70a 100644 --- a/packages/lingo/src/date/parse.ts +++ b/packages/lingo/src/date/parse.ts @@ -416,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 { diff --git a/plans/backlog.md b/plans/backlog.md index f04e260..2b79035 100644 --- a/plans/backlog.md +++ b/plans/backlog.md @@ -109,6 +109,12 @@ surfaces mid-task, add it here and keep going — don't act on it. `UNSUPPORTED_DATE`. Fiscal-year offsets are the reason this isn't free: `Q3` means different months to different companies, so it needs a `fiscalYearStart` option before it can be more than a calendar-quarter guess. +- **`UNSUPPORTED_DATE` still suggests only a time slot** — the message hint is + `try "2pm to 4pm"`, written before calendar ranges existed. A person typing + `Q3` or `Aug 3-9` is asking for a date span, so the suggestion sends them the + wrong way. The hint should reflect the shape the input looks like it wanted + (a date span → `"Aug 3 - Aug 9"`, a period → `"next week"`). Changing the copy + moves corpus rows, so it wants its own change rather than a docs pass. ## Wire schema & types diff --git a/skills/lingo/SKILL.md b/skills/lingo/SKILL.md index 64dcc0e..73ae43b 100644 --- a/skills/lingo/SKILL.md +++ b/skills/lingo/SKILL.md @@ -3,7 +3,7 @@ name: lingo description: Parse natural-language quantities, units, dates, and ranges ("5'11\"", "1.5 cups", "72 in to cm", "three days ago", "between 5 and 10 kg", "it's hot") into canonical, validated values with issue codes and spans — and humanize them back. Use when a form input, LLM tool schema, MCP handler, or import pipeline takes free-text measurements/units/dates. Covers Standard Schema fields (quantityField/dateField/lingoObject) for the AI SDK and MCP, the headless , 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,35 @@ 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, dated: false +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 (runtime-only; never serialized). +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 +197,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 From 02ab98749a9305467238297b39dd3faf863db1e1 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 18:37:59 +0200 Subject: [PATCH 10/15] test(gates): fail when llms.txt drops an entry point or an issue code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs-sync gate only compared the Kinds line, which is why the served index could advertise 12 of 13 entry points indefinitely and nobody noticed. A gate that checks one line out of a reference document is a gate you can walk past. check-docs-sync now asserts both llms.txt copies against package.json exports and against every ISSUE_CODES member, and — the failure mode the presence checks cannot see — that the served copy is byte-identical to the source, since site:sync copies it verbatim and a stale copy looks fine on its own. check-llms-index asserts the generated index advertises every published export. Both were negative-tested rather than assumed: removing react-native from the index and RANGE_REVERSED from llms.txt each fails the corresponding gate, and both pass again once restored. Co-authored-by: Cursor --- packages/lingo/scripts/check-docs-sync.mjs | 51 ++++++++++++++++++--- packages/lingo/scripts/check-llms-index.mjs | 22 ++++++++- 2 files changed, 65 insertions(+), 8 deletions(-) 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') From 0f9021d86da07a16b404a6929a87de4598d7440d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 29 Jul 2026 18:43:03 +0200 Subject: [PATCH 11/15] fix(site): restore docs heading hierarchy, nav order, and sidebar contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /docs sidebar listed two subsections in the reverse of the document order it highlights while scrolling, so the nav disagreed with the page. Section and subsection headings both rendered at 14px, flattening the outline and disagreeing with the 16px/15px scale the standalone /docs/
pages use for the same content. One heading was also duplicated back-to-back, and "Kinds" hand-rolled its own h3 rather than using SubHeading, leaving it the only depth-3 anchor without an anchor link. Sidebar group labels and subtitles used opacity-modified muted foreground at 11px, measuring 2.1:1 and 2.9:1 in light mode — below WCAG AA. They now use the plain token. Co-authored-by: Cursor --- apps/site/src/app/docs/page.tsx | 2 +- .../src/components/site/anchor-heading.tsx | 4 +-- .../src/components/site/completions-demo.tsx | 2 +- .../src/components/site/coverage-explorer.tsx | 6 ++-- apps/site/src/components/site/docs-nav.tsx | 4 +-- apps/site/src/lib/docs-catalog.ts | 28 +++++++++---------- packages/lingo/CHANGELOG.md | 14 ++++++++++ 7 files changed, 36 insertions(+), 24 deletions(-) diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index 5c5ace5..b2155fd 100644 --- a/apps/site/src/app/docs/page.tsx +++ b/apps/site/src/app/docs/page.tsx @@ -1069,7 +1069,7 @@ export default async function Home() {
Built-in lingo unit kinds 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/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts index 0d4aeb2..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', diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index 20a8b41..0ff4f00 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -73,6 +73,20 @@ change**, even if the API is untouched. 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 `mil millones` is 10^9 (was 1,001,000) and From 9c3bb2b6de08450443e1c03084cd8359c1240ab2 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 1 Aug 2026 19:37:23 -0400 Subject: [PATCH 12/15] docs(changelog): make [Unreleased] stand alone as release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This text becomes the GitHub release body, so it has to read to someone who has never seen the repo. It had two `### Changed` headings, so half the changes sorted below Fixed and Keep a Changelog order was broken. Corrections found by diffing behavior against the published 0.3.0 rather than against the commits: `mil millones` already read 1e9 (the broken input was `dos mil millones`, 1,002,000), `milliard` was already correct, and French `billion` silently read 1e9 where the long scale wants 1e12 — that factor-of-1000 fix was unrecorded. Loading a zh/ja pack also stopped emitting AMBIGUOUS_UNIT on `¥`, which no entry mentioned. Also states the SemVer position outright: every corpus row pinned at 0.3.0 is unchanged, and the five readings that do change are named so an upgrader can check them. Co-authored-by: Cursor --- packages/lingo/CHANGELOG.md | 74 +++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index 0ff4f00..e2b4684 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -36,12 +36,13 @@ 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. @@ -50,9 +51,45 @@ change**, even if the API is untouched. ### 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 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. +- Root README: links the docs site, the npm package, and the agent `llms.txt` + index directly, with an install one-liner. +- Docs site: every markdown section now also renders as a standalone, + indexable HTML page at `/docs/
` (agent markdown stays at + `/docs/
.md`); legacy demo redirects (`/forms`, `/escalation`, + `/coverage`, `/integrations`) became permanent (308); the landing page + 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 @@ -89,8 +126,13 @@ change**, even if the API is untouched. `--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 `mil millones` is 10^9 (was 1,001,000) and - `dos mil millones` is 2×10^9. Both were silent wrong answers. + 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 @@ -104,28 +146,6 @@ change**, even if the API is untouched. quantity (`三至五天`, `5公斤左右`). Unit aliases stay atomic through the split, so `一時間半` remains 1.5 hours rather than splitting at the range word `間`. -### Changed - -- 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. -- 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. -- Root README: links the docs site, the npm package, and the agent `llms.txt` - index directly, with an install one-liner. -- Docs site: every markdown section now also renders as a standalone, - indexable HTML page at `/docs/
` (agent markdown stays at - `/docs/
.md`); legacy demo redirects (`/forms`, `/escalation`, - `/coverage`, `/integrations`) became permanent (308); the landing page - 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. - ## [0.3.0] - 2026-07-21 ### Added From 804ff3d20b3e1e91253fa22f272c1a9dfda39c37 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 1 Aug 2026 19:37:32 -0400 Subject: [PATCH 13/15] fix(docs): claims that did not survive being executed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked the prose against the built library instead of reading it: - The skill told agents a time slot returns `dated: false`. `dated` is optional and absent on slots, so `=== false` never matches — an agent branching on it would route every slot down the calendar path. - The landing page claimed nine languages of number words. Six packs carry a numberWords table (en, es, fr, pt, zh, ja); en-gb inherits English. - The /docs Locales copy listed six pack subpaths and omitted `locales/en`, disagreeing with its own markdown mirror and package.json. - npm keywords advertised dates and durations but nothing about locales, which is most of what shipped since 0.3.0. Co-authored-by: Cursor --- apps/site/src/app/docs/page.tsx | 6 +++--- apps/site/src/components/site/landing-sections.tsx | 4 ++-- packages/lingo/package.json | 2 ++ skills/lingo/SKILL.md | 5 +++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index b2155fd..c73f208 100644 --- a/apps/site/src/app/docs/page.tsx +++ b/apps/site/src/app/docs/page.tsx @@ -953,9 +953,9 @@ export default async function Home() { >

- 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.

diff --git a/apps/site/src/components/site/landing-sections.tsx b/apps/site/src/components/site/landing-sections.tsx index 0b14de7..782897d 100644 --- a/apps/site/src/components/site/landing-sections.tsx +++ b/apps/site/src/components/site/landing-sections.tsx @@ -243,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 {' '} 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/skills/lingo/SKILL.md b/skills/lingo/SKILL.md index 73ae43b..3230608 100644 --- a/skills/lingo/SKILL.md +++ b/skills/lingo/SKILL.md @@ -152,14 +152,15 @@ 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, dated: false +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 (runtime-only; never serialized). +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 From 0561887e1f2658986a4e97ad87ae63df656e3d6d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 1 Aug 2026 19:37:42 -0400 Subject: [PATCH 14/15] docs(knowledge): name the date-range vocabulary before it drifts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four surfaces already agree on three shape names — time slot, dated span, calendar period — but the glossary that is supposed to settle naming never heard of any of them, and two surfaces had started saying "date-to-date span" instead. CONTEXT.md now owns the terms, and records that unqualified "range" still means the quantity kind. Counts that stopped being true: the corpus is six contracts now, not one file; the suite is 1,029 tests, not 600+; the check gate grew two scripts; plan 029 pinned IssueCode at 31 and there are 33. Plan 033 shipped two waves and was never in the index. Three plans were edited after their `updated:` frontmatter said they were. Co-authored-by: Cursor --- CONTEXT.md | 14 ++++++++++++-- plans/005-dates-and-durations.md | 2 +- plans/012-agent-and-llm-friendliness.md | 2 +- plans/029-schema-reference-and-adapters.md | 2 +- plans/031-locale-packs.md | 2 +- plans/README.md | 1 + wiki/architecture.md | 11 +++++++---- 7 files changed, 24 insertions(+), 10 deletions(-) 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/plans/005-dates-and-durations.md b/plans/005-dates-and-durations.md index d8d3276..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`) 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/README.md b/plans/README.md index 9ced4b3..66bde1c 100644 --- a/plans/README.md +++ b/plans/README.md @@ -102,6 +102,7 @@ Driver: Date: Sat, 1 Aug 2026 19:37:50 -0400 Subject: [PATCH 15/15] feat(release): publish the curated changelog, not a commit list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gh release create --generate-notes` threw away the hand-written changelog and shipped a list of commit subjects instead, so the release page said nothing the changelog says. Extraction lives in a script rather than inline shell so it can be run locally against a real CHANGELOG before a release depends on it, and the body reaches gh through --notes-file: the section is full of backticks, quotes and parentheses that shell interpolation would execute or break. npm has already published by the time this step runs, so a missing or empty section must not strand a published package without a release — the script exits non-zero in both cases and the step falls back to --generate-notes. Co-authored-by: Cursor --- .github/workflows/release.yml | 20 +++++-- packages/lingo/scripts/changelog-section.mjs | 56 ++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 packages/lingo/scripts/changelog-section.mjs 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/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`) +}