LAB/ Tanishk Interface Lab
InputstableReactTypeScript parser

Adaptive Composer

Type a sentence; the input rearranges into the structure it recognised.

Live preview

Ledger · operations

Capture

Meeting

Meeting · Tuesday 14:00 · 45 min

Title
The deck with Ana
Day
Tuesday
Time
14:00
Duration
45 min

Write plainly — “book the room for tue 10am” becomes a meeting, “18 * 3” becomes a calculation. Nothing leaves this card.

Recent entries

  • Meetingreview the deck with Ana on tue 2pm for 45mTuesday · 14:00 · 45 min
  • Calculationwhat is 240 / 6 + 8= 48
  • Tasksfile the receipts; update the tracker2 tasks
SampleShow parsed formShow intent hint

Edit the sentence — the structure follows

01What it does

Deterministic text-to-structure, not AI: a small intent parser reads the sentence as you type and the composer exposes the structured form underneath — a parsed date and duration for meetings, a live result for arithmetic, a place row, a task checklist, people chips. The input keeps its one job; the interface below it changes to fit what you meant.

In this previewPick a sample sentence, then edit it. The composer names the intent it recognised and lays out the structured form — date and duration, result, place, checklist, people or link.

02Install

Registry install

Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/adaptive-composer/ with its stylesheet beside it.

npx shadcn@latest add <lab-origin>/r/adaptive-composer.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/adaptive-composer/adaptive-composer.tsxcomponent
  • components/lab/adaptive-composer/composer-parse.tslogic
  • components/lab/adaptive-composer/adaptive-composer.cssstyles

Machine-readable item: /r/adaptive-composer.json ↗

03Usage

adaptive-composer.usage.tsx
import { AdaptiveComposer } from "@/components/lab/adaptive-composer/adaptive-composer";
import { parseComposerInput } from "@/components/lab/adaptive-composer/composer-parse";

export function Capture() {
return (
<AdaptiveComposer
sample="review the deck with Ana on tue 2pm for 45m"
onSubmit={(value, intent, payload) => {
console.log(intent, payload); // "meeting", { ... }
}}
/>
);
}

// parseComposerInput is exported for tests and custom panels.

04Props

Props accepted by Adaptive Composer
PropTypeDefaultNotes
value / onValueChangestring / (v: string) => void—Controlled text; omit both to let the composer own it.
defaultValuestring""Initial text for uncontrolled use.
onSubmit(value, intent, payload) => void—Fires on ⌘/Ctrl+Enter with the parsed structure.
samplestring—Example sentence shown as a nudge while empty.
disabledbooleanfalseDisables both the field and the structure panel.

05Accessibility

  • The text field is the only required control; the parsed panel is aria-live=polite and never steals focus.
  • Every parsed row is presentational — submitting works with the raw sentence alone.
  • Intents are announced as text (“Meeting · Tuesday 14:30, 1 hour”), so the structure is readable without the visuals.
  • The parser is deterministic and offline; nothing is sent anywhere, so it is safe for private text.

06Source

3 files, 1147 lines. Nothing private, no configuration required.

components/lab/adaptive-composer/adaptive-composer.tsx— press enter to show or hide the source
tsx
"use client";

import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import {
formatIntent,
parseComposerInput,
type ComposerIntent,
type ComposerPayload,
} from "./composer-parse";
import "./adaptive-composer.css";

export type AdaptiveComposerProps = {
/** Controlled text; omit both value and onValueChange to let it own the text. */
value?: string;
onValueChange?: (value: string) => void;
/** Initial text for uncontrolled use. */
defaultValue?: string;
/** Example sentence shown as a nudge while the field is empty. */
sample?: string;
/** Fires on ⌘/Ctrl+Enter with the raw value and the parsed structure. */
onSubmit?: (
value: string,
intent: ComposerIntent,
payload: ComposerPayload,
) => void;
disabled?: boolean;
className?: string;
};

