diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx
index c73f208..b0ae09e 100644
--- a/apps/site/src/app/docs/page.tsx
+++ b/apps/site/src/app/docs/page.tsx
@@ -21,10 +21,13 @@ 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 { RemindMePopoverBlock } from '@/components/site/remind-me-popover'
+import { SemanticTokenHighlighter } from '@/components/site/semantic-token-highlighter'
import {
StrictnessVariantWall,
SystemNumberFormatVariantWall,
} from '@/components/site/variant-walls'
+import { WorkflowTriggerBlock } from '@/components/site/workflow-trigger-block'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import {
@@ -864,6 +867,17 @@ export default async function Home() {
+
+
+ Thresholds out of prose
+
+ Automation prompts mix nouns a rules engine already knows with thresholds it does
+ not. findQuantities pulls the bounds (below $10k,{' '}
+ over 15 minutes) out of the sentence with spans into the original
+ string and the same issues a form field would see.
+
+
+
+
+
+ Color only where it read
+
+ Lingo reads one expression at a time and reports one span per result, so a sentence
+ has to be pre-segmented before it can be colored. The demo proposes slices with
+ regexes and lets parseDate, parseDateRange, and{' '}
+ parseDuration confirm each one — a slice lingo declines stays plain.
+
+
+
+
+
+ Presets and prose, one reader
+
+ Quick presets and a free-text field share one reader, so the row a preset shows is
+ the same reading typing its phrase would give. A phrase lingo cannot read shows
+ lingo's own message instead of a made-up time.
+
+
+ read(value, now), [value, now])
const { mode, start, end } = reading
const twoUp = mode === 'range'
+ // The JSON prints UTC instants, and SSR_NOW is a local wall-clock time: the
+ // same wall clock is a different instant on the server and in the visitor's
+ // zone, so the panel waits for hydration instead of tripping React #418.
+ const output = hydrated ? JSON.stringify(reading.result, null, 2) : ''
// The calendar follows the parse unless the reader has paged away from it.
const anchor = useMemo(
@@ -287,7 +291,7 @@ export function CalendarFieldDemo() {
return (
}
+ details={}
detailsLabel="Output"
stageClassName="min-h-[34rem] justify-start"
title="Adaptive date field"
diff --git a/apps/site/src/components/site/coverage-explorer.tsx b/apps/site/src/components/site/coverage-explorer.tsx
index e556448..776eea3 100644
--- a/apps/site/src/components/site/coverage-explorer.tsx
+++ b/apps/site/src/components/site/coverage-explorer.tsx
@@ -8,6 +8,7 @@ import { DemoFrame } from '@/components/site/demo-frame'
import { DocsPane, DocsSplitPane } from '@/components/site/docs-split-pane'
import { JsonView } from '@/components/site/json-view'
import { Readout } from '@/components/site/readout'
+import { useHydrated } from '@/components/site/use-hydrated'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
@@ -65,11 +66,15 @@ function clockLabel(date: Date): string {
}
export function CoverageExplorer() {
+ const hydrated = useHydrated()
const [index, setIndex] = useState(0)
const current = aliasExamples[index] ?? aliasExamples[0]
const result = useMemo(() => lingo(current.text, { kind: current.kind }), [current])
const aliasCopyText = useMemo(() => JSON.stringify(resultToPlain(result), null, 2), [result])
const fuzzyCopyText = useMemo(() => JSON.stringify(temperatureVocabs, null, 2), [])
+ // Day words resolve in the visitor's zone ("today" at 12:00Z is already
+ // tomorrow in Auckland), so the instants cannot match between the server
+ // and the browser; the column fills in after hydration.
const dateRows = useMemo(
() =>
dateExamples.map((example) => {
@@ -77,12 +82,15 @@ export function CoverageExplorer() {
now: referenceNow,
dayFirst: true,
})
- const value = parsed.ok
- ? parsed.date.toISOString().replace('.000Z', 'Z')
- : (parsed.issues[0]?.code ?? 'UNSUPPORTED_DATE')
+ let value = ''
+ if (!parsed.ok) {
+ value = parsed.issues[0]?.code ?? 'UNSUPPORTED_DATE'
+ } else if (hydrated) {
+ value = parsed.date.toISOString().replace('.000Z', 'Z')
+ }
return { example, parsed, value }
}),
- [],
+ [hydrated],
)
const dateCopyText = useMemo(
() =>
diff --git a/apps/site/src/components/site/landing-sections.tsx b/apps/site/src/components/site/landing-sections.tsx
index 782897d..1900998 100644
--- a/apps/site/src/components/site/landing-sections.tsx
+++ b/apps/site/src/components/site/landing-sections.tsx
@@ -3,6 +3,9 @@ import { ChevronDownIcon } from 'lucide-react'
import Link from 'next/link'
import { CodeBlock } from '@/components/site/code-block'
+import { RemindMePopoverBlock } from '@/components/site/remind-me-popover'
+import { SemanticTokenHighlighter } from '@/components/site/semantic-token-highlighter'
+import { WorkflowTriggerBlock } from '@/components/site/workflow-trigger-block'
import { Badge } from '@/components/ui/badge'
import {
Table,
@@ -259,6 +262,27 @@ export function LandingSections() {
+
+
+
+
+
+
+
+
+
+
+
+
(hydrated ? new Date() : SSR_NOW), [hydrated])
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [condition, setCondition] = useState('if no reply')
+ // Only the phrase is stored; the date is re-read from `now`, so a reminder
+ // set before hydration is never a stale SSR instant.
+ const [reminder, setReminder] = useState({
+ condition: 'if no reply',
+ phrase: PRESETS[0].phrase,
+ })
+
+ const queryReading = useMemo(() => read(query, now), [query, now])
+ const reminderReading = useMemo(() => read(reminder.phrase ?? '', now), [reminder.phrase, now])
+ const presetReadings = useMemo(
+ () => PRESETS.map((preset) => (preset.phrase ? read(preset.phrase, now) : null)),
+ [now],
+ )
+ // The JSON prints UTC instants, and a local SSR_NOW is a different instant in
+ // every zone, so the panel waits for hydration.
+ const output = hydrated ? JSON.stringify(reminderReading.result, null, 2) : ''
+
+ const commit = (phrase: string | null) => {
+ setReminder({ condition, phrase })
+ setQuery('')
+ setOpen(false)
+ }
+
+ return (
+ }
+ detailsLabel="Output"
+ stageClassName="min-h-[16rem] justify-start"
+ title="Remind me"
+ >
+
+
+
+ Review Q3 forecast
+ Thread from Dana · 3 messages
+
+ Spans index the original string, so the chips above are sliced from it — no re-tokenizing
+ on the way to the UI. Edit the rule to see the bound move or disappear.
+
+
+
+ )
+}
diff --git a/apps/site/src/components/ui/popover.tsx b/apps/site/src/components/ui/popover.tsx
new file mode 100644
index 0000000..4930ec6
--- /dev/null
+++ b/apps/site/src/components/ui/popover.tsx
@@ -0,0 +1,56 @@
+'use client'
+
+import { Popover as PopoverPrimitive } from '@base-ui/react/popover'
+
+import { cn } from '@/lib/utils'
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return
+}
+
+function PopoverContent({
+ align = 'start',
+ alignOffset = 0,
+ side = 'bottom',
+ sideOffset = 6,
+ className,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick) {
+ return (
+
+
+
+
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+export { Popover, PopoverContent, PopoverTitle, PopoverTrigger }
diff --git a/apps/site/src/lib/date-display.ts b/apps/site/src/lib/date-display.ts
new file mode 100644
index 0000000..472f710
--- /dev/null
+++ b/apps/site/src/lib/date-display.ts
@@ -0,0 +1,63 @@
+import type { DateGrain, DateRange, DateResult } from '@pascal-app/lingo/date'
+
+/** True when a reading pinned a time of day, not just a calendar day. */
+export function isTimedGrain(grain: DateGrain | undefined): boolean {
+ return grain === 'hour' || grain === 'minute' || grain === 'second'
+}
+
+/** "Sat, Sep 12" — the day without a year, for tight rows. */
+export function formatDay(date: Date): string {
+ return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
+}
+
+/** "Sat, Sep 12, 2026" — the day with its year. */
+export function formatDate(date: Date): string {
+ return date.toLocaleDateString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+}
+
+/** "PDT" — the short zone name, which only exists on the visitor's clock. */
+export function formatZone(date: Date): string | undefined {
+ return date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
+}
+
+/** "9:00 AM" */
+export function formatClock(date: Date): string {
+ return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
+}
+
+/** Day plus clock when the grain carries one: "Sat, Sep 12 · 9:00 AM". */
+export function formatWhen(date: Date, grain: DateGrain | undefined): string {
+ return isTimedGrain(grain) ? `${formatDay(date)} · ${formatClock(date)}` : formatDay(date)
+}
+
+/**
+ * One line for a range: dates when the endpoints are calendar days, day plus
+ * clock when either endpoint carries a time of day, so an overnight shift keeps
+ * its 10 PM and 2 AM.
+ */
+export function formatRange(range: DateRange): string {
+ const start = range.start
+ const end = range.end
+ if (!(start || end)) {
+ return 'open range'
+ }
+ const timed = isTimedGrain(start?.grain) || isTimedGrain(end?.grain)
+ const show = (date: Date, grain: DateGrain | undefined) =>
+ timed ? formatWhen(date, grain) : formatDay(date)
+ if (start && end) {
+ return `${show(start.date, start.grain)} → ${show(end.date, end.grain)}`
+ }
+ if (start) {
+ return `from ${show(start.date, start.grain)}`
+ }
+ return `until ${show(end!.date, end!.grain)}`
+}
+
+export function formatDateResult(result: DateResult): string {
+ return formatWhen(result.date, result.grain)
+}
diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts
index aca1acc..d51727c 100644
--- a/apps/site/src/lib/docs-catalog.ts
+++ b/apps/site/src/lib/docs-catalog.ts
@@ -247,6 +247,13 @@ export const docsNavGroups: DocsNavGroup[] = [
['data grid', 'table', 'tanstack', 'react-table', 'spreadsheet', 'cell', 'column', 'bulk'],
{ depth: 3, markdownSectionId: 'one-schema' },
),
+ page(
+ 'one-schema-workflow',
+ 'Thresholds out of prose',
+ 'Pull numeric thresholds out of automation prompts with findQuantities.',
+ ['workflow', 'rule', 'trigger', 'automation', 'threshold', 'findQuantities', 'bound'],
+ { depth: 3, markdownSectionId: 'one-schema' },
+ ),
],
},
{
@@ -321,6 +328,20 @@ export const docsNavGroups: DocsNavGroup[] = [
],
{ depth: 3, markdownSectionId: 'dates' },
),
+ page(
+ 'dates-semantic-tokens',
+ 'Color only where it read',
+ 'Pre-segment a sentence, then let lingo confirm each date, time, and duration slice.',
+ ['token', 'highlighter', 'span', 'slice', 'category', 'legend', 'confirm'],
+ { depth: 3, markdownSectionId: 'dates' },
+ ),
+ page(
+ 'dates-remind-me',
+ 'Presets and prose, one reader',
+ 'Scheduling popover whose presets and free text share one date reader.',
+ ['remind me', 'popover', 'scheduling', 'preset', 'tomorrow', 'weekend', 'someday'],
+ { depth: 3, markdownSectionId: 'dates' },
+ ),
page(
'calculations',
'Calculations',
diff --git a/apps/site/src/lib/semantic-spans.ts b/apps/site/src/lib/semantic-spans.ts
new file mode 100644
index 0000000..dff2740
--- /dev/null
+++ b/apps/site/src/lib/semantic-spans.ts
@@ -0,0 +1,179 @@
+import { findQuantities } from '@pascal-app/lingo'
+import { humanizeDuration, parseDate, parseDuration } from '@pascal-app/lingo/date'
+
+import { formatClock, formatDay, formatWhen, isTimedGrain } from '@/lib/date-display'
+
+export type TokenCategory = 'date' | 'time' | 'duration' | 'quantity' | 'plain'
+
+export interface ClassifiedSpan {
+ category: TokenCategory
+ end: number
+ /** What lingo read the slice as — absent for plain text. */
+ reading?: string
+ start: number
+ text: string
+}
+
+interface Candidate extends ClassifiedSpan {
+ priority: number
+}
+
+const WEEKDAY = 'monday|tuesday|wednesday|thursday|friday|saturday|sunday'
+const MONTH =
+ 'january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sept|sep|oct|nov|dec'
+const DAY_UNIT = 'days?|weeks?|wks?|months?|years?|yrs?'
+const CLOCK_UNIT = 'seconds?|secs?|minutes?|mins?|hours?|hrs?'
+const SMALL_WORD = 'a|an|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve'
+const HOUR_WORD = 'one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve'
+
+/**
+ * Regexes only propose slices; `parseDate` has to accept each one before it is
+ * shown as a token. Lingo exposes one whole-match span per result, not
+ * sub-token spans, so pre-segmentation is how a sentence is broken up — and
+ * validation is what keeps the colors honest.
+ */
+const DATE_CANDIDATES = [
+ /\bday\s+after\s+tomorrow\b/gi,
+ /\b(?:today|tomorrow|yesterday)\b/gi,
+ new RegExp(`\\b(?:next|last|this)\\s+(?:week|weekend|month|year|${WEEKDAY})\\b`, 'gi'),
+ new RegExp(`\\b(?:${WEEKDAY})\\b`, 'gi'),
+ /\bend\s+of\s+(?:the\s+)?(?:week|month|year)\b/gi,
+ new RegExp(`\\bin\\s+(?:${SMALL_WORD}|\\d+)\\s+(?:${DAY_UNIT})\\b`, 'gi'),
+ new RegExp(`\\b(?:${SMALL_WORD}|\\d+)\\s+(?:${DAY_UNIT})\\s+ago\\b`, 'gi'),
+ new RegExp(`\\b(?:${MONTH})\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?(?:\\s*,?\\s*\\d{4})?\\b`, 'gi'),
+ new RegExp(
+ `\\b\\d{1,2}(?:st|nd|rd|th)?\\s+(?:of\\s+)?(?:${MONTH})\\b(?:\\s*,?\\s*\\d{4})?`,
+ 'gi',
+ ),
+ /\b\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?\b/g,
+]
+
+const TIME_CANDIDATES = [
+ /\b(?:tonight|this\s+(?:morning|afternoon|evening))\b/gi,
+ new RegExp(`\\b(?:tomorrow|${WEEKDAY})\\s+(?:morning|afternoon|evening|night)\\b`, 'gi'),
+ new RegExp(`\\bin\\s+(?:${SMALL_WORD}|\\d+)\\s+(?:${CLOCK_UNIT})\\b`, 'gi'),
+ /\b(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.|p\.m\.)\b/gi,
+ /\b(?:at\s+)?\d{1,2}:\d{2}\b/gi,
+ new RegExp(`\\b(?:at\\s+)?(?:${HOUR_WORD}|\\d{1,2})\\s+o'clock\\b`, 'gi'),
+ /\b(?:quarter|half)\s+(?:past|to)\s+(?:\w+)\b/gi,
+ /\b(?:at\s+)?(?:noon|midnight)\b/gi,
+]
+
+const DURATION_CANDIDATES = [
+ /\bhalf\s+an\s+hour\b/gi,
+ new RegExp(`\\b(?:${SMALL_WORD})\\s+(?:${CLOCK_UNIT}|${DAY_UNIT})\\s+and\\s+a\\s+half\\b`, 'gi'),
+ new RegExp(
+ `\\b(?:${SMALL_WORD}|\\d+(?:\\.\\d+)?)\\s*(?:${CLOCK_UNIT}|${DAY_UNIT})(?:\\s+(?:and\\s+)?\\d+\\s*(?:${CLOCK_UNIT}))?\\b`,
+ 'gi',
+ ),
+]
+
+const DAY_WORDS =
+ /\b(?:in|ago|today|tomorrow|tonight|yesterday|this|next|last|morning|afternoon|evening|night)\b|\d{4}-\d{2}-\d{2}/i
+
+function scan(input: string, patterns: RegExp[], visit: (start: number, end: number) => void) {
+ for (const re of patterns) {
+ re.lastIndex = 0
+ let m = re.exec(input)
+ while (m !== null) {
+ if (m[0].length > 0) {
+ visit(m.index, m.index + m[0].length)
+ }
+ m = re.exec(input)
+ }
+ }
+}
+
+export function classifyTextSpans(input: string, now: Date): ClassifiedSpan[] {
+ if (!input) {
+ return []
+ }
+
+ const candidates: Candidate[] = []
+ const propose = (start: number, end: number, category: TokenCategory, reading: string) => {
+ const priority =
+ category === 'time' ? 4 : category === 'date' ? 3 : category === 'duration' ? 2 : 1
+ candidates.push({ category, end, priority, reading, start, text: input.slice(start, end) })
+ }
+
+ const confirmDate = (start: number, end: number, category: 'date' | 'time') => {
+ const slice = input.slice(start, end)
+ const result = parseDate(slice, { now })
+ if (!result.ok) {
+ return
+ }
+ // A relative offset like "in 2 hours" lands in the time list, but the
+ // reading decides the label: the grain is what the user actually pinned.
+ const timed = isTimedGrain(result.grain)
+ const label =
+ category === 'time' && !timed ? 'date' : category === 'date' && timed ? 'time' : category
+ // A bare clock ("at 3pm") only pins a time of day; naming the day lingo
+ // filled in would over-claim what the slice says. Offsets and day words
+ // ("in 20 minutes", "tomorrow morning") do carry a day.
+ const carriesDay = result.known.includes('weekday') || DAY_WORDS.test(slice)
+ const reading = timed
+ ? carriesDay
+ ? formatWhen(result.date, result.grain)
+ : formatClock(result.date)
+ : formatDay(result.date)
+ propose(start, end, label, reading)
+ }
+
+ scan(input, DATE_CANDIDATES, (start, end) => confirmDate(start, end, 'date'))
+ scan(input, TIME_CANDIDATES, (start, end) => confirmDate(start, end, 'time'))
+ scan(input, DURATION_CANDIDATES, (start, end) => {
+ const result = parseDuration(input.slice(start, end))
+ if (result.ok) {
+ propose(start, end, 'duration', humanizeDuration(result.duration))
+ }
+ })
+
+ for (const hit of findQuantities(input)) {
+ const { result } = hit
+ // A bare number ("9" in "at 9am") is not a measurement.
+ if (result.type === 'number') {
+ continue
+ }
+ const reading =
+ result.type === 'quantity'
+ ? result.quantity.format()
+ : result.type === 'range'
+ ? result.range.format()
+ : result.type === 'conversion'
+ ? result.converted.format()
+ : input.slice(hit.span.start, hit.span.end)
+ propose(hit.span.start, hit.span.end, 'quantity', reading)
+ }
+
+ // Highest priority first, then the longest slice, then reading order; take
+ // greedily so a confirmed time is never swallowed by a quantity underneath it.
+ candidates.sort(
+ (a, b) => b.priority - a.priority || b.end - b.start - (a.end - a.start) || a.start - b.start,
+ )
+ const chosen: Candidate[] = []
+ for (const c of candidates) {
+ if (chosen.every((k) => c.end <= k.start || c.start >= k.end)) {
+ chosen.push(c)
+ }
+ }
+ chosen.sort((a, b) => a.start - b.start)
+
+ const spans: ClassifiedSpan[] = []
+ let cursor = 0
+ for (const { priority: _, ...span } of chosen) {
+ if (span.start > cursor) {
+ spans.push({
+ category: 'plain',
+ end: span.start,
+ start: cursor,
+ text: input.slice(cursor, span.start),
+ })
+ }
+ spans.push(span)
+ cursor = span.end
+ }
+ if (cursor < input.length) {
+ spans.push({ category: 'plain', end: input.length, start: cursor, text: input.slice(cursor) })
+ }
+ return spans
+}
diff --git a/bun.lock b/bun.lock
index c5ea11f..6002e7f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -51,7 +51,7 @@
},
"packages/lingo": {
"name": "@pascal-app/lingo",
- "version": "0.3.0",
+ "version": "0.5.0",
"devDependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/react": "^19.2.17",
diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md
index 2fb65ed..6de3123 100644
--- a/packages/lingo/CHANGELOG.md
+++ b/packages/lingo/CHANGELOG.md
@@ -7,6 +7,21 @@ change**, even if the API is untouched.
## [Unreleased]
+### Added
+
+- Docs site: three demos built on spans — a token highlighter that
+ pre-segments a sentence with regexes and lets `parseDate`/`parseDateRange`/
+ `parseDuration` decide what each piece is; a "Remind me" popover whose
+ presets and free-text field read through `./date`; and a workflow-rule
+ list that extracts numeric bounds with `findQuantities`.
+
+### Fixed
+
+- `findQuantities` returned mid-word spans for open-bound ranges and fuzzy
+ spreads that did not start the input (`call mom over 5 min` → `[2, 19)`).
+ The qualifier branch passed a token index where `okRange` expects a
+ normalized offset; spans now start at the qualifier (`over 5 min`).
+
## [0.5.0] - 2026-08-23
### Added
diff --git a/packages/lingo/src/parse/grammar.test.ts b/packages/lingo/src/parse/grammar.test.ts
index 31d3b98..1ac7f6b 100644
--- a/packages/lingo/src/parse/grammar.test.ts
+++ b/packages/lingo/src/parse/grammar.test.ts
@@ -604,6 +604,20 @@ describe('free-text extraction', () => {
expect(hits.map((hit) => hit.result.type)).toEqual(['quantity', 'quantity', 'conversion'])
expect(hits[2]!.result.text.slice(hits[2]!.span.start, hits[2]!.span.end)).toBe('72 in to cm')
})
+
+ it('anchors open-bound and fuzzy-spread spans at the qualifier, not mid-word', () => {
+ const text = 'Page me when AWS credits fall below $10k or the build takes over 15 minutes'
+ const hits = findQuantities(text)
+ expect(hits.map((hit) => text.slice(hit.span.start, hit.span.end))).toEqual([
+ 'below $10k',
+ 'over 15 minutes',
+ ])
+ expect(hits.map((hit) => hit.result.type)).toEqual(['range', 'range'])
+
+ const fuzzy = 'wait a few minutes first'
+ const [spread] = findQuantities(fuzzy)
+ expect(fuzzy.slice(spread!.span.start, spread!.span.end)).toBe('a few minutes')
+ })
})
describe('errors', () => {
diff --git a/packages/lingo/src/parse/range.ts b/packages/lingo/src/parse/range.ts
index c6e7321..421b497 100644
--- a/packages/lingo/src/parse/range.ts
+++ b/packages/lingo/src/parse/range.ts
@@ -100,7 +100,7 @@ export function parseRangeOrQty(p: ParserState, i: number, atStart: boolean): Pa
approximate: quals.approximate || a.approximate,
})
return {
- result: okRange(p, range, i, end),
+ result: okRange(p, range, p.tokens[i]!.start, end),
nextToken: trailing?.next ?? a.nextToken,
}
}
@@ -136,7 +136,7 @@ export function parseRangeOrQty(p: ParserState, i: number, atStart: boolean): Pa
max: { base: toBase(unit, a.spread[1]), unit: a.headUnit },
approximate: true,
})
- return { result: okRange(p, range, i, a.normEnd), nextToken: a.nextToken }
+ return { result: okRange(p, range, p.tokens[i]!.start, a.normEnd), nextToken: a.nextToken }
}
// Single quantity / bare number.