From e8956ea7a6eff9e0272572d159fd205214969834 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 20:56:20 +0000 Subject: [PATCH 1/6] feat(site): add semantic token highlighter, remind-me popover, and workflow trigger blocks Co-authored-by: Aymeric Rabot --- apps/site/src/app/docs/page.tsx | 33 ++ .../src/components/site/landing-sections.tsx | 24 ++ .../src/components/site/remind-me-popover.tsx | 220 +++++++++++++ .../site/semantic-token-highlighter.tsx | 276 ++++++++++++++++ .../site/workflow-trigger-block.tsx | 296 ++++++++++++++++++ apps/site/src/lib/docs-catalog.ts | 21 ++ apps/site/src/lib/semantic-spans.ts | 189 +++++++++++ bun.lock | 2 +- packages/lingo/CHANGELOG.md | 7 + 9 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 apps/site/src/components/site/remind-me-popover.tsx create mode 100644 apps/site/src/components/site/semantic-token-highlighter.tsx create mode 100644 apps/site/src/components/site/workflow-trigger-block.tsx create mode 100644 apps/site/src/lib/semantic-spans.ts diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index c73f208..17f5c5c 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,16 @@ export default async function Home() { + +
+ Workflow automation triggers +

+ In agent workflows, prompts frequently combine target entities, action verbs, and + numerical thresholds. Lingo powers live extraction of bounds ( + below $10k) and quantities from natural language commands. +

+
+
+ +
+ Semantic token highlighting +

+ Lingo's precise original-input spans make it straightforward to build + multi-category colorized tokenizers (day or date, clock time, repeats, and duration) + running at zero latency in the browser. +

+
+ + +
+ Interactive reminder block +

+ A production-ready scheduling popover block (inspired by modern productivity apps + like Superhuman and Linear). Quick presets, custom relative time input, and + conditional triggers in one compact control. +

+
+
+ + + + + + + + + + + + string + id: string + label: string +} + +const PRESET_ITEMS: PresetItem[] = [ + { + id: 'tomorrow', + label: 'tomorrow', + compute: (now: Date) => { + const res = parseDate('tomorrow at 8am', { now }) + if (res.ok) { + const d = res.date + const weekday = d.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() + return `${weekday}, 8:00 AM` + } + return 'SAT, 8:00 AM' + }, + }, + { + id: 'next-week', + label: 'next week', + compute: (now: Date) => { + const res = parseDate('next monday at 8am', { now }) + if (res.ok) { + const d = res.date + const weekday = d.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() + return `${weekday}, 8:00 AM` + } + return 'MON, 8:00 AM' + }, + }, + { + id: 'this-weekend', + label: 'this weekend', + compute: (now: Date) => { + const res = parseDateRange('this weekend', { now }) + if (res.ok && res.start) { + const d = res.start.date + const weekday = d.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() + return `${weekday}, 8:00 AM` + } + return 'SAT, 8:00 AM' + }, + }, + { + id: 'someday', + label: 'someday', + compute: () => '¯\\_(ツ)_/¯', + }, +] + +export function RemindMePopoverBlock() { + const [query, setQuery] = useState('') + const [selectedId, setSelectedId] = useState('tomorrow') + const [condition, setCondition] = useState('if no reply') + const [conditionOpen, setConditionOpen] = useState(false) + const hydrated = useHydrated() + const now = hydrated ? new Date() : SSR_NOW + + // Evaluate query with Lingo + const activeCustomReading = useMemo(() => { + if (!query.trim()) { + return null + } + const range = parseDateRange(query, { now }) + if (range.ok) { + if (range.start && range.end) { + const s = range.start.date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'numeric', + day: 'numeric', + }) + const e = range.end.date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'numeric', + day: 'numeric', + }) + return `${s} → ${e}` + } + if (range.start) { + return `From ${range.start.date.toLocaleDateString('en-US', { weekday: 'short', hour: 'numeric', minute: '2-digit' })}` + } + } + const single = parseDate(query, { now }) + if (single.ok) { + const weekday = single.date.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() + const time = single.date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + return `${weekday}, ${time}` + } + return null + }, [query, now]) + + return ( +
+ {/* Popover Card modeled after Reference Image 1 */} +
+ {/* Header */} +
+ + Remind me +
+ + {/* Input Bar & Condition Selector */} +
+
+ Try: + { + setQuery(e.target.value) + if (e.target.value) { + setSelectedId('') + } + }} + placeholder="8 am, 3 days, aug 7" + type="text" + value={query} + /> +
+ +
+ + + {conditionOpen && ( +
+ {['if no reply', 'regardless', 'if unresolved'].map((item) => ( + + ))} +
+ )} +
+
+ + {/* Custom Parsed Row when user types */} + {query.trim() && ( +
+ {query} + + {activeCustomReading ?? 'Typing valid time...'} + +
+ )} + + {/* Preset Options List */} +
+ {PRESET_ITEMS.map((item) => { + const isSelected = selectedId === item.id && !query + const output = item.compute(now) + return ( + + ) + })} +
+ + {/* Bottom confirmation readout */} +
+ + Scheduled via Lingo Date Engine + + + {now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + +
+
+
+ ) +} diff --git a/apps/site/src/components/site/semantic-token-highlighter.tsx b/apps/site/src/components/site/semantic-token-highlighter.tsx new file mode 100644 index 0000000..0888e15 --- /dev/null +++ b/apps/site/src/components/site/semantic-token-highlighter.tsx @@ -0,0 +1,276 @@ +'use client' + +import { parseDate, parseDateRange } from '@pascal-app/lingo/date' +import { CalendarIcon, ClockIcon, RepeatIcon, SparklesIcon, TimerIcon } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useHydrated } from '@/components/site/use-hydrated' +import { classifyTextSpans, type TokenCategory } from '@/lib/semantic-spans' +import { cn } from '@/lib/utils' + +const SSR_NOW = new Date(2026, 8, 11, 19, 49, 0) // Friday Sep 11, 2026 + +interface PresetExample { + label: string + text: string +} + +const PRESET_EXAMPLES: PresetExample[] = [ + { label: 'A REMINDER', text: 'tomorrow at 9am' }, + { label: 'DINNER, MID-SENTENCE', text: 'book dinner for October 2 at eight pm' }, + { label: 'STANDUP', text: 'every weekday at nine am' }, + { label: 'OVERNIGHT SHIFT', text: 'Friday at 10pm until Saturday at 2am' }, + { label: 'A TRIP', text: 'from Sep 4 through September 8' }, + { label: 'PAYDAY', text: 'the last Friday of each month' }, + { label: 'TWO-WEEK CYCLE', text: 'every other Friday at noon' }, + { label: 'A TIMER', text: 'in 20 minutes for half an hour' }, +] + +const CATEGORY_STYLES: Record< + TokenCategory, + { bg: string; text: string; dot: string; label: string; icon: typeof CalendarIcon } +> = { + date: { + bg: 'bg-blue-100 dark:bg-blue-950/70 border-blue-200 dark:border-blue-800/60', + text: 'text-blue-900 dark:text-blue-200', + dot: 'bg-blue-500', + label: 'Day or Date', + icon: CalendarIcon, + }, + time: { + bg: 'bg-amber-100 dark:bg-amber-950/70 border-amber-200 dark:border-amber-800/60', + text: 'text-amber-900 dark:text-amber-200', + dot: 'bg-amber-500', + label: 'Clock Time', + icon: ClockIcon, + }, + repeats: { + bg: 'bg-purple-100 dark:bg-purple-950/70 border-purple-200 dark:border-purple-800/60', + text: 'text-purple-900 dark:text-purple-200', + dot: 'bg-purple-500', + label: 'Repeats', + icon: RepeatIcon, + }, + duration: { + bg: 'bg-emerald-100 dark:bg-emerald-950/70 border-emerald-200 dark:border-emerald-800/60', + text: 'text-emerald-900 dark:text-emerald-200', + dot: 'bg-emerald-500', + label: 'How Long', + icon: TimerIcon, + }, + quantity: { + bg: 'bg-sky-100 dark:bg-sky-950/70 border-sky-200 dark:border-sky-800/60', + text: 'text-sky-900 dark:text-sky-200', + dot: 'bg-sky-500', + label: 'Measurement', + icon: SparklesIcon, + }, + plain: { + bg: '', + text: 'text-foreground', + dot: '', + label: 'Plain text', + icon: SparklesIcon, + }, +} + +function formatDateDisplay(d: Date): string { + const weekday = d.toLocaleDateString('en-US', { weekday: 'short' }) + const month = d.toLocaleDateString('en-US', { month: 'short' }) + const day = d.getDate() + const year = d.getFullYear() + return `${weekday}, ${month} ${day}, ${year}` +} + +function formatTimeDisplay(d: Date): string { + return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) +} + +export function SemanticTokenHighlighter() { + const [input, setInput] = useState('book dinner for October 2 at eight pm') + const hydrated = useHydrated() + const now = hydrated ? new Date() : SSR_NOW + + const spans = useMemo(() => classifyTextSpans(input, now), [input, now]) + + // Compute parsed reading using Lingo + const parsedData = useMemo(() => { + // 1. Try date range + const range = parseDateRange(input, { now }) + if (range.ok) { + return { + type: 'range' as const, + start: range.start?.date ?? null, + end: range.end?.date ?? null, + dated: range.dated, + text: range.text, + } + } + // 2. Try single date + const single = parseDate(input, { now }) + if (single.ok) { + return { + type: 'single' as const, + date: single.date, + grain: single.grain, + text: single.text, + } + } + return null + }, [input, now]) + + return ( +
+
+