function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const hours = minutes / 60;
if (Number.isInteger(hours)) {
return hours === 1 ? "1 hour" : `${hours} hours`;
}
return `${Math.floor(hours)} h ${minutes % 60} min`;
}

/** One readable line for the live region and the summary row. */
function summarise(payload: ComposerPayload): string {
switch (payload.intent) {
case "link":
return `Link · ${payload.url}`;
case "math":
return `Calculation · ${payload.math.expression} = ${payload.math.result ?? "—"}`;
case "meeting": {
const { meeting } = payload;
const when = [meeting.day, meeting.time].filter(Boolean).join(" ");
return [
"Meeting",
when,
meeting.durationMin !== null
? formatDuration(meeting.durationMin)
: "",
]
.filter(Boolean)
.join(" · ");
}
case "task":
return payload.tasks.length === 1
? "1 task"
: `${payload.tasks.length} tasks`;
case "place":
return `Place · ${payload.place.name}`;
case "person":
return `People · ${payload.person.names.join(", ")}`;
default:
return "Text";
}
}

/** The read-only structured form for the recognised intent. */
function structureDetail(payload: ComposerPayload): ReactNode {
switch (payload.intent) {
case "meeting": {
const { meeting } = payload;
return (
<dl className="ac-grid">
<div className="ac-row">
<dt>Title</dt>
<dd>{meeting.title}</dd>
</div>
<div className="ac-row">
<dt>Day</dt>
<dd>{meeting.day ?? "—"}</dd>
</div>
<div className="ac-row">
<dt>Time</dt>
<dd>{meeting.time ?? "—"}</dd>
</div>
<div className="ac-row">
<dt>Duration</dt>
<dd>
{meeting.durationMin !== null
? formatDuration(meeting.durationMin)
: "—"}
</dd>
</div>
</dl>
);
}
case "task":
return (
<ul className="ac-tasks">
{payload.tasks.map((task, index) => (
<li key={`${task.text}-${index}`}>
<span className="ac-box" aria-hidden="true" />
<span>{task.text}</span>
</li>
))}
</ul>
);
case "math":
return (
<div className="ac-math">
<code className="ac-expr">{payload.math.expression}</code>
<span className="ac-eq" aria-hidden="true">
=
</span>
<strong className="ac-value">{payload.math.result ?? "—"}</strong>
</div>
);
case "place":
return (
<dl className="ac-grid">
<div className="ac-row">
<dt>Place</dt>
<dd>{payload.place.name}</dd>
</div>
<div className="ac-row">
<dt>Context</dt>
<dd>{payload.place.context ?? "—"}</dd>
</div>
</dl>
);
case "person":
return (
<div className="ac-people">
{payload.person.names.map((name) => (
<span className="ac-person" key={name}>
{name}
</span>
))}
{payload.person.verbs.length > 0 ? (
<p className="ac-quiet">
{payload.person.verbs.join(", ")}
</p>
) : null}
</div>
);
case "link":
return <p className="ac-url">{payload.url}</p>;
default:
return (
<p className="ac-quiet">
Nothing structured recognised — it will be read as a plain note.
</p>
);
}
}

/**
* ADAPTIVE COMPOSER — one input, a structure that follows the meaning.
*
* A deterministic parser (no network, no AI) reads the sentence as it is
* typed: the chip above the field names the intent, and the panel below
* lays out the structured form — parsed date and duration, a live result,
* a place, a checklist, people or the link. The panel is a polite live
* region and purely presentational; the text field is the only control,
* and ⌘/Ctrl+Enter submits without inserting a newline.
*/
export function AdaptiveComposer({
value,
onValueChange,
defaultValue = "",
sample,
onSubmit,
disabled = false,
className,
}: AdaptiveComposerProps) {
const controlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue);
const text = controlled ? value : internal;
const parsed = useMemo(() => parseComposerInput(text), [text]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputId = useId();
const hasText = text.trim().length > 0;

/* Auto-grow between one and three rows. */
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const styles = window.getComputedStyle(el);
const lineHeight = Number.parseFloat(styles.lineHeight);
const padding =
Number.parseFloat(styles.paddingTop) +
Number.parseFloat(styles.paddingBottom);
const max =
(Number.isFinite(lineHeight) ? lineHeight : 22) * 3 +
(Number.isFinite(padding) ? padding : 0);
el.style.height = `${Math.min(el.scrollHeight, max)}px`;
}, [text, disabled]);

