Copy for LLM
Messages
A messaging template. A scrolling message thread with ChatBubble, plus ComposeFlow quick replies for the no-keyboard text answer, on one glanceable screen.
A chat screen: a scrolling thread of ChatBubble messages, with ComposeFlow for the reply. Since the lens has no keyboard, activating the field opens a back-gesture-aware picker of quick replies; pick one and it composes back into the thread.
Messages
An inbox: thread list to conversation, replying through ComposeFlow's picker, the realistic text path on a keyboard-less platform.
- Navigator push with params (the thread you opened)
- ComposeFlow reply: back gesture closes the picker, not the app
- ChatBubble thread + an accent Toast confirming the send
- List rows with Avatar leading slots
Installation
npx @glasskit-ui/cli add messagesInstall the SDK, then copy these files into your project:
npm install @glasskit-ui/react// components/lib/utils.tsimport { clsx, type ClassValue } from "clsx";import { twMerge } from "tailwind-merge";export type { ClassValue };/** * Merge class names the shadcn way: clsx joins conditionals, tailwind-merge * de-dupes conflicting Tailwind utilities so a consumer's `className` override * wins (e.g. passing `px-2` beats the component's `px-6`). Lens components are * Tailwind utilities + `--gk-*` tokens, so this de-dupe matters. */export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs));}/** * Accessible name from a free-form `label` prop: the label itself when it's a * plain string, otherwise undefined (a ReactNode can't become an aria-label). */export function stringLabel(label: unknown): string | undefined { return typeof label === "string" ? label : undefined;}// components/lib/glass-history.ts/** * A tiny swappable history abstraction for <Navigator> — the React * Router / TanStack pattern. Navigator drives navigation through this seam * instead of touching globals directly, so production runs on the real * `window.history` (the Display's middle-pinch BACK gesture arrives as a real * `popstate` on `window`, OS v125.1+) while tests inject an in-memory stack * and stop racing on jsdom's shared `window.history`. * * `createBrowserHistory()` is the production default and behaves exactly like * the prior direct calls: same `window.history` methods, same real `window` * `popstate` listener. `createMemoryHistory()` mirrors those semantics over an * internal entry stack — a state object and URL carried per entry — and * notifies subscribers (via a microtask, matching how the tests `await`) when * `back()`/`go()` traverse, so Navigator behaves identically off-window. *//** A single history entry: the structured-clone-able state and its URL. */export type GlassHistoryEntry = { state: unknown; url: string;};/** * The slice of the history API <Navigator> needs. Mirrors `window.history` * (plus `location.pathname` via `pathname`) so the browser adapter is a thin * pass-through and the memory adapter is a faithful stand-in. */export type GlassHistory = { /** Current `history.state`. */ readonly state: unknown; /** Current `location.pathname`. */ readonly pathname: string; /** Push a new entry (adds a traversable entry — back returns here). */ pushState(state: unknown, url?: string | null): void; /** Replace the current entry in place (no new entry). */ replaceState(state: unknown, url?: string | null): void; /** Traverse back one entry (fires the popstate notification). */ back(): void; /** Traverse by `delta` entries (negative = back). */ go(delta: number): void; /** * Subscribe to popstate-style traversals; the callback receives a * PopStateEvent-shaped object carrying the entry's `state`. Returns an * unsubscribe. */ subscribe(onPopstate: (e: { state: unknown }) => void): () => void;};/** * Production default: a thin wrapper over the real `window.history` / * `window` `popstate` / `location`. Every method is a direct pass-through, so * on-device semantics are byte-for-byte identical to calling the globals. */export function createBrowserHistory(): GlassHistory { return { get state() { return window.history.state; }, get pathname() { return window.location.pathname; }, pushState(state, url) { window.history.pushState(state, "", url ?? undefined); }, replaceState(state, url) { window.history.replaceState(state, "", url ?? undefined); }, back() { window.history.back(); }, go(delta) { window.history.go(delta); }, subscribe(onPopstate) { const handler = (e: PopStateEvent) => onPopstate(e); window.addEventListener("popstate", handler); return () => window.removeEventListener("popstate", handler); }, };}/** * In-memory history for deterministic tests: an entry stack with its own * index, never touching `window`. `back()`/`go()` move the index and notify * subscribers on a microtask with the now-current entry's state — mirroring * how jsdom's real traversal resolves across queued tasks (the tests `await` a * settle before asserting). State objects are carried per entry exactly as the * browser does. */export function createMemoryHistory( initial?: { state?: unknown; url?: string },): GlassHistory { const entries: GlassHistoryEntry[] = [ { state: initial?.state ?? null, url: initial?.url ?? "/" }, ]; let index = 0; const listeners = new Set<(e: { state: unknown }) => void>(); const notify = () => { const state = entries[index]!.state; // Microtask, matching jsdom's async traversal (the tests await a settle). queueMicrotask(() => { for (const l of [...listeners]) l({ state }); }); }; return { get state() { return entries[index]!.state; }, get pathname() { return entries[index]!.url; }, pushState(state, url) { // Drop any forward entries, exactly like a real navigation. entries.splice(index + 1); entries.push({ state, url: url ?? entries[index]!.url }); index = entries.length - 1; }, replaceState(state, url) { entries[index] = { state, url: url ?? entries[index]!.url }; }, back() { this.go(-1); }, go(delta) { const next = index + delta; if (next < 0 || next >= entries.length || delta === 0) return; index = next; notify(); }, subscribe(onPopstate) { listeners.add(onPopstate); return () => { listeners.delete(onPopstate); }; }, };}// components/glasskit/navigator.tsx"use client";import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode,} from "react";import { getFocusables, seedFocus } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { createBrowserHistory, type GlassHistory } from "../lib/glass-history";/** * <Navigator> — a screen stack for glasses apps (react-navigation semantics, * glasses-native mechanics). The Display's system back gesture (middle pinch) * pops browser history and the page receives `popstate` (glasses OS v125.1+), * so every push adds a real history entry and back — gesture, Escape in * desktop dev, or `pop()` — flows through one path: history → popstate → * stack. At the root the gesture falls through to the system (app * switcher/menu), exactly like Android's back contract. * * One screen renders at a time (one task per view); pushing unmounts nothing * until the entry is popped, but only the top screen is in the DOM so the * D-pad focus engine never sees covered screens. Focus seeds to the new * screen's first focusable (or its `data-autofocus` target) on push; popping * back restores the ring to the element that opened the screen — the row you * came from, not the top of the list (focus memory). * * Overlays intercept back with `useBackHandler` (last registered wins first; * return true to consume — e.g. close a sheet instead of leaving the screen). * * The stack itself rides in `history.state` (when params are * structured-cloneable), so a mid-flow reload restores the screen the wearer * was on instead of kicking them to the root. Optional `paths` mirrors the * stack into the URL (`/detail` on push, restored on back) — give the host a * catch-all route and pushed screens become deep-linkable. */type NavEntry = { name: string; params?: unknown; key: number };type NavAPI = { /** Current stack, root first. */ stack: readonly NavEntry[]; /** Push a screen (adds a history entry — system back returns here). */ push: (name: string, params?: unknown) => void; /** Pop one screen (no-op at the root). */ pop: () => void; /** Pop everything back to the root screen. */ popToTop: () => void; /** Swap the top screen without growing the stack. */ replace: (name: string, params?: unknown) => void;};const NavContext = createContext<NavAPI | null>(null);const BackChainContext = createContext<{ current: Array<() => boolean>;} | null>(null);/** Navigation API of the nearest <Navigator>. */export function useNavigator(): NavAPI { const api = useContext(NavContext); if (!api) throw new Error("useNavigator must be used inside <Navigator>"); return api;}/** * Intercept the back gesture while mounted (overlays, confirm sheets, * mid-flow guards). `handler` returns true to consume the back, false to let * it pop the screen. Handlers run newest-first. */export function useBackHandler(handler: () => boolean): void { const chain = useContext(BackChainContext); if (!chain) throw new Error("useBackHandler must be used inside <Navigator>"); const ref = useRef(handler); ref.current = handler; useEffect(() => { const entry = () => ref.current(); chain.current.push(entry); return () => { chain.current.splice(chain.current.indexOf(entry), 1); }; }, [chain]);}export function Navigator({ screens, initial, initialParams, paths, className, history: historyProp,}: { /** Screen renderers by name; receive the push params. */ screens: Record<string, (params?: unknown) => ReactNode>; /** Root screen name. */ initial: string; initialParams?: unknown; /** Screen name → URL segment. Mirrors the stack into the pathname * (appended to the pathname where the Navigator mounted). */ paths?: Record<string, string>; className?: string; /** * Advanced/testing seam: a swappable history adapter (the React * Router / TanStack pattern). Defaults to {@link createBrowserHistory}, * which drives the real `window.history` and `window` `popstate` exactly * as before — leave it unset in production. Inject `createMemoryHistory()` * in tests for a deterministic in-memory stack that never touches `window`. */ history?: GlassHistory;}) { // One stable adapter for the component's lifetime; the browser default is // created lazily so production behaviour is identical to direct globals. const historyRef = useRef<GlassHistory | undefined>(historyProp); if (!historyRef.current) historyRef.current = createBrowserHistory(); const history = historyRef.current; const [stack, setStack] = useState<NavEntry[]>(() => [ { name: initial, params: initialParams, key: 0 }, ]); const keyRef = useRef(0); const backChain = useRef<Array<() => boolean>>([]); // Focus memory: entry key → index (in D-pad order) of the element that was // focused when that screen pushed. Restored when the screen resurfaces. const focusMemory = useRef(new Map<number, number>()); // Mirror of the stack for event handlers (popstate/keydown read the latest // without re-subscribing per render). const stackRef = useRef(stack); stackRef.current = stack; // Pathname where the Navigator mounted — `paths` segments append to it. const basePath = useRef<string | null>(null); const pathsRef = useRef(paths); pathsRef.current = paths; const urlFor = (name: string): string | undefined => { const seg = pathsRef.current?.[name]; if (seg == null || basePath.current == null) return undefined; return seg === "" ? basePath.current : `${basePath.current}/${seg}`; }; /** history.state payload for a stack — drops params that can't be cloned * (a reload then restores the screen without them). */ const stateFor = (s: NavEntry[]) => { const entries = s.map((e) => ({ name: e.name, params: e.params })); try { structuredClone(entries); return { gkNavDepth: s.length - 1, gkStack: entries }; } catch { return { gkNavDepth: s.length - 1, gkStack: entries.map((e) => ({ name: e.name })), }; } }; /** Ask overlays first; true = back was consumed. */ const runBackChain = () => { for (let i = backChain.current.length - 1; i >= 0; i--) { if (backChain.current[i]!()) return true; } return false; }; useEffect(() => { basePath.current = history.pathname.replace(/\/$/, ""); const state = history.state as | { gkStack?: Array<{ name: string; params?: unknown }>; gkNavDepth?: number } | null | undefined; const saved = state?.gkStack; if (state?.gkNavDepth == null) { // Fresh entry — mark the root so popstate can tell our entries from // the host page's. history.replaceState({ ...(state ?? {}), ...stateFor(stackRef.current), }); } else if (saved && saved.length > 1 && stackRef.current.length === 1) { // Reload mid-flow: this entry carries a deeper stack — restore it so // the wearer lands back on the screen they were on. basePath must not // include the restored top's segment, so strip it when paths are on. const topSeg = pathsRef.current?.[saved[saved.length - 1]!.name]; if (topSeg && basePath.current.endsWith(`/${topSeg}`)) { basePath.current = basePath.current.slice(0, -(topSeg.length + 1)); } setStack( saved.map((e) => ({ name: e.name, params: e.params, key: ++keyRef.current, })), ); } const onPopstate = (e: { state: unknown }) => { const depth: number = (e.state as { gkNavDepth?: number } | null | undefined)?.gkNavDepth ?? 0; if (depth >= stackRef.current.length - 1) return; // not ours / no-op if (runBackChain()) { // An overlay consumed the back — restore the history entry the // system just popped so depth and stack stay in sync. history.pushState( stateFor([...stackRef.current]), urlFor(stackRef.current[stackRef.current.length - 1]!.name), ); return; } setStack((s) => s.slice(0, depth + 1)); }; // Desktop dev parity: the official simulator mapping is BACK = Escape; // on-device the gesture never reaches keydown (v125.1 sends popstate). // Routing Escape through the adapter's back() converges both worlds. const onKeydown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; if (runBackChain()) return; if (stackRef.current.length > 1) history.back(); }; const unsubscribe = history.subscribe(onPopstate); window.addEventListener("keydown", onKeydown); return () => { unsubscribe(); window.removeEventListener("keydown", onKeydown); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const push = useCallback((name: string, params?: unknown) => { setStack((s) => { // Remember where the ring was on the outgoing screen (idempotent — // safe under StrictMode's double-invoke, like the pushState below). const idx = getFocusables().indexOf( document.activeElement as HTMLElement, ); if (idx !== -1) focusMemory.current.set(s[s.length - 1]!.key, idx); const next = [...s, { name, params, key: ++keyRef.current }]; history.pushState(stateFor(next), urlFor(name)); return next; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const pop = useCallback(() => { if (stackRef.current.length > 1) history.back(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const popToTop = useCallback(() => { const depth = stackRef.current.length - 1; if (depth > 0) history.go(-depth); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const replace = useCallback((name: string, params?: unknown) => { setStack((s) => { const next = [...s.slice(0, -1), { name, params, key: ++keyRef.current }]; history.replaceState(stateFor(next), urlFor(name)); return next; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const api = useMemo<NavAPI>( () => ({ stack, push, pop, popToTop, replace }), [stack, push, pop, popToTop, replace], ); // Stack top changed → move the D-pad ring. Returning to a remembered // screen restores the element that pushed (clamped if the list shrank); // a fresh screen seeds normally. const top = stack[stack.length - 1]!; useEffect(() => { const remembered = focusMemory.current.get(top.key); if (remembered != null) { focusMemory.current.delete(top.key); const els = getFocusables(); if (els.length > 0) { els[Math.min(remembered, els.length - 1)]!.focus(); return; } } seedFocus(); }, [top.key]); return ( <NavContext.Provider value={api}> <BackChainContext.Provider value={backChain}> <div key={top.key} className={cn("gk-nav", className)}> {screens[top.name]?.(top.params) ?? null} </div> </BackChainContext.Provider> </NavContext.Provider> );}// components/glasskit/screen.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** * <Screen> — the on-lens layout shell: a status region (block-start), a * centered stage (the one task), and the cue region (block-end). Renders * *inside* a <GlassViewport>; it does not own the 600×600 surface. * * The `cue` is the screen's one narration line: pass the hint text and Screen * renders it as a polite `role="status"` live region (announces updates to * screen readers + the glasses TTS without stealing focus). Set `cueLive` for a * live/active state (accent). One cue per screen keeps the live region sane. * * Keeps ≥50–60% of the surface pure black (apple-feel §3) by reserving the * center for a single readout/action and pinning chrome to the edges. */export function Screen({ status, cue, cueLive = false, children, className,}: { /** Optional top region (e.g. a Heading). System status is OS chrome, not an * app component, so most app screens leave this empty. */ status?: ReactNode; /** The bottom narration line: the hint or transient status for this view. * Rendered as a polite `role="status"` live region. */ cue?: ReactNode; /** Accent the cue for a live/active state ("Recording", "Connected"). */ cueLive?: boolean; /** The stage: the one task for this view. */ children: ReactNode; className?: string;}) { return ( <div className={cn("flex h-full flex-col gap-[14px] p-[22px]", className)}> {status ? ( <div data-screen-status className="flex-none"> {status} </div> ) : null} <div data-screen-stage className="flex min-h-0 flex-1 flex-col items-center justify-center gap-5 text-center" > {children} </div> {cue != null ? ( <div data-screen-cue role="status" className={cn( "t-caption flex flex-none items-center justify-center gap-2", cueLive ? "text-primary" : "text-foreground-faint", )} > {cue} </div> ) : null} </div> );}// components/glasskit/heading.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** * <Heading> — a screen/section title with an optional eyebrow label above it. * Pure display. Use sparingly — one heading per view keeps the glance cheap. */export function Heading({ children, eyebrow, className,}: { children: ReactNode; /** Small tracked label above the title. */ eyebrow?: ReactNode; className?: string;}) { return ( <div className={cn("flex flex-col items-center gap-1 text-center", className)}> {eyebrow != null ? ( <span className="t-caption uppercase tracking-[0.16em] text-primary"> {eyebrow} </span> ) : null} <h2 className="t-title">{children}</h2> </div> );}// components/glasskit/list.tsx"use client";import { useEffect, useRef, type ReactNode } from "react";import { cn } from "../lib/utils";/** * <List> — a vertical stack of focusable rows (watchOS list spirit) that fills * the stage and scrolls (D-pad scrollIntoView). A position indicator on the * inline-end edge tracks scroll — sized to the content and hidden when the list * fits. Compose with <ListRow>. */export function List({ children, className,}: { children: ReactNode; className?: string;}) { const scrollRef = useRef<HTMLDivElement>(null); const railRef = useRef<HTMLSpanElement>(null); const thumbRef = useRef<HTMLSpanElement>(null); useEffect(() => { const sc = scrollRef.current; const rail = railRef.current; const thumb = thumbRef.current; if (!sc || !rail || !thumb) return; const update = () => { const { scrollTop, scrollHeight, clientHeight } = sc; const overflow = scrollHeight - clientHeight; if (overflow <= 1) { rail.dataset.show = "false"; return; } rail.dataset.show = "true"; const trackH = rail.clientHeight; const thumbH = Math.max(trackH * (clientHeight / scrollHeight), 26); const pos = (scrollTop / overflow) * (trackH - thumbH); thumb.style.height = `${thumbH}px`; thumb.style.transform = `translateY(${pos}px)`; }; // Smooth scroll on focus: the D-pad engine moves the ring with // preventScroll, so the List owns the scroll. `block: "nearest"` keeps the // list still while the focused row is fully on screen and only glides once // the ring passes the visible page (then it slides just enough to reveal // the next row) — smooth, not the instant per-row jump native focus does. // Honors reduced-motion. const reduce = typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches; const onFocusIn = (e: FocusEvent) => { const row = (e.target as HTMLElement | null)?.closest("[data-list-row]"); if (row && sc.contains(row) && typeof row.scrollIntoView === "function") { row.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth", }); } }; update(); sc.addEventListener("scroll", update, { passive: true }); sc.addEventListener("focusin", onFocusIn); const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(update) : null; ro?.observe(sc); return () => { sc.removeEventListener("scroll", update); sc.removeEventListener("focusin", onFocusIn); ro?.disconnect(); }; }, []); return ( <div className={cn("relative size-full self-stretch", className)}> <div className="gk-list__scroll" ref={scrollRef}> {children} </div> <span className="gk-list__rail" ref={railRef} data-show="false" aria-hidden="true" > <span className="gk-list__bar" ref={thumbRef} /> </span> </div> );}/** * <ListRow> — a D-pad-focusable row: leading glyph, label, trailing value. * Renders a real <button> with the `focusable` class so `useDpad()` walks * it. Logical layout (leading = inline-start) for RTL safety. */export function ListRow({ children, leading, trailing, onClick, disabled, className,}: { /** The row label. */ children: ReactNode; /** Optional inline-start glyph — typically a <Icon>. */ leading?: ReactNode; /** Optional inline-end value/affordance. */ trailing?: ReactNode; onClick?: () => void; disabled?: boolean; className?: string;}) { return ( <button type="button" disabled={disabled} onClick={onClick} data-list-row="" className={cn( "focusable press-scale t-body surface flex w-full flex-none items-center gap-[13px] rounded-lens px-5 py-4 text-start min-h-[74px]", className, )} > {leading} <span className="min-w-0 flex-1">{children}</span> {trailing != null ? ( <span className="t-caption text-foreground-faint [font-variant-numeric:tabular-nums]"> {trailing} </span> ) : null} </button> );}// components/glasskit/avatar.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";const SIZE = { sm: "size-[46px] text-[17px]", md: "size-[62px] text-[23px]", lg: "size-[92px] text-[34px]",} as const;export type AvatarTone = | "blue" | "green" | "peach" | "violet" | "cyan" | "amber";/** * <Avatar> — a contact / sender avatar: shows a photo when you have one, else an * icon, else initials on a gradient plate. Used by NotificationCard, ChatBubble, * CallCard. RTL-safe. */export function Avatar({ name, src, icon, tone = "blue", size = "md", className,}: { /** Display name — used for initials + the a11y label. */ name: string; /** Optional image URL. */ src?: string; /** Optional icon shown instead of initials (e.g. a group / bot glyph). */ icon?: ReactNode; /** Gradient tone when showing initials. */ tone?: AvatarTone; size?: "sm" | "md" | "lg"; className?: string;}) { const initials = name .split(/\s+/) .map((w) => w[0]) .filter(Boolean) .slice(0, 2) .join("") .toUpperCase(); return ( <span className={cn( "inline-grid shrink-0 place-items-center overflow-hidden rounded-full font-bold text-white [box-shadow:inset_0_1px_0_rgba(255,255,255,0.3),0_6px_14px_-8px_rgba(0,0,0,0.6)]", SIZE[size], !src && `gk-grad-${tone}`, className, )} role="img" aria-label={name} > {src ? ( // eslint-disable-next-line @next/next/no-img-element <img className="size-full object-cover" src={src} alt="" /> ) : icon ? ( <span className="grid size-full place-items-center [&_svg]:size-[45%] [&>*]:size-[45%]"> {icon} </span> ) : ( <span>{initials}</span> )} </span> );}// components/glasskit/chat-bubble.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** * <MessageThread> — a vertical stack of <ChatBubble>s (a conversation view). * Newest at the bottom; scrolls like <List>. RTL-safe (logical alignment). */export function MessageThread({ children, className,}: { children: ReactNode; className?: string;}) { return ( <div className={cn( "flex h-full w-full flex-col justify-end gap-2.5 overflow-y-auto", className, )} > {children} </div> );}/** * <ChatBubble> — one message. `from="me"` is the accent-gradient bubble aligned * to the inline-end; `from="them"` is a surface bubble at the inline-start. */export function ChatBubble({ from = "them", children, className,}: { from?: "me" | "them"; children: ReactNode; className?: string;}) { return ( <div className={cn( "t-body max-w-[82%] rounded-[22px] px-[18px] py-3.5", from === "me" ? "self-end rounded-ee-[7px] border border-white/20 text-white [background:var(--accent-surface)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.35),0_10px_22px_-12px_var(--accent-glow)]" : "surface self-start rounded-es-[7px]", className, )} > {children} </div> );}// components/glasskit/quick-reply-chips.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** * <QuickReplyChips> — tappable canned replies (the comms job; there is no * keyboard on the lens — text is voice). Each chip is D-pad-focusable. Keep the * set short and the labels glanceable. */export function QuickReplyChips({ options, onSelect, className,}: { options: string[]; onSelect?: (reply: string) => void; className?: string;}) { return ( <div className={cn("flex flex-wrap justify-center gap-2", className)}> {options.map((o, i) => ( <button key={`${i}-${o}`} type="button" onClick={onSelect ? () => onSelect(o) : undefined} className="focusable press-scale t-body surface rounded-full py-3.5 px-[22px]" > {o} </button> ))} </div> );}// components/glasskit/compose-flow.tsx"use client";import { useEffect, useRef, useState, type ReactNode } from "react";import { FocusScope } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { Heading } from "./heading";import { QuickReplyChips } from "./quick-reply-chips";/** * <ComposeFlow> — the working text-entry recipe for a platform with no * keyboard or microphone: a focusable field that opens a picker of choices * when activated; choosing writes the value back and returns to the field. The * picker is a real back-gesture surface — opening pushes a history entry, so * a middle pinch (or Escape in desktop dev) closes it instead of leaving the * screen, inside or outside a <Navigator>. * * This is the seam system dictation would replace: if Meta ships a text-input * API (see the ComposeFlow docs), swap the picker for the system flow and the * field API doesn't change. */export function ComposeFlow({ label, value, placeholder = "Pinch to enter text", options, pickerTitle = "Choose", icon, onChange, className,}: { /** Field label. */ label?: ReactNode; /** Current value. Controlled — pair with `onChange`. */ value?: string | null; placeholder?: ReactNode; /** The choices the picker offers. */ options: string[]; /** Heading on the picker view. */ pickerTitle?: ReactNode; /** Field trailing glyph. */ icon?: ReactNode; onChange?: (value: string) => void; className?: string;}) { const [open, setOpen] = useState(false); const field = useRef<HTMLButtonElement>(null); const wasOpen = useRef(false); const openPicker = () => { // Ride history so the system back gesture closes the picker. Carrying a // bumped gkNavDepth makes an enclosing <Navigator> treat the entry as // "not mine" and leave its stack alone. history.pushState( { ...history.state, gkCompose: true, gkNavDepth: (history.state?.gkNavDepth ?? 0) + 1, }, "", ); setOpen(true); }; useEffect(() => { if (!open) return; const onPop = () => setOpen(false); const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // The picker owns this back — a Navigator's own Escape handler would // otherwise also call history.back() and pop two entries. Capture // phase runs first; stop the event there. e.stopImmediatePropagation(); history.back(); }; window.addEventListener("popstate", onPop); window.addEventListener("keydown", onKey, true); return () => { window.removeEventListener("popstate", onPop); window.removeEventListener("keydown", onKey, true); }; }, [open]); // Closing re-renders the field view with fresh DOM — put the ring back on // the field so the wearer continues where they left off. useEffect(() => { if (wasOpen.current && !open) { field.current?.focus(); } wasOpen.current = open; }, [open]); const choose = (v: string) => { onChange?.(v); history.back(); // popstate closes the picker — history stays balanced }; const filled = value != null && value !== ""; return ( <div className={cn("contents", className)}> {open ? ( <FocusScope restoreFocus={false}> <Heading>{pickerTitle}</Heading> <QuickReplyChips options={options} onSelect={choose} /> </FocusScope> ) : ( <button ref={field} type="button" onClick={openPicker} className="focusable gk-composefield surface flex w-full items-center gap-[14px] rounded-lens px-5 py-4 text-start" > <span className="flex min-w-0 flex-1 flex-col gap-[3px] text-start"> {label != null ? ( <span className="t-caption uppercase tracking-[0.1em] text-foreground-faint"> {label} </span> ) : null} <span className={cn( "t-body", filled ? "text-foreground" : "text-foreground-faint", )} > {filled ? value : placeholder} </span> </span> {icon != null ? ( <span className="[&_.gk-icon]:size-[26px] [&_.gk-icon]:text-accent-active"> {icon} </span> ) : null} </button> )} </div> );}// components/glasskit/messages.tsx"use client";import { useState } from "react";import { Navigator, useNavigator } from "./navigator";import { Screen } from "./screen";import { Heading } from "./heading";import { List, ListRow } from "./list";import { Avatar } from "./avatar";import { MessageThread, ChatBubble } from "./chat-bubble";import { ComposeFlow } from "./compose-flow";/** * <Messages> — an inbox: a thread list and a conversation, replying through * ComposeFlow on a keyboard-less platform. */export function MessagesApp() { return ( <Navigator screens={{ home: () => <Threads />, thread: (p) => <Thread who={(p as { who?: string })?.who ?? "Maya"} />, }} initial="home" /> );}const PREVIEWS: Record<string, string> = { Maya: "Still on for 7?", Dispatch: "Route updated — Market St is closed", "Group ride": "Sam: rolling out at 6:15",};function Threads() { const nav = useNavigator(); return ( <Screen cue="Pinch opens a conversation"> <Heading eyebrow="Messages">Inbox</Heading> <List> {Object.entries(PREVIEWS).map(([who, preview]) => ( <ListRow key={who} leading={<Avatar name={who} size="sm" tone="violet" />} trailing={who === "Maya" ? "now" : undefined} onClick={() => nav.push("thread", { who })} > {who} · {preview} </ListRow> ))} </List> </Screen> );}function Thread({ who }: { who: string }) { const [sent, setSent] = useState<string | null>(null); const [confirmed, setConfirmed] = useState(false); return ( <Screen status={<Heading eyebrow={who}>Thread</Heading>} cue={confirmed ? `Sent to ${who}` : "Pinch to reply"} cueLive={confirmed} > <MessageThread> <ChatBubble from="them">{PREVIEWS[who]}</ChatBubble> <ChatBubble from="them">I got us a table by the window.</ChatBubble> {sent ? <ChatBubble from="me">{sent}</ChatBubble> : null} </MessageThread> <ComposeFlow label="Reply" value={sent} placeholder="Pinch to reply" options={["On my way", "5 min", "Call me", "Can't talk now"]} pickerTitle="Quick replies" onChange={(v) => { setSent(v); setConfirmed(true); setTimeout(() => setConfirmed(false), 2200); }} /> </Screen> );}Usage
import { MessagesApp } from "@/components/glasskit/messages";export default function Page() { return <MessagesApp />;}Workout
A multi-screen workout template. A Navigator stack with a self-ticking Timer and a destructive Confirm to end the session, showing focus memory and the back gesture in a real flow.
Build with AI agents
GlassKit ships its own agent skill. Every scaffolded app comes with its agent pre-briefed on the platform contract (Claude Code, Cursor, Copilot, anything that reads AGENTS.md), plus llms.txt and an MCP server for the registry.