{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"adaptive-composer","type":"registry:component","title":"Adaptive Composer","description":"Type a sentence; the input rearranges into the structure it recognised.","dependencies":[],"categories":["input","composer","input","intent","deterministic"],"files":[{"path":"components/lab/adaptive-composer/adaptive-composer.tsx","target":"components/lab/adaptive-composer/adaptive-composer.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport {\n  formatIntent,\n  parseComposerInput,\n  type ComposerIntent,\n  type ComposerPayload,\n} from \"./composer-parse\";\nimport \"./adaptive-composer.css\";\n\nexport type AdaptiveComposerProps = {\n  /** Controlled text; omit both value and onValueChange to let it own the text. */\n  value?: string;\n  onValueChange?: (value: string) => void;\n  /** Initial text for uncontrolled use. */\n  defaultValue?: string;\n  /** Example sentence shown as a nudge while the field is empty. */\n  sample?: string;\n  /** Fires on ⌘/Ctrl+Enter with the raw value and the parsed structure. */\n  onSubmit?: (\n    value: string,\n    intent: ComposerIntent,\n    payload: ComposerPayload,\n  ) => void;\n  disabled?: boolean;\n  className?: string;\n};\n\nfunction formatDuration(minutes: number): string {\n  if (minutes < 60) return `${minutes} min`;\n  const hours = minutes / 60;\n  if (Number.isInteger(hours)) {\n    return hours === 1 ? \"1 hour\" : `${hours} hours`;\n  }\n  return `${Math.floor(hours)} h ${minutes % 60} min`;\n}\n\n/** One readable line for the live region and the summary row. */\nfunction summarise(payload: ComposerPayload): string {\n  switch (payload.intent) {\n    case \"link\":\n      return `Link · ${payload.url}`;\n    case \"math\":\n      return `Calculation · ${payload.math.expression} = ${payload.math.result ?? \"—\"}`;\n    case \"meeting\": {\n      const { meeting } = payload;\n      const when = [meeting.day, meeting.time].filter(Boolean).join(\" \");\n      return [\n        \"Meeting\",\n        when,\n        meeting.durationMin !== null\n          ? formatDuration(meeting.durationMin)\n          : \"\",\n      ]\n        .filter(Boolean)\n        .join(\" · \");\n    }\n    case \"task\":\n      return payload.tasks.length === 1\n        ? \"1 task\"\n        : `${payload.tasks.length} tasks`;\n    case \"place\":\n      return `Place · ${payload.place.name}`;\n    case \"person\":\n      return `People · ${payload.person.names.join(\", \")}`;\n    default:\n      return \"Text\";\n  }\n}\n\n/** The read-only structured form for the recognised intent. */\nfunction structureDetail(payload: ComposerPayload): ReactNode {\n  switch (payload.intent) {\n    case \"meeting\": {\n      const { meeting } = payload;\n      return (\n        <dl className=\"ac-grid\">\n          <div className=\"ac-row\">\n            <dt>Title</dt>\n            <dd>{meeting.title}</dd>\n          </div>\n          <div className=\"ac-row\">\n            <dt>Day</dt>\n            <dd>{meeting.day ?? \"—\"}</dd>\n          </div>\n          <div className=\"ac-row\">\n            <dt>Time</dt>\n            <dd>{meeting.time ?? \"—\"}</dd>\n          </div>\n          <div className=\"ac-row\">\n            <dt>Duration</dt>\n            <dd>\n              {meeting.durationMin !== null\n                ? formatDuration(meeting.durationMin)\n                : \"—\"}\n            </dd>\n          </div>\n        </dl>\n      );\n    }\n    case \"task\":\n      return (\n        <ul className=\"ac-tasks\">\n          {payload.tasks.map((task, index) => (\n            <li key={`${task.text}-${index}`}>\n              <span className=\"ac-box\" aria-hidden=\"true\" />\n              <span>{task.text}</span>\n            </li>\n          ))}\n        </ul>\n      );\n    case \"math\":\n      return (\n        <div className=\"ac-math\">\n          <code className=\"ac-expr\">{payload.math.expression}</code>\n          <span className=\"ac-eq\" aria-hidden=\"true\">\n            =\n          </span>\n          <strong className=\"ac-value\">{payload.math.result ?? \"—\"}</strong>\n        </div>\n      );\n    case \"place\":\n      return (\n        <dl className=\"ac-grid\">\n          <div className=\"ac-row\">\n            <dt>Place</dt>\n            <dd>{payload.place.name}</dd>\n          </div>\n          <div className=\"ac-row\">\n            <dt>Context</dt>\n            <dd>{payload.place.context ?? \"—\"}</dd>\n          </div>\n        </dl>\n      );\n    case \"person\":\n      return (\n        <div className=\"ac-people\">\n          {payload.person.names.map((name) => (\n            <span className=\"ac-person\" key={name}>\n              {name}\n            </span>\n          ))}\n          {payload.person.verbs.length > 0 ? (\n            <p className=\"ac-quiet\">\n              {payload.person.verbs.join(\", \")}\n            </p>\n          ) : null}\n        </div>\n      );\n    case \"link\":\n      return <p className=\"ac-url\">{payload.url}</p>;\n    default:\n      return (\n        <p className=\"ac-quiet\">\n          Nothing structured recognised — it will be read as a plain note.\n        </p>\n      );\n  }\n}\n\n/**\n * ADAPTIVE COMPOSER — one input, a structure that follows the meaning.\n *\n * A deterministic parser (no network, no AI) reads the sentence as it is\n * typed: the chip above the field names the intent, and the panel below\n * lays out the structured form — parsed date and duration, a live result,\n * a place, a checklist, people or the link. The panel is a polite live\n * region and purely presentational; the text field is the only control,\n * and ⌘/Ctrl+Enter submits without inserting a newline.\n */\nexport function AdaptiveComposer({\n  value,\n  onValueChange,\n  defaultValue = \"\",\n  sample,\n  onSubmit,\n  disabled = false,\n  className,\n}: AdaptiveComposerProps) {\n  const controlled = value !== undefined;\n  const [internal, setInternal] = useState(defaultValue);\n  const text = controlled ? value : internal;\n  const parsed = useMemo(() => parseComposerInput(text), [text]);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const inputId = useId();\n  const hasText = text.trim().length > 0;\n\n  /* Auto-grow between one and three rows. */\n  useEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    const styles = window.getComputedStyle(el);\n    const lineHeight = Number.parseFloat(styles.lineHeight);\n    const padding =\n      Number.parseFloat(styles.paddingTop) +\n      Number.parseFloat(styles.paddingBottom);\n    const max =\n      (Number.isFinite(lineHeight) ? lineHeight : 22) * 3 +\n      (Number.isFinite(padding) ? padding : 0);\n    el.style.height = `${Math.min(el.scrollHeight, max)}px`;\n  }, [text, disabled]);\n\n  const update = (next: string): void => {\n    if (!controlled) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  const handleKeyDown = (\n    event: ReactKeyboardEvent<HTMLTextAreaElement>,\n  ): void => {\n    if ((event.metaKey || event.ctrlKey) && event.key === \"Enter\") {\n      event.preventDefault();\n      onSubmit?.(text, parsed.intent, parsed.payload);\n    }\n  };\n\n  return (\n    <div\n      className={`ac-root${className ? ` ${className}` : \"\"}`}\n      data-intent={parsed.intent}\n      data-disabled={disabled ? \"true\" : \"false\"}\n    >\n      <div className=\"ac-field\">\n        <div className=\"ac-field__head\">\n          <label className=\"ac-label\" htmlFor={inputId}>\n            Compose\n          </label>\n          {hasText ? (\n            <span\n              className=\"ac-chip\"\n              title={`Confidence ${Math.round(parsed.confidence * 100)}%`}\n            >\n              {formatIntent(parsed.intent)}\n            </span>\n          ) : null}\n        </div>\n\n        <textarea\n          id={inputId}\n          ref={textareaRef}\n          className=\"ac-input\"\n          rows={1}\n          value={text}\n          onChange={(event) => update(event.target.value)}\n          onKeyDown={handleKeyDown}\n          placeholder={sample}\n          disabled={disabled}\n        />\n\n        <div className=\"ac-field__foot\">\n          <span className=\"ac-keys\" aria-hidden=\"true\">\n            ⌘/Ctrl + Enter\n          </span>\n          {hasText && !disabled ? (\n            <button\n              type=\"button\"\n              className=\"ac-clear\"\n              onClick={() => {\n                update(\"\");\n                textareaRef.current?.focus();\n              }}\n            >\n              Clear\n            </button>\n          ) : null}\n        </div>\n      </div>\n\n      <div className=\"ac-structure\" aria-live=\"polite\">\n        {hasText ? (\n          <div className=\"ac-card\">\n            <p className=\"ac-summary\">{summarise(parsed.payload)}</p>\n            {structureDetail(parsed.payload)}\n          </div>\n        ) : (\n          <p className=\"ac-quiet\">\n            The structured form appears here as you type.\n          </p>\n        )}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/lab/adaptive-composer/composer-parse.ts","target":"components/lab/adaptive-composer/composer-parse.ts","type":"registry:file","content":"/**\n * COMPOSER PARSE — pure, deterministic text-to-structure parsing.\n *\n * No React, no DOM, no dependencies: the same sentence always produces\n * the same structure, which keeps the composer testable and completely\n * offline.\n *\n * Priority order (the first rule that fires wins):\n *   link > math > meeting > task > place > person > text\n * A URL is never a meeting, arithmetic is narrow and unambiguous,\n * scheduling phrases are more specific than generic lists, capitalised\n * places are more intentional than capitalised names, and text is the\n * honest fallback.\n */\n\nexport type ComposerIntent =\n  | \"text\"\n  | \"task\"\n  | \"meeting\"\n  | \"math\"\n  | \"place\"\n  | \"person\"\n  | \"link\";\n\nexport type ParsedTask = { text: string; done: false };\nexport type ParsedMeeting = {\n  title: string;\n  day: string | null;\n  time: string | null;\n  durationMin: number | null;\n};\nexport type ParsedMath = { expression: string; result: number | null };\nexport type ParsedPlace = { name: string; context: string | null };\nexport type ParsedPerson = { names: string[]; verbs: string[] };\n\nexport type ComposerPayload =\n  | { intent: \"text\" }\n  | { intent: \"task\"; tasks: ParsedTask[] }\n  | { intent: \"meeting\"; meeting: ParsedMeeting }\n  | { intent: \"math\"; math: ParsedMath }\n  | { intent: \"place\"; place: ParsedPlace }\n  | { intent: \"person\"; person: ParsedPerson }\n  | { intent: \"link\"; url: string };\n\n/* Confidence is fixed per rule so the composer never pretends to guess. */\nconst CONFIDENCE = {\n  link: 0.9,\n  math: 0.95,\n  meeting: 0.85,\n  task: 0.85,\n  place: 0.7,\n  person: 0.65,\n  text: 0.3,\n} as const;\n\nconst INTENT_LABELS: Record<ComposerIntent, string> = {\n  text: \"Text\",\n  task: \"Tasks\",\n  meeting: \"Meeting\",\n  math: \"Calculation\",\n  place: \"Place\",\n  person: \"People\",\n  link: \"Link\",\n};\n\nexport function formatIntent(intent: ComposerIntent): string {\n  return INTENT_LABELS[intent];\n}\n\n/* ------------------------------------------------------------------ *\n * Link                                                                 *\n * ------------------------------------------------------------------ */\n\n/*\n * A scheme or www prefix matches anything; a bare domain must end in a\n * known TLD so prose like \"one.hour\" or \"e.g.\" never reads as a link.\n */\nconst TLDS =\n  \"com|org|net|io|dev|app|ai|co|me|info|biz|design|studio|xyz|edu|gov|\" +\n  \"us|uk|ca|au|de|fr|es|it|nl|se|no|fi|dk|eu|tv|fm|gg|sh|to|so|ly|\" +\n  \"codes|tools|works|space|site|online|page|art|blog|email|tech\";\n\nconst LINK_RE = new RegExp(\n  `(?:https?:\\\\/\\\\/|www\\\\.)[^\\\\s]+|\\\\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.(?:${TLDS})(?:\\\\.[a-z]{2})?(?:\\\\/[^\\\\s]*)?`,\n  \"i\",\n);\n\nfunction matchLink(text: string): string | null {\n  const match = LINK_RE.exec(text);\n  if (!match) return null;\n  return match[0].replace(/[),.;:!?]+$/, \"\");\n}\n\n/* ------------------------------------------------------------------ *\n * Math                                                                 *\n * ------------------------------------------------------------------ */\n\n/* A leading phrase and a trailing question mark are noise, not maths. */\nconst MATH_PREFIX =\n  /^(?:what\\s*(?:'s|is)|what's|how\\s+much\\s+is|calc(?:ulate)?|compute|=)\\s*/i;\nconst MATH_ONLY = /^[\\d\\s+\\-*/%().^]+$/;\nconst MATH_OPERATOR = /[+\\-*/%^]/;\n\nfunction mathExpression(text: string): string | null {\n  const expression = text.replace(MATH_PREFIX, \"\").replace(/\\?+$/, \"\").trim();\n  if (!expression || !MATH_ONLY.test(expression)) return null;\n  if (!MATH_OPERATOR.test(expression)) return null;\n  const numbers = expression.match(/\\d+/g) ?? [];\n  if (numbers.length < 2) return null;\n  return expression;\n}\n\n/**\n * Safe arithmetic evaluator: numbers, + - * / % ( ) and ^ only.\n * A tiny recursive-descent parser — never eval(), never Function.\n * Returns null for malformed input, division by zero or non-finite\n * results.\n */\nexport function evaluateExpression(expression: string): number | null {\n  const src = expression.replace(/\\s+/g, \"\");\n  if (!src || src.length > 200) return null;\n\n  const tokens: Array<number | string> = [];\n  let i = 0;\n  while (i < src.length) {\n    const ch = src.charAt(i);\n    if ((ch >= \"0\" && ch <= \"9\") || ch === \".\") {\n      let j = i;\n      while (j < src.length) {\n        const next = src.charAt(j);\n        if ((next >= \"0\" && next <= \"9\") || next === \".\") j += 1;\n        else break;\n      }\n      const raw = src.slice(i, j);\n      if (raw === \".\" || (raw.match(/\\./g) ?? []).length > 1) return null;\n      const value = Number(raw);\n      if (!Number.isFinite(value)) return null;\n      tokens.push(value);\n      i = j;\n      continue;\n    }\n    if (\"+-*/%^()\".indexOf(ch) !== -1) {\n      tokens.push(ch);\n      i += 1;\n      continue;\n    }\n    return null;\n  }\n\n  let pos = 0;\n  const token = (): number | string | undefined => tokens[pos];\n\n  const parsePrimary = (): number | null => {\n    const current = token();\n    if (typeof current === \"number\") {\n      pos += 1;\n      return current;\n    }\n    if (current === \"(\") {\n      pos += 1;\n      const value = parseExpression();\n      if (value === null || token() !== \")\") return null;\n      pos += 1;\n      return value;\n    }\n    return null;\n  };\n\n  const parsePower = (): number | null => {\n    const base = parsePrimary();\n    if (base === null) return null;\n    if (token() === \"^\") {\n      pos += 1;\n      const exponent = parseUnary();\n      if (exponent === null) return null;\n      const value = Math.pow(base, exponent);\n      return Number.isFinite(value) ? value : null;\n    }\n    return base;\n  };\n\n  const parseUnary = (): number | null => {\n    const current = token();\n    if (current === \"-\" || current === \"+\") {\n      pos += 1;\n      const value = parseUnary();\n      if (value === null) return null;\n      return current === \"-\" ? -value : value;\n    }\n    return parsePower();\n  };\n\n  const parseTerm = (): number | null => {\n    const first = parseUnary();\n    if (first === null) return null;\n    let value: number = first;\n    for (;;) {\n      const op = token();\n      if (op !== \"*\" && op !== \"/\" && op !== \"%\") break;\n      pos += 1;\n      const rhs = parseUnary();\n      if (rhs === null) return null;\n      const next: number =\n        op === \"*\" ? value * rhs : op === \"/\" ? value / rhs : value % rhs;\n      if (!Number.isFinite(next)) return null;\n      value = next;\n    }\n    return value;\n  };\n\n  const parseExpression = (): number | null => {\n    const first = parseTerm();\n    if (first === null) return null;\n    let value: number = first;\n    for (;;) {\n      const op = token();\n      if (op !== \"+\" && op !== \"-\") break;\n      pos += 1;\n      const rhs = parseTerm();\n      if (rhs === null) return null;\n      const next: number = op === \"+\" ? value + rhs : value - rhs;\n      if (!Number.isFinite(next)) return null;\n      value = next;\n    }\n    return value;\n  };\n\n  const result = parseExpression();\n  if (result === null || pos !== tokens.length) return null;\n  return result;\n}\n\n/* ------------------------------------------------------------------ *\n * Day, time, duration                                                  *\n * ------------------------------------------------------------------ */\n\ntype Match<T> = { value: T; raw: string };\n\nconst DAY_WORDS: Array<[RegExp, string]> = [\n  [/\\bnext\\s+week\\b/i, \"Next week\"],\n  [/\\btoday\\b/i, \"Today\"],\n  [/\\btomorrow\\b/i, \"Tomorrow\"],\n  [/\\bmon(?:day)?\\b/i, \"Monday\"],\n  [/\\btue(?:s(?:day)?)?\\b/i, \"Tuesday\"],\n  [/\\bwed(?:nesday)?\\b/i, \"Wednesday\"],\n  [/\\bthu(?:r(?:s(?:day)?)?)?\\b/i, \"Thursday\"],\n  [/\\bfri(?:day)?\\b/i, \"Friday\"],\n  [/\\bsat(?:urday)?\\b/i, \"Saturday\"],\n  [/\\bsun(?:day)?\\b/i, \"Sunday\"],\n];\n\nfunction matchDay(text: string): Match<string> | null {\n  for (const [pattern, label] of DAY_WORDS) {\n    const match = pattern.exec(text);\n    if (match) return { value: label, raw: match[0] };\n  }\n  return null;\n}\n\ntype TimeMatch = Match<string> & { hour: number; minute: number };\n\nfunction matchTime(text: string): TimeMatch | null {\n  const meridiem = /\\b(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm)\\b/i.exec(text);\n  if (meridiem) {\n    const hour = Number(meridiem[1]);\n    const minute = meridiem[2] ? Number(meridiem[2]) : 0;\n    if (hour < 1 || hour > 12 || minute > 59) return null;\n    const h24 = (hour % 12) + (meridiem[3].toLowerCase() === \"pm\" ? 12 : 0);\n    return {\n      value: `${String(h24).padStart(2, \"0\")}:${String(minute).padStart(2, \"0\")}`,\n      raw: meridiem[0],\n      hour: h24,\n      minute,\n    };\n  }\n  const clock = /\\b(\\d{1,2}):(\\d{2})\\b/.exec(text);\n  if (clock) {\n    const hour = Number(clock[1]);\n    const minute = Number(clock[2]);\n    if (hour > 23 || minute > 59) return null;\n    return {\n      value: `${String(hour).padStart(2, \"0\")}:${String(minute).padStart(2, \"0\")}`,\n      raw: clock[0],\n      hour,\n      minute,\n    };\n  }\n  return null;\n}\n\nexport function parseDayTime(text: string): {\n  day: string | null;\n  time: string | null;\n} {\n  return {\n    day: matchDay(text)?.value ?? null,\n    time: matchTime(text)?.value ?? null,\n  };\n}\n\nconst HOUR_AND_HALF = /\\ban?\\s+hour\\s+and\\s+a\\s+half\\b/i; // 90 min\nconst HALF_AN_HOUR = /\\bhalf\\s+an?\\s+hour\\b/i; // 30 min\nconst HOURS_MINUTES =\n  /(\\d+)\\s*h(?:(?:ou)?rs?)?(?:\\s*(\\d+)\\s*m?(?:in(?:ute)?s?)?)?/i;\nconst MINUTES = /(\\d+)\\s*(?:min(?:ute)?s?|m)\\b/i;\nconst WORD_HOURS = /\\b(one|two|three|four|five|six|an?)\\s+hours?\\b/i;\nconst COUPLE_HOURS = /\\ba\\s+couple\\s+of\\s+hours\\b/i;\n\nconst WORD_HOUR_VALUES: Record<string, number> = {\n  one: 1,\n  an: 1,\n  a: 1,\n  two: 2,\n  three: 3,\n  four: 4,\n  five: 5,\n  six: 6,\n};\n\n/** \"45m\" → 45, \"1h30\" → 90, \"one hour\" → 60. Null when nothing matches. */\nexport function parseDuration(text: string): number | null {\n  return matchDuration(text)?.value ?? null;\n}\n\nfunction matchDuration(text: string): Match<number> | null {\n  const ninety = HOUR_AND_HALF.exec(text);\n  if (ninety) return { value: 90, raw: ninety[0] };\n  const thirty = HALF_AN_HOUR.exec(text);\n  if (thirty) return { value: 30, raw: thirty[0] };\n  const hours = HOURS_MINUTES.exec(text);\n  if (hours) {\n    const minutes = Number(hours[1]) * 60 + (hours[2] ? Number(hours[2]) : 0);\n    if (minutes > 0) return { value: minutes, raw: hours[0] };\n  }\n  const plain = MINUTES.exec(text);\n  if (plain) {\n    const minutes = Number(plain[1]);\n    if (minutes > 0) return { value: minutes, raw: plain[0] };\n  }\n  const couple = COUPLE_HOURS.exec(text);\n  if (couple) return { value: 120, raw: couple[0] };\n  const words = WORD_HOURS.exec(text);\n  if (words) {\n    const hoursCount = WORD_HOUR_VALUES[words[1].toLowerCase()] ?? 0;\n    if (hoursCount > 0) return { value: hoursCount * 60, raw: words[0] };\n  }\n  return null;\n}\n\n/* ------------------------------------------------------------------ *\n * Meeting                                                              *\n * ------------------------------------------------------------------ */\n\nconst MEETING_VERBS =\n  /\\b(meet|meeting|call|review|stand-?up|sync|catch\\s+up|schedule|book)\\b/i;\n\nfunction capitalize(text: string): string {\n  if (!text) return text;\n  return text.charAt(0).toUpperCase() + text.slice(1);\n}\n\nfunction meetingTitle(\n  text: string,\n  day: Match<string> | null,\n  time: TimeMatch | null,\n  duration: Match<number> | null,\n): string {\n  let title = text;\n  for (const raw of [day?.raw, time?.raw, duration?.raw]) {\n    if (raw) title = title.replace(raw, \" \");\n  }\n  title = title.replace(/\\s+/g, \" \").trim();\n  title = title.replace(\n    /^(?:(?:meet|meeting|call|review|stand-?up|sync|catch\\s+up|schedule|book)\\b[\\s:,.-]*)+/i,\n    \"\",\n  );\n  /* Fragments often leave a dangling connector at either end. */\n  title = title.replace(/^(?:on|at|for|in|from|by|about)\\b\\s*/i, \"\");\n  title = title.replace(/(?:\\s+(?:on|at|for|in|from|by|about))+\\s*$/i, \"\");\n  title = title.replace(/\\s+/g, \" \").trim();\n  return capitalize(title) || \"Meeting\";\n}\n\n/* ------------------------------------------------------------------ *\n * Tasks                                                                *\n * ------------------------------------------------------------------ */\n\nconst TASK_SPLIT =\n  /\\n+|\\s*;\\s*|\\s*,\\s*then\\s+|\\s+and\\s+then\\s+|\\s+then\\s+/i;\nconst ACTION_START =\n  /^(?: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;\n\n/* ------------------------------------------------------------------ *\n * Place and person                                                     *\n * ------------------------------------------------------------------ */\n\nconst PLACE_RE =\n  /\\b(?:[Ii]n|[Aa]t|[Nn]ear|[Aa]round)\\s+([A-Z][\\w'-]*(?:\\s+[A-Z][\\w'-]*)*)/;\n\nconst PERSON_RE =\n  /\\b(with|ask|tell|email|ping)\\s+([A-Z][a-z]+(?:[, ]+(?:and\\s+)?[A-Z][a-z]+)*)/g;\n\n/* ------------------------------------------------------------------ *\n * The parser                                                           *\n * ------------------------------------------------------------------ */\n\nexport function parseComposerInput(text: string): {\n  intent: ComposerIntent;\n  confidence: number;\n  payload: ComposerPayload;\n} {\n  const trimmed = text.trim();\n  if (!trimmed) {\n    return { intent: \"text\", confidence: CONFIDENCE.text, payload: { intent: \"text\" } };\n  }\n\n  /* 1 — link */\n  const url = matchLink(trimmed);\n  if (url) {\n    return {\n      intent: \"link\",\n      confidence: CONFIDENCE.link,\n      payload: { intent: \"link\", url },\n    };\n  }\n\n  /* 2 — math */\n  const expression = mathExpression(trimmed);\n  if (expression) {\n    return {\n      intent: \"math\",\n      confidence: CONFIDENCE.math,\n      payload: {\n        intent: \"math\",\n        math: { expression, result: evaluateExpression(expression) },\n      },\n    };\n  }\n\n  /* 3 — meeting */\n  const day = matchDay(trimmed);\n  const time = matchTime(trimmed);\n  const duration = matchDuration(trimmed);\n  if ((day || time) && (duration || MEETING_VERBS.test(trimmed))) {\n    return {\n      intent: \"meeting\",\n      confidence: CONFIDENCE.meeting,\n      payload: {\n        intent: \"meeting\",\n        meeting: {\n          title: meetingTitle(trimmed, day, time, duration),\n          day: day?.value ?? null,\n          time: time?.value ?? null,\n          durationMin: duration?.value ?? null,\n        },\n      },\n    };\n  }\n\n  /* 4 — task */\n  const segments = trimmed\n    .split(TASK_SPLIT)\n    .map((segment) => segment.trim())\n    .filter(Boolean);\n  if (\n    segments.length >= 2 ||\n    (ACTION_START.test(trimmed) && /\\bthen\\b/i.test(trimmed))\n  ) {\n    const tasks: ParsedTask[] = segments.map((segment) => ({\n      text: capitalize(segment),\n      done: false,\n    }));\n    return {\n      intent: \"task\",\n      confidence: CONFIDENCE.task,\n      payload: { intent: \"task\", tasks },\n    };\n  }\n\n  /* 5 — place */\n  const place = PLACE_RE.exec(trimmed);\n  if (place) {\n    const rest = trimmed.slice(place.index + place[0].length).trim();\n    return {\n      intent: \"place\",\n      confidence: CONFIDENCE.place,\n      payload: {\n        intent: \"place\",\n        place: { name: place[1], context: rest || null },\n      },\n    };\n  }\n\n  /* 6 — person */\n  const names: string[] = [];\n  const verbs: string[] = [];\n  PERSON_RE.lastIndex = 0;\n  for (;;) {\n    const match = PERSON_RE.exec(trimmed);\n    if (!match) break;\n    for (const name of match[2].split(/\\s*(?:,|\\band\\b)\\s*/)) {\n      if (name && !names.includes(name)) names.push(name);\n    }\n    const verb = match[1].toLowerCase();\n    if (!verbs.includes(verb)) verbs.push(verb);\n  }\n  if (names.length > 0) {\n    return {\n      intent: \"person\",\n      confidence: CONFIDENCE.person,\n      payload: { intent: \"person\", person: { names, verbs } },\n    };\n  }\n\n  /* 7 — text */\n  return { intent: \"text\", confidence: CONFIDENCE.text, payload: { intent: \"text\" } };\n}\n"},{"path":"components/lab/adaptive-composer/adaptive-composer.css","target":"components/lab/adaptive-composer/adaptive-composer.css","type":"registry:file","content":"/* ============================================================\n   ADAPTIVE COMPOSER — self-contained styles.\n   Every token is scoped with a fallback, so the component works in\n   any codebase without the Lab's stylesheet.\n   ============================================================ */\n\n.ac-root {\n  --ac-ink: #141418;\n  --ac-muted: #5f5f68;\n  --ac-faint: #8c8c94;\n  --ac-line: rgba(20, 20, 24, 0.12);\n  --ac-line-soft: rgba(20, 20, 24, 0.07);\n  --ac-surface: #ffffff;\n  --ac-wash: #f7f7f6;\n  --ac-accent: #32119c;\n  --ac-accent-soft: rgba(79, 39, 224, 0.1);\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  min-width: 0;\n  color: var(--ac-ink);\n  font-size: 0.9375rem;\n  line-height: 1.5;\n}\n\n/* A quiet accent per recognised intent; text stays AA on light surfaces. */\n.ac-root[data-intent=\"math\"] {\n  --ac-accent: #0f5d51;\n  --ac-accent-soft: rgba(15, 107, 92, 0.12);\n}\n\n.ac-root[data-intent=\"place\"] {\n  --ac-accent: #7a4a12;\n  --ac-accent-soft: rgba(146, 91, 21, 0.12);\n}\n\n.ac-root[data-intent=\"person\"] {\n  --ac-accent: #7d2c4e;\n  --ac-accent-soft: rgba(157, 58, 99, 0.12);\n}\n\n.ac-root[data-intent=\"link\"] {\n  --ac-accent: #1f4f8f;\n  --ac-accent-soft: rgba(31, 79, 143, 0.12);\n}\n\n.ac-root[data-intent=\"task\"] {\n  --ac-accent: #3f3f46;\n  --ac-accent-soft: rgba(63, 63, 70, 0.1);\n}\n\n/* ---- field ---- */\n\n.ac-field {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  min-width: 0;\n  padding: 12px 14px 10px;\n  background: var(--ac-surface);\n  border: 1px solid var(--ac-line);\n  border-radius: 14px;\n  transition:\n    border-color 140ms ease,\n    box-shadow 140ms ease;\n}\n\n.ac-field:focus-within {\n  border-color: var(--ac-accent);\n  box-shadow: 0 0 0 3px var(--ac-accent-soft);\n}\n\n.ac-field__head {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  min-height: 20px;\n}\n\n.ac-label {\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: 0.625rem;\n  letter-spacing: 0.12em;\n  text-transform: uppercase;\n  color: var(--ac-faint);\n}\n\n.ac-chip {\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: 0.625rem;\n  letter-spacing: 0.1em;\n  text-transform: uppercase;\n  color: var(--ac-accent);\n  background: var(--ac-accent-soft);\n  border-radius: 999px;\n  padding: 3px 10px;\n  white-space: nowrap;\n  transition:\n    color 140ms ease,\n    background-color 140ms ease;\n}\n\n.ac-input {\n  display: block;\n  width: 100%;\n  min-width: 0;\n  min-height: calc(1.5em + 4px);\n  padding: 2px 0;\n  border: 0;\n  outline: none;\n  background: transparent;\n  color: inherit;\n  font: inherit;\n  caret-color: var(--ac-accent);\n  resize: none;\n  overflow-y: auto;\n  transition: height 120ms ease;\n}\n\n.ac-input::placeholder {\n  color: var(--ac-faint);\n}\n\n.ac-input:disabled {\n  color: var(--ac-faint);\n  cursor: not-allowed;\n}\n\n.ac-field__foot {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  min-height: 18px;\n}\n\n.ac-keys {\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: 0.625rem;\n  letter-spacing: 0.06em;\n  color: var(--ac-faint);\n}\n\n.ac-clear {\n  margin: -2px -6px -2px 0;\n  padding: 2px 6px;\n  border: 0;\n  border-radius: 6px;\n  background: none;\n  color: var(--ac-muted);\n  font: inherit;\n  font-size: 0.6875rem;\n  cursor: pointer;\n}\n\n.ac-clear:hover {\n  color: var(--ac-ink);\n  background: var(--ac-line-soft);\n}\n\n.ac-clear:focus-visible {\n  outline: 2px solid var(--ac-accent);\n  outline-offset: 1px;\n}\n\n/* ---- structure panel ---- */\n\n.ac-structure {\n  min-height: 34px;\n}\n\n.ac-card {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  padding: 12px 14px;\n  background: var(--ac-wash);\n  border: 1px solid var(--ac-line-soft);\n  border-radius: 12px;\n}\n\n.ac-summary {\n  margin: 0;\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: 0.6875rem;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: var(--ac-accent);\n}\n\n.ac-grid {\n  display: grid;\n  gap: 6px;\n  margin: 0;\n}\n\n.ac-row {\n  display: grid;\n  grid-template-columns: 5.5rem minmax(0, 1fr);\n  gap: 10px;\n  align-items: baseline;\n}\n\n.ac-row dt {\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: 0.625rem;\n  letter-spacing: 0.1em;\n  text-transform: uppercase;\n  color: var(--ac-faint);\n}\n\n.ac-row dd {\n  margin: 0;\n  font-size: 0.875rem;\n  color: var(--ac-ink);\n  overflow-wrap: anywhere;\n}\n\n.ac-tasks {\n  display: grid;\n  gap: 6px;\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n.ac-tasks li {\n  display: flex;\n  align-items: baseline;\n  gap: 8px;\n  font-size: 0.875rem;\n}\n\n.ac-box {\n  flex: none;\n  width: 13px;\n  height: 13px;\n  margin-top: 2px;\n  border: 1.5px solid var(--ac-line);\n  border-radius: 4px;\n}\n\n.ac-math {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: baseline;\n  gap: 8px;\n}\n\n.ac-expr {\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: 0.8125rem;\n  color: var(--ac-muted);\n}\n\n.ac-eq {\n  color: var(--ac-faint);\n}\n\n.ac-value {\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: 1.0625rem;\n  letter-spacing: -0.01em;\n  color: var(--ac-accent);\n}\n\n.ac-people {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 6px;\n}\n\n.ac-people .ac-quiet {\n  flex-basis: 100%;\n}\n\n.ac-person {\n  padding: 2px 10px;\n  background: var(--ac-surface);\n  border: 1px solid var(--ac-line);\n  border-radius: 999px;\n  font-size: 0.8125rem;\n}\n\n.ac-url {\n  margin: 0;\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: 0.8125rem;\n  color: var(--ac-accent);\n  overflow-wrap: anywhere;\n}\n\n.ac-quiet {\n  margin: 0;\n  font-size: 0.8125rem;\n  color: var(--ac-faint);\n}\n\n/* ---- disabled ---- */\n\n.ac-root[data-disabled=\"true\"] .ac-field {\n  background: #fbfbfa;\n}\n\n.ac-root[data-disabled=\"true\"] .ac-structure,\n.ac-root[data-disabled=\"true\"] .ac-field__foot {\n  opacity: 0.55;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ac-field,\n  .ac-chip,\n  .ac-input {\n    transition: none;\n  }\n}\n"}]}