Adaptive Composer
Type a sentence; the input rearranges into the structure it recognised.
Ledger · operations
Capture
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
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.jsonNo 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
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
| Prop | Type | Default | Notes |
|---|---|---|---|
value / onValueChange | string / (v: string) => void | — | Controlled text; omit both to let the composer own it. |
defaultValue | string | "" | Initial text for uncontrolled use. |
onSubmit | (value, intent, payload) => void | — | Fires on ⌘/Ctrl+Enter with the parsed structure. |
sample | string | — | Example sentence shown as a nudge while empty. |
disabled | boolean | false | Disables 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
"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
/**
* 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
/* ============================================================
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;
}
}