Copy for LLM
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.
A multi-screen flow: a list of activities pushes a detail screen, a self-ticking Timer runs the set, and ending the session routes through a destructive Confirm. Watch the back gesture pop a screen and the focus ring return to the row that opened it.
Workout
A fitness companion: list → live session → rest timer, with a destructive end-confirm that seeds the ring on the safe action.
- Navigator hierarchy with focus memory (pop lands on the row you came from)
- Screen status slot (live GPS dot) and the one-task-per-view rhythm
- Timer with pause/resume; EmptyState for unvisited history
- Confirm destructive: a blind pinch can't end the run
Installation
npx @glasskit-ui/cli add workoutInstall 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/stat-grid.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";export type Stat = { label: ReactNode; value: ReactNode; unit?: ReactNode;};/** * <StatGrid> — a compact grid of readouts for a multi-metric glance (a watch * "complication cluster"). Pure display, tabular numerals. Keep it to 2–4 cells * — density past that stops being glanceable. */export function StatGrid({ items, className,}: { items: Stat[]; className?: string;}) { return ( <div className={cn("grid w-full grid-cols-2 gap-3", className)}> {items.map((it, i) => ( <div key={i} className="surface flex flex-col items-start gap-1.5 rounded-[20px] p-[18px] text-start" > <span className="t-caption uppercase tracking-[0.12em] text-foreground-faint"> {it.label} </span> <span className="t-readout font-bold"> {it.value} {it.unit != null ? ( <span className="text-[16px] text-foreground-faint"> {it.unit}</span> ) : null} </span> </div> ))} </div> );}// components/glasskit/status-dot.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** * <StatusDot> — a glanceable sensor / permission / connection indicator. The * lens has one accent, so state reads from luminance + motion, not a second * hue: `on` = steady accent, `live` = pulsing accent, `off` = dim. */export function StatusDot({ status = "on", label, className,}: { /** Semantic state — live pulses, off dims. */ status?: "on" | "live" | "off"; label?: ReactNode; className?: string;}) { return ( <span className={cn( "inline-flex items-center gap-2", `gk-statusdot--${status}`, className, )} > <span className="gk-statusdot__dot" /> {label != null ? ( <span className="t-caption text-muted-foreground">{label}</span> ) : null} </span> );}// components/glasskit/progress.tsximport type { ReactNode } from "react";import { cn, stringLabel } from "../lib/utils";/** * <Progress> — emitted progress, two shapes: * - "linear" a continuous bar (also covers countdown: feed a decreasing * value and a time label). Uses a native <progress>, so the * dynamic fill needs no inline style. * - "step" discrete step-of-N dots (wizard / pinch-advance). * * `value` is clamped to [0, max]. For "step", value = completed steps. */export function Progress({ value, max = 100, variant = "linear", label, className,}: { value: number; max?: number; variant?: "linear" | "step"; /** Optional caption shown with the bar (e.g. "3 of 5", "0:42 left"). */ label?: ReactNode; className?: string;}) { const clamped = Math.max(0, Math.min(value, max)); if (variant === "step") { // Steps must be a sane integer count — a fractional or negative max // would make Array.from misrender the dots. const steps = Math.max(0, Math.floor(max)); return ( <div className={cn("flex items-center gap-2", className)} role="progressbar" aria-valuenow={clamped} aria-valuemin={0} aria-valuemax={steps} aria-label={stringLabel(label)} > {Array.from({ length: steps }, (_, i) => ( <span key={i} className={cn( "size-3 rounded-full transition-[background,box-shadow] duration-[250ms] ease-in-out", i < clamped ? "bg-primary [box-shadow:0_0_7px_color-mix(in_oklab,var(--accent)_45%,transparent)]" : "bg-[rgba(255,255,255,0.16)]", )} /> ))} </div> ); } return ( <div className={cn("flex w-full flex-col gap-2", className)}> <progress className="gk-progress__el" value={clamped} max={max} aria-label={stringLabel(label)} /> {label != null ? ( <div className="t-caption flex items-center justify-between text-foreground-faint [font-variant-numeric:tabular-nums]"> {label} </div> ) : null} </div> );}// components/glasskit/timer.tsx"use client";import { useEffect, useRef, useState, type ReactNode } from "react";import { cn, stringLabel } from "../lib/utils";import { Progress } from "./progress";/** * <Timer> — a countdown readout: big tabular m:ss (h:mm:ss past an hour), an * optional label, and a bar draining toward zero. Self-ticking by default: * give it `duration` and flip `running` to pause/resume — ticks are end-time * anchored (no setTimeout drift) and aligned to the second boundary. * `onComplete` fires once at zero. Pass `remaining` to control it instead: * the prop always wins, the component never ticks, and `onComplete` is yours * to call — you own the clock. */export function Timer({ duration, remaining, running = true, label, showBar = true, onComplete, className,}: { /** Total seconds. Drives self-ticking and the drain bar's scale. */ duration?: number; /** Controlled seconds left. Omit to self-tick from `duration`. */ remaining?: number; /** Pause/resume the self-ticking countdown. */ running?: boolean; label?: ReactNode; /** Hide the drain bar (it needs `duration` for its scale). */ showBar?: boolean; /** Fires once when a self-ticking countdown reaches zero. */ onComplete?: () => void; className?: string;}) { const controlled = remaining != null; const [internal, setInternal] = useState(duration ?? 0); // Latest-values refs: the anchor effect reads these at (re)start time so // pausing/resuming re-anchors from the frozen value, and onComplete stays // current without being an effect dependency. const internalRef = useRef(internal); internalRef.current = internal; const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; // A new duration is a new countdown — reset. Runs before the anchor effect // below (declaration order), so the ref is fresh when it re-anchors. useEffect(() => { setInternal(duration ?? 0); internalRef.current = duration ?? 0; }, [duration]); useEffect(() => { if (controlled || !running || internalRef.current <= 0) return; // Anchor to a wall-clock end time: each tick recomputes from it, so // timeout jitter never accumulates into a slow timer. const endAt = Date.now() + internalRef.current * 1000; let timer: ReturnType<typeof setTimeout>; const schedule = () => { timer = setTimeout(tick, ((endAt - Date.now() - 20) % 1000) + 20); }; const tick = () => { const left = Math.max(0, Math.ceil((endAt - Date.now()) / 1000)); setInternal(left); if (left <= 0) { onCompleteRef.current?.(); return; } schedule(); }; schedule(); return () => clearTimeout(timer); }, [controlled, running, duration]); const shown = controlled ? Math.max(0, remaining) : internal; return ( <div className={cn("gk-timer", className)} role="timer" aria-label={stringLabel(label) ?? "Timer"} > <span className="gk-timer__time">{formatSeconds(shown)}</span> {label != null ? ( <span className="gk-timer__label t-caption">{label}</span> ) : null} {showBar && duration != null && duration > 0 ? ( // Decorative: the big time above carries the value (role="timer"), // a visible Progress label here would just repeat the caption. <div aria-hidden="true" className="gk-timer__bar"> <Progress value={shown} max={duration} /> </div> ) : null} </div> );}function formatSeconds(total: number): string { const s = Math.max(0, Math.floor(total)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const mm = h > 0 ? String(m).padStart(2, "0") : String(m); return `${h > 0 ? `${h}:` : ""}${mm}:${String(s % 60).padStart(2, "0")}`;}// components/glasskit/button.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** * <Button> — a D-pad-focusable action. Renders a real <button> carrying * the `focusable` class, so `useDpad()` includes it in spatial navigation * and activates it on Enter/Space (the hook calls `.click()`, which fires * `onClick`). `primary` wears the accent fill; `positive`/`danger` carry the * semantic accept/destroy fills (calls, irreversible confirms); `ghost` is * chrome-less (just text + focus ring + press) for toolbars and icon rows. */export function Button({ children, variant = "secondary", icon, disabled, onClick, type = "button", initialFocus = false, "aria-label": ariaLabel, className,}: { /** The label. Omit for an icon-only button, but then set `aria-label`. */ children?: ReactNode; variant?: "primary" | "secondary" | "ghost" | "positive" | "danger"; /** Optional leading glyph — typically a <Icon>. */ icon?: ReactNode; disabled?: boolean; onClick?: () => void; type?: "button" | "submit" | "reset"; /** Seed the D-pad ring here when the screen mounts (`data-autofocus`). */ initialFocus?: boolean; /** Accessible name — required for an icon-only button. */ "aria-label"?: string; className?: string;}) { return ( <button type={type} disabled={disabled} onClick={onClick} aria-label={ariaLabel} data-autofocus={initialFocus || undefined} className={cn( "focusable press-scale t-body inline-flex items-center justify-center gap-2 rounded-2xl", variant === "primary" ? "btn-primary" : variant === "positive" ? "btn-positive" : variant === "danger" ? "btn-danger" : variant === "ghost" ? "bg-transparent border-0 text-foreground" : "surface", !children ? "p-[13px] [&_svg]:size-[22px]" : "px-6 py-4", className, )} > {icon} {children} </button> );}// components/glasskit/confirm.tsx"use client";import type { ReactNode } from "react";import { FocusScope } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { Button } from "./button";/** * <Confirm> — a decision screen: a prompt + a two-button action bar (confirm / * cancel). Drop it into a <Screen> stage. Focus seeds on the primary action; * set `destructive` for irreversible decisions and the ring seeds on cancel * instead — a blind pinch must never destroy anything. Rendered inside a * FocusScope so the ring can't wander off the decision. */export function Confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", destructive = false, onConfirm, onCancel, className,}: { title?: ReactNode; message?: ReactNode; confirmLabel?: ReactNode; cancelLabel?: ReactNode; /** Irreversible action — seed the D-pad ring on cancel, not confirm. */ destructive?: boolean; onConfirm?: () => void; onCancel?: () => void; className?: string;}) { return ( <FocusScope> <div className={cn( "flex flex-col items-center gap-4 text-center", className, )} > {title != null ? <p className="t-title">{title}</p> : null} {message != null ? ( <p className="t-body max-w-[28ch] text-muted-foreground">{message}</p> ) : null} <div className="mt-1.5 flex gap-3"> <Button variant={destructive ? "danger" : "primary"} initialFocus={!destructive} onClick={onConfirm} > {confirmLabel} </Button> <Button initialFocus={destructive} onClick={onCancel}> {cancelLabel} </Button> </div> </div> </FocusScope> );}// components/glasskit/empty-state.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";import { Button } from "./button";/** * <EmptyState> — the nothing-here screen: optional glyph + title + hint + one * action. The quiet sibling of <ErrorState> — nothing failed, there is just no * content yet, so it reads dimmer and the action invites rather than recovers. * Drop it into a <Screen> stage, or use it as the `placeholder` slot of * <AsyncView>. */export function EmptyState({ title = "Nothing here yet", hint, icon, onAction, actionLabel = "Refresh", className,}: { title?: ReactNode; /** A quieter second line — what will fill this screen, or how. */ hint?: ReactNode; /** Optional leading glyph — typically a <Icon>. */ icon?: ReactNode; onAction?: () => void; actionLabel?: ReactNode; className?: string;}) { return ( <div className={cn( "flex flex-col items-center gap-3.5 text-center", className, )} > {icon} <p className="t-title text-muted-foreground">{title}</p> {hint != null ? ( <p className="t-body max-w-[30ch] text-foreground-faint">{hint}</p> ) : null} {onAction ? <Button onClick={onAction}>{actionLabel}</Button> : null} </div> );}// components/glasskit/icon.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** Tasteful gradient tones for icon plates (see styles.css `.gk-grad-*`). */export type IconTone = "blue" | "green" | "peach" | "violet" | "cyan" | "amber";/** * <Icon> — wraps any icon provider's SVG (Lucide, Tabler, or your own) and * applies the lens icon styling. Two modes: * - default: a 2-tier luminance glyph (inert near-white, `active` = accent). * - `plate`: an iOS/Meta-style gradient app-icon squircle holding a white glyph * (pass `tone` for the gradient). * * Decorative by default; pass `label` to expose it to assistive tech. */export function Icon({ children, active = false, size = "md", plate = false, tone = "blue", label, className,}: { /** A stroke-based SVG element. */ children: ReactNode; active?: boolean; /** sm 16 · md 20 · lg 28 (px @ 600×600). */ size?: "sm" | "md" | "lg"; /** Render as a gradient app-icon plate. */ plate?: boolean; /** Plate gradient tone. */ tone?: IconTone; label?: string; className?: string;}) { const a11y = { role: label ? ("img" as const) : undefined, "aria-label": label, "aria-hidden": label ? undefined : true, }; // Plate (squircle) and inner-glyph sizes, in px @ 600×600. const plateSize = { sm: "size-[46px]", md: "size-[66px]", lg: "size-[132px]" }; const plateGlyph = { sm: "size-6", md: "size-[34px]", lg: "size-16" }; const glyphSize = { sm: "size-4", md: "size-5", lg: "size-7" }; if (plate) { return ( <span className={cn( "gk-plate", `gk-grad-${tone}`, plateSize[size], className, )} {...a11y} > <span className={cn("gk-icon", plateGlyph[size])}>{children}</span> </span> ); } return ( <span className={cn( "gk-icon", glyphSize[size], active && "gk-icon--active", className, )} {...a11y} > {children} </span> );}// components/glasskit/workout.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 { StatGrid } from "./stat-grid";import { StatusDot } from "./status-dot";import { Timer } from "./timer";import { Button } from "./button";import { Confirm } from "./confirm";import { EmptyState } from "./empty-state";import { Icon } from "./icon";/** * <Workout> — a fitness companion: a workout list, a live session, and a rest * timer, composed from Navigator, Timer, and a destructive Confirm. */export function WorkoutApp() { return ( <Navigator screens={{ home: () => <Home />, run: () => <Run />, rest: () => <Rest />, end: () => <End />, history: () => <History />, }} initial="home" /> );}function Home() { const nav = useNavigator(); return ( <Screen cue="Pinch opens · middle pinch backs out"> <Heading eyebrow="Workout">Today</Heading> <List> <ListRow leading={ <Icon size="sm" plate tone="peach" label="Run"> <HeartGlyph /> </Icon> } trailing={<ChevronGlyph />} onClick={() => nav.push("run")} > Start a run </ListRow> <ListRow trailing={<ChevronGlyph />} onClick={() => nav.push("rest")}> Rest timer </ListRow> <ListRow trailing={<ChevronGlyph />} onClick={() => nav.push("history")} > History </ListRow> </List> </Screen> );}function Run() { const nav = useNavigator(); return ( <Screen status={ <span className="t-caption text-foreground-faint"> <StatusDot status="live" label="GPS" /> Recording </span> } cue="Stats update as you move" > <StatGrid items={[ { label: "Pace", value: "8'42", unit: "/mi" }, { label: "Heart", value: 128, unit: "bpm" }, { label: "Dist", value: "3.2", unit: "km" }, { label: "Time", value: "18:40" }, ]} /> <Button variant="primary" onClick={() => nav.push("end")}> End workout </Button> </Screen> );}function Rest() { const [running, setRunning] = useState(true); const [round, setRound] = useState(0); return ( <Screen cue="Middle pinch returns to the list"> <Timer key={round} duration={90} running={running} label="Rest" /> <div className="row"> <Button variant="primary" onClick={() => setRunning((r) => !r)}> {running ? "Pause" : "Resume"} </Button> <Button onClick={() => { setRound((r) => r + 1); setRunning(true); }} > Restart </Button> </div> </Screen> );}function End() { const nav = useNavigator(); return ( <Screen cue="The ring seeds on the safe action"> {/* destructive: ending discards the live session — focus starts on "Keep going" so a blind pinch can't end the run. */} <Confirm destructive title="End workout?" message="42 minutes will be saved." confirmLabel="End" cancelLabel="Keep going" onConfirm={() => nav.popToTop()} onCancel={() => nav.pop()} /> </Screen> );}function History() { return ( <Screen cue="Nothing here yet"> <EmptyState title="No workouts" hint="Finished sessions land here." actionLabel="Start one" /> </Screen> );}function HeartGlyph() { return ( <svg xmlns="http://www.w3.org/2000/svg" width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" > <path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" /> </svg> );}function ChevronGlyph() { return ( <svg xmlns="http://www.w3.org/2000/svg" width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" > <path d="M9 6l6 6l-6 6" /> </svg> );}Usage
import { WorkoutApp } from "@/components/glasskit/workout";export default function Page() { return <WorkoutApp />;}Overview
Complete multi-screen apps composed from the real components. Each template gets its own page so its D-pad focus and inputs stay isolated. Drive them with your keyboard, or scan the QR to run them on your glasses.
Messages
A messaging template. A scrolling message thread with ChatBubble, plus ComposeFlow quick replies for the no-keyboard text answer, on one glanceable screen.