const update = (next: string): void => {
if (!controlled) setInternal(next);
onValueChange?.(next);
};

const handleKeyDown = (
event: ReactKeyboardEvent<HTMLTextAreaElement>,
): void => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
onSubmit?.(text, parsed.intent, parsed.payload);
}
};

return (
<div
className={`ac-root${className ? ` ${className}` : ""}`}
data-intent={parsed.intent}
data-disabled={disabled ? "true" : "false"}
>
<div className="ac-field">
<div className="ac-field__head">
<label className="ac-label" htmlFor={inputId}>
Compose
</label>
{hasText ? (
<span
className="ac-chip"
title={`Confidence ${Math.round(parsed.confidence * 100)}%`}
>
{formatIntent(parsed.intent)}
</span>
) : null}
</div>

<textarea
id={inputId}
ref={textareaRef}
className="ac-input"
rows={1}
value={text}
onChange={(event) => update(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={sample}
disabled={disabled}
/>

<div className="ac-field__foot">
<span className="ac-keys" aria-hidden="true">
⌘/Ctrl + Enter
</span>
{hasText && !disabled ? (
<button
type="button"
className="ac-clear"
onClick={() => {
update("");
textareaRef.current?.focus();
}}
>
Clear
</button>
) : null}
</div>
</div>

<div className="ac-structure" aria-live="polite">
{hasText ? (
<div className="ac-card">
<p className="ac-summary">{summarise(parsed.payload)}</p>
{structureDetail(parsed.payload)}
</div>
) : (
<p className="ac-quiet">
The structured form appears here as you type.
</p>
)}
</div>
</div>
);
}
components/lab/adaptive-composer/composer-parse.ts— press enter to show or hide the source
ts
/**
* COMPOSER PARSE — pure, deterministic text-to-structure parsing.
*
* No React, no DOM, no dependencies: the same sentence always produces
* the same structure, which keeps the composer testable and completely
* offline.
*
* Priority order (the first rule that fires wins):
* link > math > meeting > task > place > person > text
* A URL is never a meeting, arithmetic is narrow and unambiguous,
* scheduling phrases are more specific than generic lists, capitalised
* places are more intentional than capitalised names, and text is the
* honest fallback.
*/

export type ComposerIntent =
| "text"
| "task"
| "meeting"
| "math"
| "place"
| "person"
| "link";

export type ParsedTask = { text: string; done: false };
export type ParsedMeeting = {
title: string;
day: string | null;
time: string | null;
durationMin: number | null;
};
export type ParsedMath = { expression: string; result: number | null };
export type ParsedPlace = { name: string; context: string | null };
export type ParsedPerson = { names: string[]; verbs: string[] };

export type ComposerPayload =
| { intent: "text" }
| { intent: "task"; tasks: ParsedTask[] }
| { intent: "meeting"; meeting: ParsedMeeting }
| { intent: "math"; math: ParsedMath }
| { intent: "place"; place: ParsedPlace }
| { intent: "person"; person: ParsedPerson }
| { intent: "link"; url: string };

/* Confidence is fixed per rule so the composer never pretends to guess. */
const CONFIDENCE = {
link: 0.9,
math: 0.95,
meeting: 0.85,
task: 0.85,
place: 0.7,
person: 0.65,
text: 0.3,
} as const;

const INTENT_LABELS: Record<ComposerIntent, string> = {
text: "Text",
task: "Tasks",
meeting: "Meeting",
math: "Calculation",
place: "Place",
person: "People",
link: "Link",
};

export function formatIntent(intent: ComposerIntent): string {
return INTENT_LABELS[intent];
}

/* ------------------------------------------------------------------ *
* Link *
* ------------------------------------------------------------------ */

/*
* A scheme or www prefix matches anything; a bare domain must end in a
* known TLD so prose like "one.hour" or "e.g." never reads as a link.
*/
const TLDS =
"com|org|net|io|dev|app|ai|co|me|info|biz|design|studio|xyz|edu|gov|" +
"us|uk|ca|au|de|fr|es|it|nl|se|no|fi|dk|eu|tv|fm|gg|sh|to|so|ly|" +
"codes|tools|works|space|site|online|page|art|blog|email|tech";

const LINK_RE = new RegExp(
`(?:https?:\\/\\/|www\\.)[^\\s]+|\\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.(?:${TLDS})(?:\\.[a-z]{2})?(?:\\/[^\\s]*)?`,
"i",
);

function matchLink(text: string): string | null {
const match = LINK_RE.exec(text);
if (!match) return null;
return match[0].replace(/[),.;:!?]+$/, "");
}

