ComposeFlow
The working text-entry recipe: a focusable field that opens a picker of choices when activated; choosing writes back and returns. The picker rides history, so the back gesture closes it. It stands in for the on-device dictation the platform does not expose yet.
Installation
npx @glasskit-ui/cli add compose-flowInstall the SDK (it provides GlassViewport, useDpad and the stylesheet), 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/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/quick-reply-chips.tsximport { 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> );}Usage
const [reply, setReply] = useState<string | null>(null);<ComposeFlow label="Reply" value={reply} onChange={setReply} options={["On my way", "5 min", "Call me"]} pickerTitle="Quick replies"/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Field label. |
value | string | null | — | Current value. Controlled, so pair with onChange. |
placeholder | ReactNode | "Pinch to enter text" | Empty-field hint. |
options | string[] | — | The choices the picker offers. |
pickerTitle | ReactNode | "Choose" | Heading on the picker view. |
icon | ReactNode | — | Field trailing glyph. |
onChange | (value: string) => void | — | Fires with the chosen option. |
className | string | — | Extra classes. |
When to use
ComposeFlow is the working answer for text on the lens: a picker for an enumerable set of answers, opened from a field activation. There is no keyboard or microphone API, so for anything outside an enumerable set there is no on-device text path today.
PermissionPrompt
An explicit access request (sensors, location, camera, mic) that MRBD apps must ask for before use. A gradient-plate icon, a clear title, the reason, and allow / deny actions.
Compass
A heading rose: North stays world-aligned while a fixed top marker shows where you face. World-anchored, never mirrored under RTL.