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

Workout

Today

Pinch opens · middle pinch backs out
  • 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 workout

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