/* ------------------------------------------------------------------ *
* Math *
* ------------------------------------------------------------------ */

/* A leading phrase and a trailing question mark are noise, not maths. */
const MATH_PREFIX =
/^(?:what\s*(?:'s|is)|what's|how\s+much\s+is|calc(?:ulate)?|compute|=)\s*/i;
const MATH_ONLY = /^[\d\s+\-*/%().^]+$/;
const MATH_OPERATOR = /[+\-*/%^]/;

function mathExpression(text: string): string | null {
const expression = text.replace(MATH_PREFIX, "").replace(/\?+$/, "").trim();
if (!expression || !MATH_ONLY.test(expression)) return null;
if (!MATH_OPERATOR.test(expression)) return null;
const numbers = expression.match(/\d+/g) ?? [];
if (numbers.length < 2) return null;
return expression;
}

/**
* Safe arithmetic evaluator: numbers, + - * / % ( ) and ^ only.
* A tiny recursive-descent parser — never eval(), never Function.
* Returns null for malformed input, division by zero or non-finite
* results.
*/
export function evaluateExpression(expression: string): number | null {
const src = expression.replace(/\s+/g, "");
if (!src || src.length > 200) return null;

const tokens: Array<number | string> = [];
let i = 0;
while (i < src.length) {
const ch = src.charAt(i);
if ((ch >= "0" && ch <= "9") || ch === ".") {
let j = i;
while (j < src.length) {
const next = src.charAt(j);
if ((next >= "0" && next <= "9") || next === ".") j += 1;
else break;
}
const raw = src.slice(i, j);
if (raw === "." || (raw.match(/\./g) ?? []).length > 1) return null;
const value = Number(raw);
if (!Number.isFinite(value)) return null;
tokens.push(value);
i = j;
continue;
}
if ("+-*/%^()".indexOf(ch) !== -1) {
tokens.push(ch);
i += 1;
continue;
}
return null;
}

let pos = 0;
const token = (): number | string | undefined => tokens[pos];

const parsePrimary = (): number | null => {
const current = token();
if (typeof current === "number") {
pos += 1;
return current;
}
if (current === "(") {
pos += 1;
const value = parseExpression();
if (value === null || token() !== ")") return null;
pos += 1;
return value;
}
return null;
};

const parsePower = (): number | null => {
const base = parsePrimary();
if (base === null) return null;
if (token() === "^") {
pos += 1;
const exponent = parseUnary();
if (exponent === null) return null;
const value = Math.pow(base, exponent);
return Number.isFinite(value) ? value : null;
}
return base;
};

const parseUnary = (): number | null => {
const current = token();
if (current === "-" || current === "+") {
pos += 1;
const value = parseUnary();
if (value === null) return null;
return current === "-" ? -value : value;
}
return parsePower();
};

const parseTerm = (): number | null => {
const first = parseUnary();
if (first === null) return null;
let value: number = first;
for (;;) {
const op = token();
if (op !== "*" && op !== "/" && op !== "%") break;
pos += 1;
const rhs = parseUnary();
if (rhs === null) return null;
const next: number =
op === "*" ? value * rhs : op === "/" ? value / rhs : value % rhs;
if (!Number.isFinite(next)) return null;
value = next;
}
return value;
};

const parseExpression = (): number | null => {
const first = parseTerm();
if (first === null) return null;
let value: number = first;
for (;;) {
const op = token();
if (op !== "+" && op !== "-") break;
pos += 1;
const rhs = parseTerm();
if (rhs === null) return null;
const next: number = op === "+" ? value + rhs : value - rhs;
if (!Number.isFinite(next)) return null;
value = next;
}
return value;
};

const result = parseExpression();
if (result === null || pos !== tokens.length) return null;
return result;
}

/* ------------------------------------------------------------------ *
* Day, time, duration *
* ------------------------------------------------------------------ */

type Match<T> = { value: T; raw: string };

const DAY_WORDS: Array<[RegExp, string]> = [
[/\bnext\s+week\b/i, "Next week"],
[/\btoday\b/i, "Today"],
[/\btomorrow\b/i, "Tomorrow"],
[/\bmon(?:day)?\b/i, "Monday"],
[/\btue(?:s(?:day)?)?\b/i, "Tuesday"],
[/\bwed(?:nesday)?\b/i, "Wednesday"],
[/\bthu(?:r(?:s(?:day)?)?)?\b/i, "Thursday"],
[/\bfri(?:day)?\b/i, "Friday"],
[/\bsat(?:urday)?\b/i, "Saturday"],
[/\bsun(?:day)?\b/i, "Sunday"],
];

function matchDay(text: string): Match<string> | null {
for (const [pattern, label] of DAY_WORDS) {
const match = pattern.exec(text);
if (match) return { value: label, raw: match[0] };
}
return null;
}

type TimeMatch = Match<string> & { hour: number; minute: number };

function matchTime(text: string): TimeMatch | null {
const meridiem = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i.exec(text);
if (meridiem) {
const hour = Number(meridiem[1]);
const minute = meridiem[2] ? Number(meridiem[2]) : 0;
if (hour < 1 || hour > 12 || minute > 59) return null;
const h24 = (hour % 12) + (meridiem[3].toLowerCase() === "pm" ? 12 : 0);
return {
value: `${String(h24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`,
raw: meridiem[0],
hour: h24,
minute,
};
}
const clock = /\b(\d{1,2}):(\d{2})\b/.exec(text);
if (clock) {
const hour = Number(clock[1]);
const minute = Number(clock[2]);
if (hour > 23 || minute > 59) return null;
return {
value: `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`,
raw: clock[0],
hour,
minute,
};
}
return null;
}

export function parseDayTime(text: string): {
day: string | null;
time: string | null;
} {
return {
day: matchDay(text)?.value ?? null,
time: matchTime(text)?.value ?? null,
};
}

const HOUR_AND_HALF = /\ban?\s+hour\s+and\s+a\s+half\b/i; // 90 min
const HALF_AN_HOUR = /\bhalf\s+an?\s+hour\b/i; // 30 min
const HOURS_MINUTES =
/(\d+)\s*h(?:(?:ou)?rs?)?(?:\s*(\d+)\s*m?(?:in(?:ute)?s?)?)?/i;
const MINUTES = /(\d+)\s*(?:min(?:ute)?s?|m)\b/i;
const WORD_HOURS = /\b(one|two|three|four|five|six|an?)\s+hours?\b/i;
const COUPLE_HOURS = /\ba\s+couple\s+of\s+hours\b/i;

const WORD_HOUR_VALUES: Record<string, number> = {
one: 1,
an: 1,
a: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
};

/** "45m" → 45, "1h30" → 90, "one hour" → 60. Null when nothing matches. */
export function parseDuration(text: string): number | null {
return matchDuration(text)?.value ?? null;
}

function matchDuration(text: string): Match<number> | null {
const ninety = HOUR_AND_HALF.exec(text);
if (ninety) return { value: 90, raw: ninety[0] };
const thirty = HALF_AN_HOUR.exec(text);
if (thirty) return { value: 30, raw: thirty[0] };
const hours = HOURS_MINUTES.exec(text);
if (hours) {
const minutes = Number(hours[1]) * 60 + (hours[2] ? Number(hours[2]) : 0);
if (minutes > 0) return { value: minutes, raw: hours[0] };
}
const plain = MINUTES.exec(text);
if (plain) {
const minutes = Number(plain[1]);
if (minutes > 0) return { value: minutes, raw: plain[0] };
}
const couple = COUPLE_HOURS.exec(text);
if (couple) return { value: 120, raw: couple[0] };
const words = WORD_HOURS.exec(text);
if (words) {
const hoursCount = WORD_HOUR_VALUES[words[1].toLowerCase()] ?? 0;
if (hoursCount > 0) return { value: hoursCount * 60, raw: words[0] };
}
return null;
}

/* ------------------------------------------------------------------ *
* Meeting *
* ------------------------------------------------------------------ */

const MEETING_VERBS =
/\b(meet|meeting|call|review|stand-?up|sync|catch\s+up|schedule|book)\b/i;

function capitalize(text: string): string {
if (!text) return text;
return text.charAt(0).toUpperCase() + text.slice(1);
}

function meetingTitle(
text: string,
day: Match<string> | null,
time: TimeMatch | null,
duration: Match<number> | null,
): string {
let title = text;
for (const raw of [day?.raw, time?.raw, duration?.raw]) {
if (raw) title = title.replace(raw, " ");
}
title = title.replace(/\s+/g, " ").trim();
title = title.replace(
/^(?:(?:meet|meeting|call|review|stand-?up|sync|catch\s+up|schedule|book)\b[\s:,.-]*)+/i,
"",
);
/* Fragments often leave a dangling connector at either end. */
title = title.replace(/^(?:on|at|for|in|from|by|about)\b\s*/i, "");
title = title.replace(/(?:\s+(?:on|at|for|in|from|by|about))+\s*$/i, "");
title = title.replace(/\s+/g, " ").trim();
return capitalize(title) || "Meeting";
}

/* ------------------------------------------------------------------ *
* Tasks *
* ------------------------------------------------------------------ */

const TASK_SPLIT =
/\n+|\s*;\s*|\s*,\s*then\s+|\s+and\s+then\s+|\s+then\s+/i;
const ACTION_START =
/^(?:add|ask|book|buy|call|check|create|draft|email|file|finish|fix|follow\s+up|make|move|order|pay|ping|plan|prepare|read|renew|review|schedule|send|ship|summari[sz]e|update|write)\b/i;

/* ------------------------------------------------------------------ *
* Place and person *
* ------------------------------------------------------------------ */

const PLACE_RE =
/\b(?:[Ii]n|[Aa]t|[Nn]ear|[Aa]round)\s+([A-Z][\w'-]*(?:\s+[A-Z][\w'-]*)*)/;

