GlassKit UI
Templates
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.

Messages

Inbox

Pinch opens a conversation
  • 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 messages

Install 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 />;}