+ Plain English to Real Dates +

+

+ Zero-latency recursive-descent parser running locally in your browser. Live span + categorization and deterministic canonical outputs. +

+
+ + {/* Main Interactive Card */} +
+
+ + TRY IT OUT + + Type any date or time +
+ + {/* Input with live Highlighted Span Overlay */} +
+
+ {spans.map((span, idx) => { + if (span.category === 'plain') { + return ( + + {span.text} + + ) + } + const style = CATEGORY_STYLES[span.category] + return ( + + {span.text} + + ) + })} +
+ setInput(e.target.value)} + value={input} + /> +
+ + {/* Canonical Output Card */} +
+
+ Result +
+ {parsedData ? ( +
+
+ {parsedData.type === 'single' && parsedData.date + ? formatDateDisplay(parsedData.date) + : parsedData.type === 'range' && parsedData.start + ? parsedData.end + ? `${formatDateDisplay(parsedData.start)} — ${formatDateDisplay(parsedData.end)}` + : `From ${formatDateDisplay(parsedData.start)}` + : 'Parsed successfully'} +
+
+ {parsedData.type === 'single' && + parsedData.date && + (parsedData.grain === 'hour' || parsedData.grain === 'minute') + ? formatTimeDisplay(parsedData.date) + : parsedData.type === 'range' && parsedData.start && !parsedData.dated + ? `${formatTimeDisplay(parsedData.start)}${parsedData.end ? ` – ${formatTimeDisplay(parsedData.end)}` : ''}` + : null} +
+
+ ) : ( +
+ Type a time, day, calendar period or recurring expression to evaluate canonical + values. +
+ )} +
+ Relative to {formatDateDisplay(now)} ·{' '} + {now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()} +
+
+ + {/* Legend */} +
+ {(['date', 'time', 'repeats', 'duration'] as TokenCategory[]).map((cat) => { + const style = CATEGORY_STYLES[cat] + return ( +
+ + {style.label} +
+ ) + })} +
+
+ + {/* Tap One, See The Dates Section */} +
+
+

Tap One, See The Dates

+

Each of these runs in the box above