const PERSON_RE =
/\b(with|ask|tell|email|ping)\s+([A-Z][a-z]+(?:[, ]+(?:and\s+)?[A-Z][a-z]+)*)/g;

/* ------------------------------------------------------------------ *
* The parser *
* ------------------------------------------------------------------ */

export function parseComposerInput(text: string): {
intent: ComposerIntent;
confidence: number;
payload: ComposerPayload;
} {
const trimmed = text.trim();
if (!trimmed) {
return { intent: "text", confidence: CONFIDENCE.text, payload: { intent: "text" } };
}

/* 1 — link */
const url = matchLink(trimmed);
if (url) {
return {
intent: "link",
confidence: CONFIDENCE.link,
payload: { intent: "link", url },
};
}

/* 2 — math */
const expression = mathExpression(trimmed);
if (expression) {
return {
intent: "math",
confidence: CONFIDENCE.math,
payload: {
intent: "math",
math: { expression, result: evaluateExpression(expression) },
},
};
}

/* 3 — meeting */
const day = matchDay(trimmed);
const time = matchTime(trimmed);
const duration = matchDuration(trimmed);
if ((day || time) && (duration || MEETING_VERBS.test(trimmed))) {
return {
intent: "meeting",
confidence: CONFIDENCE.meeting,
payload: {
intent: "meeting",
meeting: {
title: meetingTitle(trimmed, day, time, duration),
day: day?.value ?? null,
time: time?.value ?? null,
durationMin: duration?.value ?? null,
},
},
};
}

/* 4 — task */
const segments = trimmed
.split(TASK_SPLIT)
.map((segment) => segment.trim())
.filter(Boolean);
if (
segments.length >= 2 ||
(ACTION_START.test(trimmed) && /\bthen\b/i.test(trimmed))
) {
const tasks: ParsedTask[] = segments.map((segment) => ({
text: capitalize(segment),
done: false,
}));
return {
intent: "task",
confidence: CONFIDENCE.task,
payload: { intent: "task", tasks },
};
}

/* 5 — place */
const place = PLACE_RE.exec(trimmed);
if (place) {
const rest = trimmed.slice(place.index + place[0].length).trim();
return {
intent: "place",
confidence: CONFIDENCE.place,
payload: {
intent: "place",
place: { name: place[1], context: rest || null },
},
};
}

/* 6 — person */
const names: string[] = [];
const verbs: string[] = [];
PERSON_RE.lastIndex = 0;
for (;;) {
const match = PERSON_RE.exec(trimmed);
if (!match) break;
for (const name of match[2].split(/\s*(?:,|\band\b)\s*/)) {
if (name && !names.includes(name)) names.push(name);
}
const verb = match[1].toLowerCase();
if (!verbs.includes(verb)) verbs.push(verb);
}
if (names.length > 0) {
return {
intent: "person",
confidence: CONFIDENCE.person,
payload: { intent: "person", person: { names, verbs } },
};
}

/* 7 — text */
return { intent: "text", confidence: CONFIDENCE.text, payload: { intent: "text" } };
}
components/lab/adaptive-composer/adaptive-composer.css— press enter to show or hide the source
css
/* ============================================================
ADAPTIVE COMPOSER — self-contained styles.
Every token is scoped with a fallback, so the component works in
any codebase without the Lab's stylesheet.
============================================================ */

.ac-root {
--ac-ink: #141418;
--ac-muted: #5f5f68;
--ac-faint: #8c8c94;
--ac-line: rgba(20, 20, 24, 0.12);
--ac-line-soft: rgba(20, 20, 24, 0.07);
--ac-surface: #ffffff;
--ac-wash: #f7f7f6;
--ac-accent: #32119c;
--ac-accent-soft: rgba(79, 39, 224, 0.1);
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
color: var(--ac-ink);
font-size: 0.9375rem;
line-height: 1.5;
}

/* A quiet accent per recognised intent; text stays AA on light surfaces. */
.ac-root[data-intent="math"] {
--ac-accent: #0f5d51;
--ac-accent-soft: rgba(15, 107, 92, 0.12);
}

.ac-root[data-intent="place"] {
--ac-accent: #7a4a12;
--ac-accent-soft: rgba(146, 91, 21, 0.12);
}

.ac-root[data-intent="person"] {
--ac-accent: #7d2c4e;
--ac-accent-soft: rgba(157, 58, 99, 0.12);
}

.ac-root[data-intent="link"] {
--ac-accent: #1f4f8f;
--ac-accent-soft: rgba(31, 79, 143, 0.12);
}

.ac-root[data-intent="task"] {
--ac-accent: #3f3f46;
--ac-accent-soft: rgba(63, 63, 70, 0.1);
}