+
+ +
+ {PRESET_EXAMPLES.map((example) => { + const exampleSpans = classifyTextSpans(example.text, now) + return ( + + ) + })} +
+
+
+ ) +} diff --git a/apps/site/src/components/site/workflow-trigger-block.tsx b/apps/site/src/components/site/workflow-trigger-block.tsx new file mode 100644 index 0000000..07c2401 --- /dev/null +++ b/apps/site/src/components/site/workflow-trigger-block.tsx @@ -0,0 +1,296 @@ +'use client' + +import { findQuantities, parseRange } from '@pascal-app/lingo' +import { CheckCircle2Icon, FilterIcon, SparklesIcon } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { cn } from '@/lib/utils' + +interface WorkflowRule { + id: string + prompt: string + title: string + tokens: Array<{ + text: string + type: 'entity' | 'agent' | 'condition' | 'target' | 'plain' + brand?: 'nextjs' | 'claude' | 'github' | 'aws' | 'x' | 'gmail' | 'gdrive' + iconText?: string + }> +} + +const SAMPLE_RULES: WorkflowRule[] = [ + { + id: 'nextjs-claude', + title: 'Framework Auto-Upgrade', + prompt: 'When nextjs comes up with a new release, ask claude to upgrade my repos', + tokens: [ + { text: 'When', type: 'plain' }, + { text: 'nextjs', type: 'entity', brand: 'nextjs' }, + { text: 'comes up with a new release, ask', type: 'plain' }, + { text: 'claude', type: 'agent', brand: 'claude' }, + { text: 'to upgrade my', type: 'plain' }, + { text: 'repos', type: 'target', brand: 'github' }, + ], + }, + { + id: 'aws-credits', + title: 'Spend & Cloud Credits Guardrail', + prompt: 'Let me know when AWS credits fall below $10k', + tokens: [ + { text: 'Let me know when', type: 'plain' }, + { text: 'AWS credits', type: 'entity', brand: 'aws' }, + { text: 'fall', type: 'plain' }, + { text: 'below $10k', type: 'condition' }, + ], + }, + { + id: 'bug-triage', + title: 'Omnichannel Bug Routing', + prompt: 'Catch when someone reports a bug on X or by email and have Anton fix it', + tokens: [ + { text: 'Catch when someone', type: 'plain' }, + { text: 'reports a bug', type: 'condition' }, + { text: 'on', type: 'plain' }, + { text: 'X', type: 'entity', brand: 'x' }, + { text: 'or by', type: 'plain' }, + { text: 'email', type: 'entity', brand: 'gmail' }, + { text: 'and have', type: 'plain' }, + { text: 'Anton', type: 'agent', iconText: '🐞' }, + { text: 'fix it', type: 'plain' }, + ], + }, + { + id: 'gdrive-archive', + title: 'Document Filing & Vault', + prompt: 'Can you save administrative emails I receive to this Google drive folder', + tokens: [ + { text: 'Can you save administrative', type: 'plain' }, + { text: 'emails', type: 'entity', brand: 'gmail' }, + { text: 'I receive to this', type: 'plain' }, + { text: 'Google drive folder', type: 'target', brand: 'gdrive' }, + ], + }, +] + +export function WorkflowTriggerBlock() { + const [selectedRuleId, setSelectedRuleId] = useState('aws-credits') + const [customText, setCustomText] = useState('Let me know when AWS credits fall below $10k') + + // Parse any condition / quantities inside the rule text with Lingo + const parsedCondition = useMemo(() => { + // 1. Try finding range/bounds ("below $10k", "over 50 kg") + const rangeRes = parseRange(customText, { kind: 'currency' }) + if (rangeRes.ok) { + const minQty = rangeRes.range.min() + const maxQty = rangeRes.range.max() + return { + type: 'range' as const, + min: minQty ? minQty.format() : null, + max: maxQty ? maxQty.format() : null, + canonicalMax: maxQty ? maxQty.base : null, + } + } + // 2. Scan free text for quantities + const found = findQuantities(customText) + if (found.length > 0) { + return { + type: 'quantities' as const, + items: found.map((f) => ({ + text: f.result.text, + canonical: + f.result.type === 'quantity' + ? `${f.result.quantity.base} ${f.result.quantity.unit}` + : f.result.text, + })), + } + } + return null + }, [customText]) + + const currentRule = SAMPLE_RULES.find((r) => r.id === selectedRuleId) ?? SAMPLE_RULES[1]! + + return ( +
+
+

+ Natural Language Workflow Triggers +

+

+ Parse human automation rules into typed entity targets and numerical triggers with + Lingo's free-text quantity and range extractor. +

+
+ + {/* Visual Canvas containing Interactive Rules modeled after Reference 3 */} +
+
+ {SAMPLE_RULES.map((rule) => { + const isSelected = selectedRuleId === rule.id + return ( + + ) + })} +
+ + {/* Lingo Canonicalization Inspector */} +
+
+ + + Lingo Automated Extraction Readout + + + Active Rule: {currentRule.title} + +
+ +
+
+ Original Prompt + + “{customText}” + +
+ +
+ + Extracted Threshold / Condition + +
+ {parsedCondition?.type === 'range' && ( +
+ + Upper Bound: + {' '} + {parsedCondition.max ?? '—'} (Canonical: $ + {parsedCondition.canonicalMax?.toLocaleString()}) +
+ )} + {parsedCondition?.type === 'quantities' && ( +
+ {parsedCondition.items.map((it, i) => ( + + {it.text} →{' '} + + {it.canonical} + + + ))} +
+ )} + {!parsedCondition && ( + + Event-driven trigger (Zero threshold) + + )} +
+
+
+
+
+
+ ) +} diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts index aca1acc..35831db 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', + 'Workflow triggers', + 'Extract numerical bounds and targets from natural language agent prompts.', + ['workflow', 'trigger', 'agent', 'automation', 'claude', 'aws', 'credits', 'prompt'], + { depth: 3, markdownSectionId: 'one-schema' }, + ), ], }, { @@ -321,6 +328,20 @@ export const docsNavGroups: DocsNavGroup[] = [ ], { depth: 3, markdownSectionId: 'dates' }, ), + page( + 'dates-semantic-tokens', + 'Semantic tokens', + 'Live span categorization into dates, times, repeats, and durations.', + ['semantic', 'tokens', 'highlighter', 'gpu-time', 'category', 'legend', 'overlay'], + { depth: 3, markdownSectionId: 'dates' }, + ), + page( + 'dates-remind-me', + 'Remind me block', + 'Linear-style scheduling popover with quick presets and relative dates.', + ['remind me', 'popover', 'scheduling', 'linear', 'superhuman', 'tomorrow', 'weekend'], + { 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..af0c2e3 --- /dev/null +++ b/apps/site/src/lib/semantic-spans.ts @@ -0,0 +1,189 @@ +import { findQuantities } from '@pascal-app/lingo' + +export type TokenCategory = 'date' | 'time' | 'quantity' | 'repeats' | 'duration' | 'plain' + +export interface ClassifiedSpan { + category: TokenCategory + detail?: string + end: number + start: number + text: string +} + +const REPEATS_PATTERNS = [ + /\b(?:every|each)\s+(?:other\s+)?(?:day|weekday|weekend|week|month|year|monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|wed|thu|fri|sat|sun)\b/gi, + /\b(?:daily|weekly|monthly|yearly|annually|biweekly|fortnightly)\b/gi, + /\b(?:the\s+)?last\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+of\s+(?:each|every)\s+month\b/gi, + /\b(?:every\s+other\s+[a-z]+)\b/gi, +] + +const CLOCK_TIME_PATTERNS = [ + /\b(?:at|@)\s+(?:noon|midnight|\d{1,2}(?::\d{2})?\s*(?:am|pm)?|[a-z]+\s+o'clock)\b/gi, + /\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/gi, + /\b(?:quarter|half)\s+(?:past|to|of)\s+\w+\b/gi, + /\bnoon\b/gi, + /\bmidnight\b/gi, +] + +const DURATION_PATTERNS = [ + /\b(?:for|in)\s+(?:half\s+an\s+hour|\d+(?:\.\d+)?\s*(?:min|mins|minutes?|hours?|hrs?|days?|weeks?|months?|seconds?|secs?))\b/gi, + /\b(?:half\s+an\s+hour|an?\s+hour\s+and\s+a\s+half|\d+\s+hours?\s+and\s+\d+\s+minutes?)\b/gi, +] + +const DATE_WORDS_PATTERNS = [ + /\b(?:today|tomorrow|yesterday|tonight|this\s+weekend|next\s+weekend|last\s+weekend|next\s+week|last\s+week|this\s+month|next\s+month|last\s+month)\b/gi, + /\b(?:january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\s+\d{1,2}(?:st|nd|rd|th)?(?:\s*,?\s*\d{4})?\b/gi, + /\b\d{1,2}(?:st|nd|rd|th)?\s+(?:of\s+)?(?:january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)(?:\s*,?\s*\d{4})?\b/gi, + /\b\d{4}-\d{2}-\d{2}\b/gi, + /\b(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|wed|thu|fri|sat|sun)\b/gi, +] + +export function classifyTextSpans(input: string, now: Date = new Date()): ClassifiedSpan[] { + if (!input) { + return [] + } + + const rawMatches: Array<{ + start: number + end: number + text: string + category: TokenCategory + priority: number + detail?: string + }> = [] + + // 1. Check for repeat patterns (highest priority for "every weekday", "last Friday of each month") + for (const re of REPEATS_PATTERNS) { + let m: RegExpExecArray | null + const regex = new RegExp(re.source, re.flags) + while ((m = regex.exec(input)) !== null) { + rawMatches.push({ + start: m.index, + end: m.index + m[0].length, + text: m[0], + category: 'repeats', + priority: 10, + detail: 'Recurring rule', + }) + } + } + + // 2. Duration phrases ("in 20 minutes", "for half an hour") + for (const re of DURATION_PATTERNS) { + let m: RegExpExecArray | null + const regex = new RegExp(re.source, re.flags) + while ((m = regex.exec(input)) !== null) { + rawMatches.push({ + start: m.index, + end: m.index + m[0].length, + text: m[0], + category: 'duration', + priority: 8, + detail: 'Duration', + }) + } + } + + // 3. Clock times ("at 9am", "at eight pm", "10pm") + for (const re of CLOCK_TIME_PATTERNS) { + let m: RegExpExecArray | null + const regex = new RegExp(re.source, re.flags) + while ((m = regex.exec(input)) !== null) { + rawMatches.push({ + start: m.index, + end: m.index + m[0].length, + text: m[0], + category: 'time', + priority: 7, + detail: 'Clock Time', + }) + } + } + + // 4. Dates & days ("October 2", "tomorrow", "Friday", "Sep 4") + for (const re of DATE_WORDS_PATTERNS) { + let m: RegExpExecArray | null + const regex = new RegExp(re.source, re.flags) + while ((m = regex.exec(input)) !== null) { + rawMatches.push({ + start: m.index, + end: m.index + m[0].length, + text: m[0], + category: 'date', + priority: 6, + detail: 'Day or Date', + }) + } + } + + // 5. Quantities / measurements ("$10k", "5 kg", "120 mm", "3 lb 4 oz") + try { + const quantities = findQuantities(input) + for (const q of quantities) { + rawMatches.push({ + start: q.span.start, + end: q.span.end, + text: input.slice(q.span.start, q.span.end), + category: 'quantity', + priority: 5, + detail: q.result.type === 'quantity' ? `${q.result.quantity.format()}` : 'Measurement', + }) + } + } catch { + // fallback if findQuantities fails on partial text + } + + // Sort matches by start position, then higher priority, then longer length + rawMatches.sort((a, b) => { + if (a.start !== b.start) { + return a.start - b.start + } + if (b.priority !== a.priority) { + return b.priority - a.priority + } + return b.end - b.start - (a.end - a.start) + }) + + // Resolve overlaps (higher priority wins) + const resolved: typeof rawMatches = [] + let lastEnd = 0 + for (const m of rawMatches) { + if (m.start >= lastEnd) { + resolved.push(m) + lastEnd = m.end + } + } + + // Slice into complete spans covering entire input (including 'plain' gaps) + const result: ClassifiedSpan[] = [] + let cursor = 0 + for (const m of resolved) { + if (m.start > cursor) { + result.push({ + start: cursor, + end: m.start, + text: input.slice(cursor, m.start), + category: 'plain', + }) + } + result.push({ + start: m.start, + end: m.end, + text: input.slice(m.start, m.end), + category: m.category, + detail: m.detail, + }) + cursor = m.end + } + + if (cursor < input.length) { + result.push({ + start: cursor, + end: input.length, + text: input.slice(cursor), + category: 'plain', + }) + } + + return result +} 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..dc53159 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -7,6 +7,13 @@ change**, even if the API is untouched. ## [Unreleased] +### Added + +- Rich visual UI blocks in documentation site (`apps/site`): + - `SemanticTokenHighlighter`: Live multi-category span classification and colorized syntax tokens (Day/Date, Clock Time, Repeats, How Long) running client-side with zero dependencies and deterministic outputs. + - `RemindMePopoverBlock`: Production-grade productivity scheduling popover with quick presets (`tomorrow`, `next week`, `this weekend`, `someday`) and live custom date parsing. + - `WorkflowTriggerBlock`: Natural-language automation rules parser with brand/agent entity chips and live numerical bound extraction. + ## [0.5.0] - 2026-08-23 ### Added From 936a5d0ae593a1e5009b1b32f8598ac338a4463d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 05:01:27 +0000 Subject: [PATCH 2/6] fix(parse): anchor open-bound range spans at the qualifier in findQuantities okRange expects a normalized offset, but the open-bound and fuzzy-spread branches of parseRangeOrQty passed the token index. Any range that did not start the input got a span starting at the token index instead of its source offset ('call mom over 5 min' -> [2, 19)). Pass p.tokens[i].start, matching buildRange. Co-authored-by: Aymeric Rabot --- packages/lingo/src/parse/grammar.test.ts | 14 ++++++++++++++ packages/lingo/src/parse/range.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) 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. From 8acba41eb0aa595165e38132e154026097355441 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 05:13:09 +0000 Subject: [PATCH 3/6] fix(site): rework the three demo blocks after adversarial review - Token highlighter: regexes only propose slices; parseDate/parseDateRange/ parseDuration confirm each one, so a color only appears where lingo read something. Real priority-first overlap resolution, per-token readings, presets that all parse, visible Input instead of an opacity-0 overlay, aria-pressed preset group, zone label deferred to hydration, memoized now. - Remind me: a real Base UI Popover (new ui/popover.tsx) with a trigger, presets that are phrases read through ./date (no hard-coded clocks or fallback times), lingo's own message on failure, Select for the condition, design tokens instead of hex colors, works in light mode. - Workflow rule: editable prompt, findQuantities with span slicing, range min/max via format(), issues surfaced with issueClass, vendor-neutral entity chips with lucide icons, event-only readout when no bound. - Shared date-display helpers; four --token-* category tints in globals.css (light + dark) replace hard-coded Tailwind hues. - Copy: drop 'zero-latency', 'production-ready', brand name-drops and the 'gpu-time' search keyword; wrap all three in DemoFrame like neighbours. Co-authored-by: Aymeric Rabot --- apps/site/src/app/docs/page.tsx | 26 +- apps/site/src/app/globals.css | 45 ++ .../src/components/site/landing-sections.tsx | 12 +- .../src/components/site/remind-me-popover.tsx | 366 ++++++------- .../site/semantic-token-highlighter.tsx | 447 +++++++--------- .../site/workflow-trigger-block.tsx | 503 +++++++++--------- apps/site/src/components/ui/popover.tsx | 56 ++ apps/site/src/lib/date-display.ts | 58 ++ apps/site/src/lib/docs-catalog.ts | 18 +- apps/site/src/lib/semantic-spans.ts | 284 +++++----- packages/lingo/CHANGELOG.md | 16 +- 11 files changed, 959 insertions(+), 872 deletions(-) create mode 100644 apps/site/src/components/ui/popover.tsx create mode 100644 apps/site/src/lib/date-display.ts diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index 17f5c5c..554337f 100644 --- a/apps/site/src/app/docs/page.tsx +++ b/apps/site/src/app/docs/page.tsx @@ -869,11 +869,12 @@ export default async function Home() {
- Workflow automation triggers + Workflow rules

- In agent workflows, prompts frequently combine target entities, action verbs, and - numerical thresholds. Lingo powers live extraction of bounds ( - below $10k) and quantities from natural language commands. + 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.

@@ -958,21 +959,22 @@ export default async function Home() {
- Semantic token highlighting + Token highlighter

- Lingo's precise original-input spans make it straightforward to build - multi-category colorized tokenizers (day or date, clock time, repeats, and duration) - running at zero latency in the browser. + 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.

- Interactive reminder block + Remind me popover

- A production-ready scheduling popover block (inspired by modern productivity apps - like Superhuman and Linear). Quick presets, custom relative time input, and - conditional triggers in one compact control. + 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.

diff --git a/apps/site/src/app/globals.css b/apps/site/src/app/globals.css index 3fef4a5..240bd87 100644 --- a/apps/site/src/app/globals.css +++ b/apps/site/src/app/globals.css @@ -236,6 +236,12 @@ --motion-moderate: 280ms; --motion-slow: 380ms; --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + /* Token-category tints: the one place the demos leave the achromatic scale. + Four hues, one per category the highlighter can confirm through lingo. */ + --token-date: oklch(0.55 0.16 255); + --token-time: oklch(0.6 0.15 65); + --token-duration: oklch(0.55 0.14 155); + --token-quantity: oklch(0.55 0.17 305); color-scheme: light; } @@ -452,6 +458,10 @@ --command-surface-shadow: inset 0 1px 0 rgb(255 255 255 / 0.035), var(--surface-ring), 0 16px 36px rgb(0 0 0 / 0.22); + --token-date: oklch(0.76 0.12 255); + --token-time: oklch(0.8 0.13 75); + --token-duration: oklch(0.78 0.12 155); + --token-quantity: oklch(0.78 0.13 305); color-scheme: dark; } @@ -649,6 +659,41 @@ p { font-variant-numeric: tabular-nums slashed-zero; } +.token-chip, +.token-dot { + --token: var(--muted-foreground); +} + +.token-chip { + color: color-mix(in oklch, var(--token), var(--foreground) 40%); + background: color-mix(in oklch, var(--token), transparent 88%); + border-color: color-mix(in oklch, var(--token), transparent 64%); +} + +.token-dot { + background: var(--token); +} + +.token-chip[data-token="date"], +.token-dot[data-token="date"] { + --token: var(--token-date); +} + +.token-chip[data-token="time"], +.token-dot[data-token="time"] { + --token: var(--token-time); +} + +.token-chip[data-token="duration"], +.token-dot[data-token="duration"] { + --token: var(--token-duration); +} + +.token-chip[data-token="quantity"], +.token-dot[data-token="quantity"] { + --token: var(--token-quantity); +} + .code-surface { --code-surface-background: color-mix(in oklch, var(--card), var(--muted) 48%); --syntax-comment: #6a737d; diff --git a/apps/site/src/components/site/landing-sections.tsx b/apps/site/src/components/site/landing-sections.tsx index 10e692c..1900998 100644 --- a/apps/site/src/components/site/landing-sections.tsx +++ b/apps/site/src/components/site/landing-sections.tsx @@ -263,22 +263,22 @@ export function LandingSections() {
diff --git a/apps/site/src/components/site/remind-me-popover.tsx b/apps/site/src/components/site/remind-me-popover.tsx index fa85bdb..b7db272 100644 --- a/apps/site/src/components/site/remind-me-popover.tsx +++ b/apps/site/src/components/site/remind-me-popover.tsx @@ -1,220 +1,198 @@ 'use client' -import { parseDate, parseDateRange } from '@pascal-app/lingo/date' -import { CheckIcon, ChevronDownIcon, ClockIcon } from 'lucide-react' +import { type DateRange, type DateResult, parseDate, parseDateRange } from '@pascal-app/lingo/date' +import { ClockIcon } from 'lucide-react' import { useMemo, useState } from 'react' +import { DemoFrame } from '@/components/site/demo-frame' +import { JsonView } from '@/components/site/json-view' import { useHydrated } from '@/components/site/use-hydrated' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@/components/ui/popover' +import { Select } from '@/components/ui/select' +import { formatDateResult, formatDay, formatRange } from '@/lib/date-display' import { cn } from '@/lib/utils' -const SSR_NOW = new Date(2026, 8, 11, 19, 49, 0) // Friday Sep 11, 2026 +/** SSR reference time. After hydration the popover switches to the real clock. */ +const SSR_NOW = new Date(2026, 8, 11, 19, 49, 0) -interface PresetItem { - compute: (now: Date) => string - id: string - label: string +/** Each preset is a phrase lingo reads, not a hard-coded clock. "someday" + * deliberately has none: it parks the item without a date. */ +const PRESETS = [ + { label: 'tomorrow', phrase: 'tomorrow at 8am' }, + { label: 'next week', phrase: 'next monday at 8am' }, + { label: 'this weekend', phrase: 'saturday at 10am' }, + { label: 'someday', phrase: null }, +] as const + +const CONDITIONS = ['if no reply', 'regardless', 'if unresolved'] as const +type Condition = (typeof CONDITIONS)[number] + +type Reading = + | { ok: true; label: string; result: DateResult | DateRange } + | { ok: false; message: string | null; result: null } + +function read(phrase: string, now: Date): Reading { + if (phrase.trim() === '') { + return { ok: false, message: null, result: null } + } + const range = parseDateRange(phrase, { now }) + if (range.ok) { + return { ok: true, label: formatRange(range), result: range } + } + const single = parseDate(phrase, { now }) + if (single.ok) { + return { ok: true, label: formatDateResult(single), result: single } + } + return { ok: false, message: single.issues[0]?.message ?? null, result: null } } -const PRESET_ITEMS: PresetItem[] = [ - { - id: 'tomorrow', - label: 'tomorrow', - compute: (now: Date) => { - const res = parseDate('tomorrow at 8am', { now }) - if (res.ok) { - const d = res.date - const weekday = d.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() - return `${weekday}, 8:00 AM` - } - return 'SAT, 8:00 AM' - }, - }, - { - id: 'next-week', - label: 'next week', - compute: (now: Date) => { - const res = parseDate('next monday at 8am', { now }) - if (res.ok) { - const d = res.date - const weekday = d.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() - return `${weekday}, 8:00 AM` - } - return 'MON, 8:00 AM' - }, - }, - { - id: 'this-weekend', - label: 'this weekend', - compute: (now: Date) => { - const res = parseDateRange('this weekend', { now }) - if (res.ok && res.start) { - const d = res.start.date - const weekday = d.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() - return `${weekday}, 8:00 AM` - } - return 'SAT, 8:00 AM' - }, - }, - { - id: 'someday', - label: 'someday', - compute: () => '¯\\_(ツ)_/¯', - }, -] +interface Reminder { + condition: Condition + phrase: string | null +} export function RemindMePopoverBlock() { - const [query, setQuery] = useState('') - const [selectedId, setSelectedId] = useState('tomorrow') - const [condition, setCondition] = useState('if no reply') - const [conditionOpen, setConditionOpen] = useState(false) const hydrated = useHydrated() - const now = hydrated ? new Date() : SSR_NOW + const now = useMemo(() => (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, + }) - // Evaluate query with Lingo - const activeCustomReading = useMemo(() => { - if (!query.trim()) { - return null - } - const range = parseDateRange(query, { now }) - if (range.ok) { - if (range.start && range.end) { - const s = range.start.date.toLocaleDateString('en-US', { - weekday: 'short', - month: 'numeric', - day: 'numeric', - }) - const e = range.end.date.toLocaleDateString('en-US', { - weekday: 'short', - month: 'numeric', - day: 'numeric', - }) - return `${s} → ${e}` - } - if (range.start) { - return `From ${range.start.date.toLocaleDateString('en-US', { weekday: 'short', hour: 'numeric', minute: '2-digit' })}` - } - } - const single = parseDate(query, { now }) - if (single.ok) { - const weekday = single.date.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() - const time = single.date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) - return `${weekday}, ${time}` - } - return null - }, [query, now]) + 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], + ) - return ( -
- {/* Popover Card modeled after Reference Image 1 */} -
- {/* Header */} -
- - Remind me -
+ const commit = (phrase: string | null) => { + setReminder({ condition, phrase }) + setQuery('') + setOpen(false) + } - {/* Input Bar & Condition Selector */} -
-
- Try: - { - setQuery(e.target.value) - if (e.target.value) { - setSelectedId('') - } - }} - placeholder="8 am, 3 days, aug 7" - type="text" - value={query} - /> + return ( + } + detailsLabel="Output" + stageClassName="min-h-[16rem] justify-start" + title="Remind me" + > +
+
+
+ Review Q3 forecast + Thread from Dana · 3 messages
- -
- - - {conditionOpen && ( -
- {['if no reply', 'regardless', 'if unresolved'].map((item) => ( - - ))} + + }> + + Remind me + + +
+ + Remind me
- )} -
-
- - {/* Custom Parsed Row when user types */} - {query.trim() && ( -
- {query} - - {activeCustomReading ?? 'Typing valid time...'} - -
- )} - - {/* Preset Options List */} -
- {PRESET_ITEMS.map((item) => { - const isSelected = selectedId === item.id && !query - const output = item.compute(now) - return ( - + ))} +
- {/* Input with live Highlighted Span Overlay */} -
-
- {spans.map((span, idx) => { - if (span.category === 'plain') { - return ( - - {span.text} - - ) - } - const style = CATEGORY_STYLES[span.category] - return ( + {/* Decorative twin of the list below; the list carries the semantics. */} +
+ {spans.length === 0 ? ( + Type to see tokens + ) : ( + spans.map((span) => + span.category === 'plain' ? ( {span.text} - ) - })} -
- setInput(e.target.value)} - value={input} - /> -
- - {/* Canonical Output Card */} -
-
- Result -
- {parsedData ? ( -
-
- {parsedData.type === 'single' && parsedData.date - ? formatDateDisplay(parsedData.date) - : parsedData.type === 'range' && parsedData.start - ? parsedData.end - ? `${formatDateDisplay(parsedData.start)} — ${formatDateDisplay(parsedData.end)}` - : `From ${formatDateDisplay(parsedData.start)}` - : 'Parsed successfully'} -
-
- {parsedData.type === 'single' && - parsedData.date && - (parsedData.grain === 'hour' || parsedData.grain === 'minute') - ? formatTimeDisplay(parsedData.date) - : parsedData.type === 'range' && parsedData.start && !parsedData.dated - ? `${formatTimeDisplay(parsedData.start)}${parsedData.end ? ` – ${formatTimeDisplay(parsedData.end)}` : ''}` - : null} -
-
- ) : ( -
- Type a time, day, calendar period or recurring expression to evaluate canonical - values. -
- )} -
- Relative to {formatDateDisplay(now)} ·{' '} - {now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()} -
-
- - {/* Legend */} -
- {(['date', 'time', 'repeats', 'duration'] as TokenCategory[]).map((cat) => { - const style = CATEGORY_STYLES[cat] - return ( -
- - {style.label} -
+ ) : ( + + {span.text} + + ), ) - })} + )}
-
- {/* Tap One, See The Dates Section */} -
-
-

Tap One, See The Dates

-

Each of these runs in the box above

+
+ + {MODE_COPY[reading.mode]} + + + {summary(reading, tokens.length)} +
-
- {PRESET_EXAMPLES.map((example) => { - const exampleSpans = classifyTextSpans(example.text, now) - return ( - - ) - })} + + {token.reading} + + + )) + )} + + +
+
    + {CATEGORIES.map((category) => ( +
  • + + {category} +
  • + ))} +