/* ---- field ---- */

.ac-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 12px 14px 10px;
background: var(--ac-surface);
border: 1px solid var(--ac-line);
border-radius: 14px;
transition:
border-color 140ms ease,
box-shadow 140ms ease;
}

.ac-field:focus-within {
border-color: var(--ac-accent);
box-shadow: 0 0 0 3px var(--ac-accent-soft);
}

.ac-field__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 20px;
}

.ac-label {
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 0.625rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ac-faint);
}

.ac-chip {
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 0.625rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ac-accent);
background: var(--ac-accent-soft);
border-radius: 999px;
padding: 3px 10px;
white-space: nowrap;
transition:
color 140ms ease,
background-color 140ms ease;
}

.ac-input {
display: block;
width: 100%;
min-width: 0;
min-height: calc(1.5em + 4px);
padding: 2px 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font: inherit;
caret-color: var(--ac-accent);
resize: none;
overflow-y: auto;
transition: height 120ms ease;
}

.ac-input::placeholder {
color: var(--ac-faint);
}

.ac-input:disabled {
color: var(--ac-faint);
cursor: not-allowed;
}

.ac-field__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}

.ac-keys {
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 0.625rem;
letter-spacing: 0.06em;
color: var(--ac-faint);
}

.ac-clear {
margin: -2px -6px -2px 0;
padding: 2px 6px;
border: 0;
border-radius: 6px;
background: none;
color: var(--ac-muted);
font: inherit;
font-size: 0.6875rem;
cursor: pointer;
}

.ac-clear:hover {
color: var(--ac-ink);
background: var(--ac-line-soft);
}

.ac-clear:focus-visible {
outline: 2px solid var(--ac-accent);
outline-offset: 1px;
}

/* ---- structure panel ---- */

.ac-structure {
min-height: 34px;
}

.ac-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
background: var(--ac-wash);
border: 1px solid var(--ac-line-soft);
border-radius: 12px;
}

.ac-summary {
margin: 0;
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 0.6875rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ac-accent);
}

.ac-grid {
display: grid;
gap: 6px;
margin: 0;
}

.ac-row {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
gap: 10px;
align-items: baseline;
}

.ac-row dt {
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 0.625rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ac-faint);
}

.ac-row dd {
margin: 0;
font-size: 0.875rem;
color: var(--ac-ink);
overflow-wrap: anywhere;
}

.ac-tasks {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}

.ac-tasks li {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 0.875rem;
}

.ac-box {
flex: none;
width: 13px;
height: 13px;
margin-top: 2px;
border: 1.5px solid var(--ac-line);
border-radius: 4px;
}

.ac-math {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}

.ac-expr {
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 0.8125rem;
color: var(--ac-muted);
}

.ac-eq {
color: var(--ac-faint);
}

.ac-value {
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 1.0625rem;
letter-spacing: -0.01em;
color: var(--ac-accent);
}

.ac-people {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}

.ac-people .ac-quiet {
flex-basis: 100%;
}

.ac-person {
padding: 2px 10px;
background: var(--ac-surface);
border: 1px solid var(--ac-line);
border-radius: 999px;
font-size: 0.8125rem;
}

.ac-url {
margin: 0;
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
Menlo, monospace;
font-size: 0.8125rem;
color: var(--ac-accent);
overflow-wrap: anywhere;
}

.ac-quiet {
margin: 0;
font-size: 0.8125rem;
color: var(--ac-faint);
}

/* ---- disabled ---- */

.ac-root[data-disabled="true"] .ac-field {
background: #fbfbfa;
}

.ac-root[data-disabled="true"] .ac-structure,
.ac-root[data-disabled="true"] .ac-field__foot {
opacity: 0.55;
}

@media (prefers-reduced-motion: reduce) {
.ac-field,
.ac-chip,
.ac-input {
transition: none;
}
}