+ + relative to {formatDate(now)} + {zone ? ` · ${zone}` : ''} +
-
+ ) } diff --git a/apps/site/src/components/site/workflow-trigger-block.tsx b/apps/site/src/components/site/workflow-trigger-block.tsx index 07c2401..461dcb4 100644 --- a/apps/site/src/components/site/workflow-trigger-block.tsx +++ b/apps/site/src/components/site/workflow-trigger-block.tsx @@ -1,296 +1,283 @@ 'use client' -import { findQuantities, parseRange } from '@pascal-app/lingo' -import { CheckCircle2Icon, FilterIcon, SparklesIcon } from 'lucide-react' +import { type FoundQuantity, findQuantities } from '@pascal-app/lingo' +import { + ActivityIcon, + BotIcon, + CloudIcon, + FolderGit2Icon, + HammerIcon, + type LucideIcon, + PackageIcon, + TicketIcon, +} from 'lucide-react' import { useMemo, useState } from 'react' +import { DemoFrame } from '@/components/site/demo-frame' +import { JsonView } from '@/components/site/json-view' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { issueClass } from '@/lib/lingo-display' import { cn } from '@/lib/utils' -interface WorkflowRule { +interface Entity { + icon: LucideIcon + role: 'entity' | 'agent' | 'target' + text: string +} + +interface Rule { + /** Hand-labelled nouns — the part a rules engine supplies. Lingo only owns + * the numbers, so these are shown as plain chips, never as parse output. */ + entities: readonly Entity[] id: string prompt: string - title: string - tokens: Array<{ - text: string - type: 'entity' | 'agent' | 'condition' | 'target' | 'plain' - brand?: 'nextjs' | 'claude' | 'github' | 'aws' | 'x' | 'gmail' | 'gdrive' - iconText?: string - }> } -const SAMPLE_RULES: WorkflowRule[] = [ +/** The last rule has no threshold on purpose: an event-only trigger is a + * legitimate reading, and the readout should say so rather than invent one. */ +const RULES: readonly Rule[] = [ { - id: 'nextjs-claude', - title: 'Framework Auto-Upgrade', - prompt: 'When nextjs comes up with a new release, ask claude to upgrade my repos', - tokens: [ - { text: 'When', type: 'plain' }, - { text: 'nextjs', type: 'entity', brand: 'nextjs' }, - { text: 'comes up with a new release, ask', type: 'plain' }, - { text: 'claude', type: 'agent', brand: 'claude' }, - { text: 'to upgrade my', type: 'plain' }, - { text: 'repos', type: 'target', brand: 'github' }, - ], + id: 'credits', + prompt: 'Let me know when cloud credits fall below $10k', + entities: [{ icon: CloudIcon, role: 'entity', text: 'cloud credits' }], }, { - id: 'aws-credits', - title: 'Spend & Cloud Credits Guardrail', - prompt: 'Let me know when AWS credits fall below $10k', - tokens: [ - { text: 'Let me know when', type: 'plain' }, - { text: 'AWS credits', type: 'entity', brand: 'aws' }, - { text: 'fall', type: 'plain' }, - { text: 'below $10k', type: 'condition' }, - ], + id: 'build', + prompt: 'Alert me if the build takes over 15 minutes', + entities: [{ icon: HammerIcon, role: 'entity', text: 'the build' }], }, { - id: 'bug-triage', - title: 'Omnichannel Bug Routing', - prompt: 'Catch when someone reports a bug on X or by email and have Anton fix it', - tokens: [ - { text: 'Catch when someone', type: 'plain' }, - { text: 'reports a bug', type: 'condition' }, - { text: 'on', type: 'plain' }, - { text: 'X', type: 'entity', brand: 'x' }, - { text: 'or by', type: 'plain' }, - { text: 'email', type: 'entity', brand: 'gmail' }, - { text: 'and have', type: 'plain' }, - { text: 'Anton', type: 'agent', iconText: '🐞' }, - { text: 'fix it', type: 'plain' }, - ], + id: 'tickets', + prompt: 'Escalate tickets that stay open for more than 3 days', + entities: [{ icon: TicketIcon, role: 'entity', text: 'tickets' }], + }, + { + id: 'errors', + prompt: 'Page me when the error rate is above 2% for 10 minutes', + entities: [{ icon: ActivityIcon, role: 'entity', text: 'error rate' }], }, { - id: 'gdrive-archive', - title: 'Document Filing & Vault', - prompt: 'Can you save administrative emails I receive to this Google drive folder', - tokens: [ - { text: 'Can you save administrative', type: 'plain' }, - { text: 'emails', type: 'entity', brand: 'gmail' }, - { text: 'I receive to this', type: 'plain' }, - { text: 'Google drive folder', type: 'target', brand: 'gdrive' }, + id: 'release', + prompt: 'When the framework ships a new release, ask the assistant to upgrade my repos', + entities: [ + { icon: PackageIcon, role: 'entity', text: 'framework' }, + { icon: BotIcon, role: 'agent', text: 'assistant' }, + { icon: FolderGit2Icon, role: 'target', text: 'repos' }, ], }, ] -export function WorkflowTriggerBlock() { - const [selectedRuleId, setSelectedRuleId] = useState('aws-credits') - const [customText, setCustomText] = useState('Let me know when AWS credits fall below $10k') +type Segment = + | { end: number; kind: 'plain'; start: number } + | { end: number; entity: Entity; kind: 'entity'; start: number } + | { end: number; hit: FoundQuantity; kind: 'bound'; start: number } + +/** Bare numbers ("25" in "under 25 units") are not thresholds. */ +function bounds(text: string): FoundQuantity[] { + return findQuantities(text).filter((hit) => hit.result.type !== 'number') +} - // Parse any condition / quantities inside the rule text with Lingo - const parsedCondition = useMemo(() => { - // 1. Try finding range/bounds ("below $10k", "over 50 kg") - const rangeRes = parseRange(customText, { kind: 'currency' }) - if (rangeRes.ok) { - const minQty = rangeRes.range.min() - const maxQty = rangeRes.range.max() - return { - type: 'range' as const, - min: minQty ? minQty.format() : null, - max: maxQty ? maxQty.format() : null, - canonicalMax: maxQty ? maxQty.base : null, +function segment(text: string, rule: Rule | undefined, hits: FoundQuantity[]): Segment[] { + const marks: Segment[] = hits.map((hit) => ({ + end: hit.span.end, + hit, + kind: 'bound', + start: hit.span.start, + })) + for (const entity of rule?.entities ?? []) { + const start = text.indexOf(entity.text) + if (start >= 0) { + const end = start + entity.text.length + if (marks.every((m) => end <= m.start || start >= m.end)) { + marks.push({ end, entity, kind: 'entity', start }) } } - // 2. Scan free text for quantities - const found = findQuantities(customText) - if (found.length > 0) { - return { - type: 'quantities' as const, - items: found.map((f) => ({ - text: f.result.text, - canonical: - f.result.type === 'quantity' - ? `${f.result.quantity.base} ${f.result.quantity.unit}` - : f.result.text, - })), - } + } + marks.sort((a, b) => a.start - b.start) + const out: Segment[] = [] + let cursor = 0 + for (const mark of marks) { + if (mark.start > cursor) { + out.push({ end: mark.start, kind: 'plain', start: cursor }) } - return null - }, [customText]) + out.push(mark) + cursor = mark.end + } + if (cursor < text.length) { + out.push({ end: text.length, kind: 'plain', start: cursor }) + } + return out +} - const currentRule = SAMPLE_RULES.find((r) => r.id === selectedRuleId) ?? SAMPLE_RULES[1]! +function describe(hit: FoundQuantity): { kind: string; value: string } { + const { result } = hit + switch (result.type) { + case 'range': + return { kind: result.range.kind, value: result.range.format({ grouping: true }) } + case 'quantity': + return { kind: result.quantity.kind, value: result.quantity.format({ grouping: true }) } + case 'conversion': + return { kind: result.converted.kind, value: result.converted.format({ grouping: true }) } + default: + return { kind: result.type, value: hit.result.text.slice(hit.span.start, hit.span.end) } + } +} - return ( -
-
-

- Natural Language Workflow Triggers -

-

- Parse human automation rules into typed entity targets and numerical triggers with - Lingo's free-text quantity and range extractor. -

-
+export function WorkflowTriggerBlock() { + const [text, setText] = useState(RULES[0]!.prompt) + const rule = useMemo(() => RULES.find((r) => r.prompt === text), [text]) + const hits = useMemo(() => bounds(text), [text]) + const segments = useMemo(() => segment(text, rule, hits), [text, rule, hits]) - {/* Visual Canvas containing Interactive Rules modeled after Reference 3 */} -
-
- {SAMPLE_RULES.map((rule) => { - const isSelected = selectedRuleId === rule.id - return ( - - ) - })} -
- - {/* Lingo Canonicalization Inspector */} -
-
- - - Lingo Automated Extraction Readout - - - Active Rule: {currentRule.title} - + {sample.id} + + ))}
+
-
-
- Original Prompt - - “{customText}” - -
+
+ {segments.length === 0 ? ( + Type a rule + ) : ( + segments.map((seg) => { + const slice = text.slice(seg.start, seg.end) + if (seg.kind === 'plain') { + return ( + + {slice} + + ) + } + if (seg.kind === 'entity') { + const Icon = seg.entity.icon + return ( + + + {slice} + + ) + } + return ( + + {slice} + + ) + }) + )} +
-
- - Extracted Threshold / Condition +
    + {hits.length === 0 ? ( +
  • + + event only + + + No numeric bound in this rule — it fires on an event, not a threshold. -
    - {parsedCondition?.type === 'range' && ( -
    - - Upper Bound: - {' '} - {parsedCondition.max ?? '—'} (Canonical: $ - {parsedCondition.canonicalMax?.toLocaleString()}) -
    - )} - {parsedCondition?.type === 'quantities' && ( -
    - {parsedCondition.items.map((it, i) => ( - - {it.text} →{' '} - - {it.canonical} - - - ))} +
  • + ) : ( + hits.map((hit) => { + const { kind, value } = describe(hit) + return ( +
  • +
    + + + {text.slice(hit.span.start, hit.span.end)} + + + {kind} · [{hit.span.start}, {hit.span.end}) + + {value}
    - )} - {!parsedCondition && ( - - Event-driven trigger (Zero threshold) - - )} -
-
-
-
+ {hit.result.issues.length > 0 ? ( +
    + {hit.result.issues.map((issue) => ( +
  • + {issue.code} + {issue.message} +
  • + ))} +
+ ) : null} + + ) + }) + )} + + +

+ 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..1aec5ba --- /dev/null +++ b/apps/site/src/lib/date-display.ts @@ -0,0 +1,58 @@ +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', + }) +} + +/** "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 35831db..5ee25e9 100644 --- a/apps/site/src/lib/docs-catalog.ts +++ b/apps/site/src/lib/docs-catalog.ts @@ -249,9 +249,9 @@ export const docsNavGroups: DocsNavGroup[] = [ ), page( 'one-schema-workflow', - 'Workflow triggers', - 'Extract numerical bounds and targets from natural language agent prompts.', - ['workflow', 'trigger', 'agent', 'automation', 'claude', 'aws', 'credits', 'prompt'], + 'Workflow rules', + 'Pull numeric thresholds out of automation prompts with findQuantities.', + ['workflow', 'rule', 'trigger', 'automation', 'threshold', 'findQuantities', 'bound'], { depth: 3, markdownSectionId: 'one-schema' }, ), ], @@ -330,16 +330,16 @@ export const docsNavGroups: DocsNavGroup[] = [ ), page( 'dates-semantic-tokens', - 'Semantic tokens', - 'Live span categorization into dates, times, repeats, and durations.', - ['semantic', 'tokens', 'highlighter', 'gpu-time', 'category', 'legend', 'overlay'], + 'Token highlighter', + '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', - 'Remind me block', - 'Linear-style scheduling popover with quick presets and relative dates.', - ['remind me', 'popover', 'scheduling', 'linear', 'superhuman', 'tomorrow', 'weekend'], + 'Remind me popover', + 'Scheduling popover whose presets and free text share one date reader.', + ['remind me', 'popover', 'scheduling', 'preset', 'tomorrow', 'weekend', 'someday'], { depth: 3, markdownSectionId: 'dates' }, ), page( diff --git a/apps/site/src/lib/semantic-spans.ts b/apps/site/src/lib/semantic-spans.ts index af0c2e3..dff2740 100644 --- a/apps/site/src/lib/semantic-spans.ts +++ b/apps/site/src/lib/semantic-spans.ts @@ -1,189 +1,179 @@ import { findQuantities } from '@pascal-app/lingo' +import { humanizeDuration, parseDate, parseDuration } from '@pascal-app/lingo/date' -export type TokenCategory = 'date' | 'time' | 'quantity' | 'repeats' | 'duration' | 'plain' +import { formatClock, formatDay, formatWhen, isTimedGrain } from '@/lib/date-display' + +export type TokenCategory = 'date' | 'time' | 'duration' | 'quantity' | 'plain' export interface ClassifiedSpan { category: TokenCategory - detail?: string end: number + /** What lingo read the slice as — absent for plain text. */ + reading?: string start: number text: string } -const REPEATS_PATTERNS = [ - /\b(?:every|each)\s+(?:other\s+)?(?:day|weekday|weekend|week|month|year|monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|wed|thu|fri|sat|sun)\b/gi, - /\b(?:daily|weekly|monthly|yearly|annually|biweekly|fortnightly)\b/gi, - /\b(?:the\s+)?last\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+of\s+(?:each|every)\s+month\b/gi, - /\b(?:every\s+other\s+[a-z]+)\b/gi, -] +interface Candidate extends ClassifiedSpan { + priority: number +} -const CLOCK_TIME_PATTERNS = [ - /\b(?:at|@)\s+(?:noon|midnight|\d{1,2}(?::\d{2})?\s*(?:am|pm)?|[a-z]+\s+o'clock)\b/gi, - /\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/gi, - /\b(?:quarter|half)\s+(?:past|to|of)\s+\w+\b/gi, - /\bnoon\b/gi, - /\bmidnight\b/gi, +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 DURATION_PATTERNS = [ - /\b(?:for|in)\s+(?:half\s+an\s+hour|\d+(?:\.\d+)?\s*(?:min|mins|minutes?|hours?|hrs?|days?|weeks?|months?|seconds?|secs?))\b/gi, - /\b(?:half\s+an\s+hour|an?\s+hour\s+and\s+a\s+half|\d+\s+hours?\s+and\s+\d+\s+minutes?)\b/gi, +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 DATE_WORDS_PATTERNS = [ - /\b(?:today|tomorrow|yesterday|tonight|this\s+weekend|next\s+weekend|last\s+weekend|next\s+week|last\s+week|this\s+month|next\s+month|last\s+month)\b/gi, - /\b(?:january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\s+\d{1,2}(?:st|nd|rd|th)?(?:\s*,?\s*\d{4})?\b/gi, - /\b\d{1,2}(?:st|nd|rd|th)?\s+(?:of\s+)?(?:january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)(?:\s*,?\s*\d{4})?\b/gi, - /\b\d{4}-\d{2}-\d{2}\b/gi, - /\b(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|wed|thu|fri|sat|sun)\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', + ), ] -export function classifyTextSpans(input: string, now: Date = new Date()): ClassifiedSpan[] { - if (!input) { - return [] - } - - const rawMatches: Array<{ - start: number - end: number - text: string - category: TokenCategory - priority: number - detail?: string - }> = [] - - // 1. Check for repeat patterns (highest priority for "every weekday", "last Friday of each month") - for (const re of REPEATS_PATTERNS) { - let m: RegExpExecArray | null - const regex = new RegExp(re.source, re.flags) - while ((m = regex.exec(input)) !== null) { - rawMatches.push({ - start: m.index, - end: m.index + m[0].length, - text: m[0], - category: 'repeats', - priority: 10, - detail: 'Recurring rule', - }) +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) } } +} - // 2. Duration phrases ("in 20 minutes", "for half an hour") - for (const re of DURATION_PATTERNS) { - let m: RegExpExecArray | null - const regex = new RegExp(re.source, re.flags) - while ((m = regex.exec(input)) !== null) { - rawMatches.push({ - start: m.index, - end: m.index + m[0].length, - text: m[0], - category: 'duration', - priority: 8, - detail: 'Duration', - }) - } +export function classifyTextSpans(input: string, now: Date): ClassifiedSpan[] { + if (!input) { + return [] } - // 3. Clock times ("at 9am", "at eight pm", "10pm") - for (const re of CLOCK_TIME_PATTERNS) { - let m: RegExpExecArray | null - const regex = new RegExp(re.source, re.flags) - while ((m = regex.exec(input)) !== null) { - rawMatches.push({ - start: m.index, - end: m.index + m[0].length, - text: m[0], - category: 'time', - priority: 7, - detail: 'Clock Time', - }) - } + 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) }) } - // 4. Dates & days ("October 2", "tomorrow", "Friday", "Sep 4") - for (const re of DATE_WORDS_PATTERNS) { - let m: RegExpExecArray | null - const regex = new RegExp(re.source, re.flags) - while ((m = regex.exec(input)) !== null) { - rawMatches.push({ - start: m.index, - end: m.index + m[0].length, - text: m[0], - category: 'date', - priority: 6, - detail: 'Day or Date', - }) + 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) } - // 5. Quantities / measurements ("$10k", "5 kg", "120 mm", "3 lb 4 oz") - try { - const quantities = findQuantities(input) - for (const q of quantities) { - rawMatches.push({ - start: q.span.start, - end: q.span.end, - text: input.slice(q.span.start, q.span.end), - category: 'quantity', - priority: 5, - detail: q.result.type === 'quantity' ? `${q.result.quantity.format()}` : 'Measurement', - }) + 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)) } - } catch { - // fallback if findQuantities fails on partial text - } + }) - // Sort matches by start position, then higher priority, then longer length - rawMatches.sort((a, b) => { - if (a.start !== b.start) { - return a.start - b.start - } - if (b.priority !== a.priority) { - return b.priority - a.priority + 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 } - return b.end - b.start - (a.end - a.start) - }) + 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) + } - // Resolve overlaps (higher priority wins) - const resolved: typeof rawMatches = [] - let lastEnd = 0 - for (const m of rawMatches) { - if (m.start >= lastEnd) { - resolved.push(m) - lastEnd = m.end + // 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) - // Slice into complete spans covering entire input (including 'plain' gaps) - const result: ClassifiedSpan[] = [] + const spans: ClassifiedSpan[] = [] let cursor = 0 - for (const m of resolved) { - if (m.start > cursor) { - result.push({ - start: cursor, - end: m.start, - text: input.slice(cursor, m.start), + 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), }) } - result.push({ - start: m.start, - end: m.end, - text: input.slice(m.start, m.end), - category: m.category, - detail: m.detail, - }) - cursor = m.end + spans.push(span) + cursor = span.end } - if (cursor < input.length) { - result.push({ - start: cursor, - end: input.length, - text: input.slice(cursor), - category: 'plain', - }) + spans.push({ category: 'plain', end: input.length, start: cursor, text: input.slice(cursor) }) } - - return result + return spans } diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index dc53159..6de3123 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -9,10 +9,18 @@ change**, even if the API is untouched. ### Added -- Rich visual UI blocks in documentation site (`apps/site`): - - `SemanticTokenHighlighter`: Live multi-category span classification and colorized syntax tokens (Day/Date, Clock Time, Repeats, How Long) running client-side with zero dependencies and deterministic outputs. - - `RemindMePopoverBlock`: Production-grade productivity scheduling popover with quick presets (`tomorrow`, `next week`, `this weekend`, `someday`) and live custom date parsing. - - `WorkflowTriggerBlock`: Natural-language automation rules parser with brand/agent entity chips and live numerical bound extraction. +- 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 From 2349281abab382a0fac2a440ee4011c00d5dca38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 05:28:24 +0000 Subject: [PATCH 4/6] fix(site): stop zone-dependent instants from tripping hydration The calendar, token-highlighter, and remind-me demos print parsed dates as UTC instants in their Output JSON, and the coverage table prints them in its value column. Their SSR reference time is a local wall clock, so the same wall clock is a different instant on the server and in the visitor's browser, and React 19 raised #418 and re-rendered the whole page client side. Instant-bearing text now fills in after hydration; wall-clock labels were already zone-stable and are unchanged. Co-authored-by: Aymeric Rabot --- .../src/components/site/calendar-field-demo.tsx | 6 +++++- .../src/components/site/coverage-explorer.tsx | 16 ++++++++++++---- .../src/components/site/remind-me-popover.tsx | 5 ++++- .../site/semantic-token-highlighter.tsx | 5 ++++- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/site/src/components/site/calendar-field-demo.tsx b/apps/site/src/components/site/calendar-field-demo.tsx index 3107928..9176500 100644 --- a/apps/site/src/components/site/calendar-field-demo.tsx +++ b/apps/site/src/components/site/calendar-field-demo.tsx @@ -176,6 +176,10 @@ export function CalendarFieldDemo() { const reading = useMemo(() => 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/remind-me-popover.tsx b/apps/site/src/components/site/remind-me-popover.tsx index b7db272..2e0923d 100644 --- a/apps/site/src/components/site/remind-me-popover.tsx +++ b/apps/site/src/components/site/remind-me-popover.tsx @@ -73,6 +73,9 @@ export function RemindMePopoverBlock() { () => 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 }) @@ -83,7 +86,7 @@ export function RemindMePopoverBlock() { return ( } + details={} detailsLabel="Output" stageClassName="min-h-[16rem] justify-start" title="Remind me" diff --git a/apps/site/src/components/site/semantic-token-highlighter.tsx b/apps/site/src/components/site/semantic-token-highlighter.tsx index 9f65e8c..d52d0f6 100644 --- a/apps/site/src/components/site/semantic-token-highlighter.tsx +++ b/apps/site/src/components/site/semantic-token-highlighter.tsx @@ -111,11 +111,14 @@ export function SemanticTokenHighlighter() { const zone = hydrated ? now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop() : null + // Same constraint for the JSON: it prints UTC instants, and a local SSR_NOW + // is a different instant in every zone. + const output = hydrated ? JSON.stringify(reading.result, null, 2) : '' return ( } + details={} detailsLabel="Output" stageClassName="min-h-[30rem] justify-start" title="Token highlighter" From 795a108d90beccd645038c2451649efd4b5c0e44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 05:31:36 +0000 Subject: [PATCH 5/6] refactor(site): move the zone label into date-display Keeps every locale-dependent formatter in one module, which is also what React Doctor's no-locale-format-in-render check expects to see. Co-authored-by: Aymeric Rabot --- .../site/src/components/site/semantic-token-highlighter.tsx | 6 ++---- apps/site/src/lib/date-display.ts | 5 +++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/site/src/components/site/semantic-token-highlighter.tsx b/apps/site/src/components/site/semantic-token-highlighter.tsx index d52d0f6..ee31250 100644 --- a/apps/site/src/components/site/semantic-token-highlighter.tsx +++ b/apps/site/src/components/site/semantic-token-highlighter.tsx @@ -17,7 +17,7 @@ import { useHydrated } from '@/components/site/use-hydrated' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { formatDate, formatDateResult, formatRange } from '@/lib/date-display' +import { formatDate, formatDateResult, formatRange, formatZone } from '@/lib/date-display' import { classifyTextSpans, type TokenCategory } from '@/lib/semantic-spans' /** SSR reference time. After hydration the field switches to the real clock. */ @@ -108,9 +108,7 @@ export function SemanticTokenHighlighter() { const reading = useMemo(() => read(value, now), [value, now]) // The zone label depends on the visitor's clock, so it waits for hydration // — the server's zone would not match and React would flag the text. - const zone = hydrated - ? now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop() - : null + const zone = hydrated ? formatZone(now) : null // Same constraint for the JSON: it prints UTC instants, and a local SSR_NOW // is a different instant in every zone. const output = hydrated ? JSON.stringify(reading.result, null, 2) : '' diff --git a/apps/site/src/lib/date-display.ts b/apps/site/src/lib/date-display.ts index 1aec5ba..472f710 100644 --- a/apps/site/src/lib/date-display.ts +++ b/apps/site/src/lib/date-display.ts @@ -20,6 +20,11 @@ export function formatDate(date: Date): string { }) } +/** "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' }) From 9b1f3a99b04ad27f2fb02df4b89d85e880d79031 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 05:38:27 +0000 Subject: [PATCH 6/6] fix(site): give the three demo sections thesis headings The sub-heading repeated the demo frame's own title directly above it. Neighbouring sections name the idea and let the frame name the widget. Co-authored-by: Aymeric Rabot --- apps/site/src/app/docs/page.tsx | 6 +++--- apps/site/src/lib/docs-catalog.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index 554337f..b0ae09e 100644 --- a/apps/site/src/app/docs/page.tsx +++ b/apps/site/src/app/docs/page.tsx @@ -869,7 +869,7 @@ export default async function Home() {
- Workflow rules + Thresholds out of prose

Automation prompts mix nouns a rules engine already knows with thresholds it does not. findQuantities pulls the bounds (below $10k,{' '} @@ -959,7 +959,7 @@ export default async function Home() {

- Token highlighter + 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 @@ -970,7 +970,7 @@ export default async function Home() {

- Remind me popover + 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 diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts index 5ee25e9..d51727c 100644 --- a/apps/site/src/lib/docs-catalog.ts +++ b/apps/site/src/lib/docs-catalog.ts @@ -249,7 +249,7 @@ export const docsNavGroups: DocsNavGroup[] = [ ), page( 'one-schema-workflow', - 'Workflow rules', + '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' }, @@ -330,14 +330,14 @@ export const docsNavGroups: DocsNavGroup[] = [ ), page( 'dates-semantic-tokens', - 'Token highlighter', + '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', - 'Remind me popover', + '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' },