← ui

browser

ui

a Safari window to put a page in: mocked, a live site, or a visit played on the playhead

npx shadcn add @ag/browser
Already installed? Preview an update
npx shadcn add @ag/browser --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-16
  • framing and livePage fetch through publicFetch too.
Updated 2026-10-16 · release notes

also called safari, browser window

shelf devices & demos

uses clip, media-asset, public-fetch, web-preview-facts, web-preview-unfurl, web-preview, image, material, playback, scroll-thumb, button, copy

the demo above, verbatim

browser.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import CAPTURED_EXAMPLE from "@/public/browser/example.json"
import {
  atFound,
  type BrowserCapture,
} from "@/registry/base-nova/lib/browser-capture"
import type { LivePage } from "@/registry/base-nova/lib/browser-framing"
import {
  type BrowserStep,
  browserTimeline,
} from "@/registry/base-nova/lib/browser-session"
import type { Art, Timeline } from "@/registry/base-nova/lib/macos-session"
import {
  Browser,
  BrowserCapturePage,
  BrowserLive,
  BrowserSession,
} from "@/registry/base-nova/ui/browser"
import { IphoneFrame } from "@/registry/base-nova/ui/device-frame"
import { Image } from "@/registry/base-nova/ui/image"
import { Macos } from "@/registry/base-nova/ui/macos"
import { Player } from "@/registry/base-nova/ui/player"

// #region page
/** A mocked page: your own React, drawn in the frame as a site would be. Its picture
 *  runs up under glass chrome (`--ag-browser-top`, the chrome's height in glass, 0
 *  in plain), as a page with a hero does in Safari 26. */
function Page({
  name,
  title,
  picture,
  paragraphs = 6,
}: {
  name: string
  title: string
  picture: string
  paragraphs?: number
}) {
  return (
    <article className="min-h-full bg-white text-zinc-900">
      <div className="relative -mt-(--ag-browser-top) pt-(--ag-browser-top)">
        <Image src={picture} alt="" fill sizes="800px" fit="cover" />
        <div className="h-56" />
      </div>
      <div className="mx-auto max-w-[56ch] space-y-4 px-6 py-8">
        <p className="font-mono text-xs text-zinc-500 lowercase">{name}</p>
        <h1 className="font-semibold text-3xl tracking-tight">{title}</h1>
        {Array.from({ length: paragraphs }, (_, i) => (
          // biome-ignore lint/suspicious/noArrayIndexKey: filler, positional
          <p key={i} className="text-[15px] text-zinc-600 leading-relaxed">
            A page is whatever you put in the frame: a mocked site in React, a
            live one in an iframe, or a visit scripted on the playhead. This is
            paragraph {i + 1}, here so the page is long enough to scroll.
          </p>
        ))}
      </div>
    </article>
  )
}
// #endregion

// #region visit
// What a person does, as steps; the pages it reaches, by address. The clip goes to
// whatever moves time (a Player here).
const STEPS: BrowserStep[] = [
  { kind: "type", text: "untitled.garden", delay: 600 },
  { kind: "scroll", to: 0.6, delay: 500 },
  { kind: "scroll", to: 0, delay: 300, ms: 600 },
  { kind: "go", url: "untitled.garden/notes/lake", delay: 300 },
  { kind: "scroll", to: 0.8, delay: 600, ms: 1400 },
  { kind: "back", delay: 600 },
]
const PAGES = {
  "ui.adriangalilea.com": (
    <Page name="ui" title="the registry" picture="/lightbox/peaks.jpg" />
  ),
  "untitled.garden": (
    <Page name="untitled" title="a garden" picture="/lightbox/field.jpg" />
  ),
  "untitled.garden/notes/lake": (
    <Page
      name="untitled / notes"
      title="the lake at six"
      picture="/lightbox/lake.jpg"
      paragraphs={10}
    />
  ),
}
const VISIT = browserTimeline("ui.adriangalilea.com", STEPS)
// #endregion

// #region capture
// The manifest `mise browser-shot https://example.com public/browser/example
// --width 1024 --tall 1 --find words=p` wrote, imported as it is: each picture's
// address is already in it.
const EXAMPLE = CAPTURED_EXAMPLE as BrowserCapture
// #endregion

// #region live
// `mise live-page <url>` read each site's headers once and printed these: Wikipedia
// lets any page frame it, GitHub refuses every one (CSP frame-ancestors 'none'), so
// its unfurl stands in.
const WIKIPEDIA: LivePage = {
  url: "https://en.wikipedia.org/wiki/Safari_(web_browser)",
  frames: true,
}
const GITHUB: LivePage = {
  url: "https://github.com/anthropics/claude-code",
  frames: false,
  reason: "frame-ancestors 'none'",
  facts: {
    url: "https://github.com/anthropics/claude-code",
    site: "GitHub",
    title: "GitHub - anthropics/claude-code",
    description:
      "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster.",
    image:
      "https://opengraph.githubassets.com/17a88e22dccf7dca25041115bdae4fae5cc5ecc4d3bf460d5220d81f3cfad00d/anthropics/claude-code",
    favicon: "https://github.githubassets.com/favicons/favicon.svg",
  },
}
// #endregion

// #region desktop
// The desktop alone: a story with no steps, no app glyph. The window is the story.
const DESKTOP: Timeline = { app: "Safari", chord: "", steps: [] }
const NO_ART: Art = { icon: "", glyphs: {} }
// #endregion

export default function BrowserDemo() {
  return (
    <div className="space-y-16">
      <Sample
        name="page"
        title="a mocked page"
        description="your own React in the frame; two tabs show the tab bar"
        with="page"
      >
        <Browser
          url="https://untitled.garden/notes/lake"
          back
          tabs={[{ title: "the lake at six" }, { title: "a garden" }]}
        >
          <Page
            name="untitled / notes"
            title="the lake at six"
            picture="/lightbox/lake.jpg"
          />
        </Browser>
      </Sample>

      <Sample
        name="glass"
        title="glass"
        description="Safari 26: no toolbar, the controls are glass over the page, which scrolls under them"
        with="page"
      >
        <Browser url="https://untitled.garden/notes/lake" material="glass" back>
          <Page
            name="untitled / notes"
            title="the lake at six"
            picture="/lightbox/lake.jpg"
          />
        </Browser>
      </Sample>

      <Sample
        name="visit"
        glance="large"
        title="a visit, played"
        description="an address typed, pages loading, a link followed, scrolled, back"
        with="page visit"
      >
        <Player
          clip={VISIT.clip}
          label="a visit to untitled.garden"
          className="mx-auto w-full max-w-3xl"
        >
          <BrowserSession
            start="ui.adriangalilea.com"
            steps={STEPS}
            pages={PAGES}
            alt="Safari: untitled.garden typed into the address bar, the page loads and scrolls, a note opens, then back"
          />
        </Player>
      </Sample>

      <Sample
        name="iphone"
        title="on an iphone"
        description="safari-ios: the controls glass at the screen's foot, the page under the status bar and them; plain is the opaque bar iOS drew before"
        with="page visit"
      >
        <div className="flex flex-wrap justify-center gap-10">
          <div className="w-72">
            <Player clip={VISIT.clip} label="a visit, on an iphone">
              <IphoneFrame>
                <BrowserSession
                  look="safari-ios"
                  material="glass"
                  start="ui.adriangalilea.com"
                  steps={STEPS}
                  pages={PAGES}
                  alt="Safari on an iPhone: untitled.garden typed, the page loads and scrolls, a note opens, then back"
                />
              </IphoneFrame>
            </Player>
          </div>
          <div className="w-72 max-sm:hidden">
            <IphoneFrame>
              <Browser look="safari-ios" url="https://untitled.garden" back>
                <Page
                  name="untitled"
                  title="a garden"
                  picture="/lightbox/field.jpg"
                />
              </Browser>
            </IphoneFrame>
          </div>
        </div>
      </Sample>

      <Sample
        name="live"
        title="a live site"
        description="framed, since Wikipedia allows it"
        with="live"
      >
        <Browser url={WIKIPEDIA.url} className="aspect-auto h-[560px]">
          <BrowserLive page={WIKIPEDIA} />
        </Browser>
      </Sample>

      <Sample
        name="captured"
        title="a captured site"
        description="the manifest mise browser-shot wrote, drawn as it is; a box laid on what --find found"
        with="capture"
      >
        <Browser url={EXAMPLE.url}>
          <BrowserCapturePage capture={EXAMPLE}>
            <div
              aria-hidden
              className="rounded-md ring-2 ring-[#0a84ff] ring-offset-4 ring-offset-transparent"
              style={atFound(EXAMPLE, "words")}
            />
          </BrowserCapturePage>
        </Browser>
      </Sample>

      <Sample
        name="refused"
        title="a site that refuses frames"
        description="GitHub refuses every frame; its unfurl stands in"
        with="live"
      >
        <Browser url={GITHUB.url} scheme="dark">
          <BrowserLive page={GITHUB} />
        </Browser>
      </Sample>

      <Sample
        name="desktop"
        title="on a mac"
        description="the same visit as a window on macos's desktop, on one clock"
        with="page visit desktop"
      >
        <Player clip={VISIT.clip} label="a visit, on a mac">
          <Macos
            timeline={DESKTOP}
            art={NO_ART}
            front="Safari"
            device="macbook"
            captions={false}
            alt="A Mac with Safari open: an address typed, pages loading, a note opened, then back"
          >
            <BrowserSession
              start="ui.adriangalilea.com"
              steps={STEPS}
              pages={PAGES}
              material="glass"
              alt=""
              className="absolute top-[48px] left-[80px] aspect-auto h-[500px] w-[800px]"
            />
          </Macos>
        </Player>
      </Sample>
    </div>
  )
}

the source, every file an install writes

browser.tsx
"use client"

// A BROWSER WINDOW, a frame you put a page in: your own React (a mocked page), a live
// site (`BrowserLive`, an iframe where the site allows framing, its unfurl where it
// refuses), or a scripted visit (`BrowserSession`: an address typed, a page loading,
// a link followed, a scroll) that plays on the playhead like the terminal and the
// chats, so it can be recorded alone or set on a Mac's desktop (`<Macos>`'s windows).
//
// `Browser` is controlled and draws one instant: the address field's text, its focus,
// the load's bar, back and forward, how far down the page is. Whoever holds the
// moment passes it: a page by hand, or `BrowserSession` from the visit's frame at the
// playhead (lib/browser-session). The chrome is a depiction, display-only and hidden
// from assistive tech (zero focusables): the page inside is the real thing.
//
// THE LOOK is the browser depicted, `look`, one chrome per name (`CHROMES`). Safari's
// only, for now; another browser is another entry with its own toolbar and its own
// heights, the window and the page around it shared.
//
// THE MATERIAL is the depicted browser's own two eras: plain is Safari's opaque
// toolbar, the page below it; glass is Safari 26's, no toolbar at all, its controls
// glass capsules floating over the page, which runs up under them. The capsules are
// front surfaces over the scroller, never inside it (the rule for a glass front): the
// page pads its top by the chrome's height so it starts below them and scrolls under.

import {
  ChevronLeft,
  ChevronRight,
  Copy,
  PanelLeft,
  Plus,
  RotateCw,
  Share,
  TextAlignJustify,
  X,
} from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import type { BrowserCapture } from "@/registry/base-nova/lib/browser-capture"
import type { LivePage } from "@/registry/base-nova/lib/browser-framing"
import {
  type BrowserLoad,
  type BrowserStep,
  browserFrameAt,
  browserTimeline,
  urlOf,
} from "@/registry/base-nova/lib/browser-session"
import { hostOf } from "@/registry/base-nova/lib/web-preview-facts"
import { Button } from "@/registry/base-nova/ui/button"
import { Copy as CopyValue } from "@/registry/base-nova/ui/copy"
import { Image } from "@/registry/base-nova/ui/image"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { usePlayhead } from "@/registry/base-nova/ui/playback"
import {
  NO_SCROLLBAR,
  ScrollThumbs,
} from "@/registry/base-nova/ui/scroll-thumb"
import { WebPreview } from "@/registry/base-nova/ui/web-preview"

/** The browser depicted. Each is a chrome in `CHROMES`: `safari` a Mac's window,
 *  `safari-ios` Safari on an iPhone (iOS 26), filling the screen of the box it is
 *  put in (an `IphoneFrame`'s), its controls at the foot. */
export type BrowserLook = "safari" | "safari-ios"

/** A tab in the tab bar. The bar shows when there are two or more, as Safari's
 *  does. */
export interface BrowserTab {
  title: string
  /** The site's icon, a URL. */
  icon?: string
}

export interface BrowserProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  look?: BrowserLook
  /** The page's address. */
  url: string
  /** What the address field holds when it is not the page's address: the text
   *  being typed, or the address of a page still loading. */
  address?: string
  /** The address field has focus: its text is shown whole, left-aligned, with the
   *  caret at its end. */
  editing?: boolean
  /** At rest, the whole address, not the host alone (Safari's "show full website
   *  address"): for a page whose path is the point (a profile's address). */
  full?: boolean
  /** The page's real address, live: the address field opens it (a new tab), a copy
   *  shows in it under the hand, and the share glyph shares it (the system's sheet,
   *  else a copy). The rest of the window stays a picture. */
  link?: string
  /** Focused and not yet typed in: the whole address is selected. */
  selected?: boolean
  /** A load under way: the bar along the address field's foot. */
  load?: BrowserLoad | null
  /** Whether back and forward have anywhere to go (dimmed when not). */
  back?: boolean
  forward?: boolean
  /** How far down the page is, 0..1. Set, the page is held there (a recording);
   *  unset, the reader scrolls it. */
  scroll?: number
  /** The site's icon, drawn before its host in the address field. */
  icon?: string
  tabs?: BrowserTab[]
  /** The tab in front, an index of `tabs`. */
  tab?: number
  material?: Material
  scheme?: Scheme
  /** The page. */
  children?: React.ReactNode
}

/** The OS being depicted speaks in its own face, not the page's. */
const SYSTEM = "-apple-system, BlinkMacSystemFont, system-ui, sans-serif"
/** Safari's accent: the load bar, the focus ring, the caret, a selection. */
const BLUE = "#0a84ff"
/** The load's hairline: the accent, paler and thinner, as Safari 26 draws it. */
const LOAD_BLUE = "rgb(100 170 255 / 0.75)"

/** What a look's chrome is told. */
interface ChromeProps {
  address: string
  editing: boolean
  selected: boolean
  load: BrowserLoad | null
  back: boolean
  forward: boolean
  icon: string | undefined
  tabs: BrowserTab[] | undefined
  tab: number
  glass: boolean
  full: boolean
  link: string | undefined
}

interface Chrome {
  /** Where the controls stand: across the window's top (a Mac's), or at the
   *  screen's foot (a phone's). */
  place: "top" | "bottom"
  /** The window the page is in: a Mac window's shape and shadow, or the whole of
   *  a phone's screen. */
  window: string
  /** What the page is padded by, CSS lengths: its top (the chrome's band in glass,
   *  where the page runs up under it, and a phone's status bar always) and its
   *  foot (a phone's controls in glass). */
  insets: (o: { glass: boolean; tabbed: boolean }) => {
    top: string
    bottom: string
  }
  Toolbar: React.ComponentType<ChromeProps>
}

export function Browser({
  look = "safari",
  url,
  address,
  editing = false,
  full = false,
  link,
  selected = false,
  load = null,
  back = false,
  forward = false,
  scroll,
  icon,
  tabs,
  tab = 0,
  material: own,
  scheme,
  children,
  className,
  style,
  ref,
  ...props
}: BrowserProps) {
  const material = useMaterial(own)
  const [worn, root] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
  const chrome = CHROMES[look]
  if (tabs && (tab < 0 || tab >= tabs.length))
    throw new Error(`browser: tab ${tab} is not one of ${tabs.length} tabs`)
  const glass = material === "glass"
  const tabbed = (tabs?.length ?? 0) > 1
  const insets = chrome.insets({ glass, tabbed })
  const bottom = chrome.place === "bottom"
  const toolbar = (
    <chrome.Toolbar
      address={address ?? url}
      editing={editing}
      selected={selected}
      load={load}
      back={back}
      forward={forward}
      icon={icon}
      tabs={tabbed ? tabs : undefined}
      tab={tab}
      glass={glass}
      full={full}
      link={link}
    />
  )
  // The chrome is a depiction, hidden from assistive tech, unless it holds the live
  // address (`link`): then its two live controls are read, its glyphs saying nothing.
  const depicted = link === undefined
  return (
    <div
      ref={root}
      data-slot="browser"
      data-look={look}
      data-material={material}
      className={cn(
        "relative isolate flex flex-col overflow-hidden bg-background text-foreground",
        chrome.window,
        schemeOf(worn),
        className,
      )}
      style={
        {
          fontFamily: SYSTEM,
          "--ag-browser-top": insets.top,
          "--ag-browser-bottom": insets.bottom,
          ...style,
        } as React.CSSProperties
      }
      {...props}
    >
      {glass ? (
        <>
          <BrowserPage scroll={scroll} className="absolute inset-0">
            {children}
          </BrowserPage>
          {/* Safari 26's band behind its controls (a Mac's) or the status bar (a
              phone's): the page passing under blurred away and tinted with its own
              ground, never sharp behind the window buttons or the clock. A sibling
              of the toolbar, so the capsules' own glass still reads what lies
              under them. */}
          <div
            aria-hidden
            className="pointer-events-none absolute inset-x-0 top-0 z-10 h-(--ag-browser-top) bg-background/75 backdrop-blur-2xl backdrop-saturate-150"
          />
          {bottom && (
            // A phone's foot: the page dimming into its ground under the
            // floating controls, so they stand on it whatever scrolls by.
            <div
              aria-hidden
              className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-(--ag-browser-bottom) bg-linear-to-b from-transparent to-background/80"
            />
          )}
          <div
            aria-hidden={depicted || undefined}
            className={cn(
              "pointer-events-none absolute inset-x-0 z-10",
              bottom ? "bottom-0" : "top-0",
            )}
          >
            {toolbar}
          </div>
        </>
      ) : (
        <>
          {!bottom && (
            <div aria-hidden={depicted || undefined} className="shrink-0">
              {toolbar}
            </div>
          )}
          <BrowserPage scroll={scroll} className="relative min-h-0 flex-1">
            {children}
          </BrowserPage>
          {bottom && (
            <div aria-hidden={depicted || undefined} className="shrink-0">
              {toolbar}
            </div>
          )}
        </>
      )}
    </div>
  )
}

/** The page's scroller. Held at `scroll` for a recording (no overflow of its own,
 *  so nothing but the playhead moves it), else the reader's, with the registry's
 *  thumbs. Its top is padded by the glass chrome over it (`--ag-browser-top`), and
 *  the thumbs keep out of that band (`scroll-padding-top`). */
function BrowserPage({
  scroll,
  className,
  children,
}: {
  scroll: number | undefined
  className: string
  children: React.ReactNode
}) {
  const ref = React.useRef<HTMLDivElement>(null)
  React.useLayoutEffect(() => {
    const el = ref.current
    if (!el || scroll === undefined) return
    el.scrollTop = scroll * (el.scrollHeight - el.clientHeight)
  })
  return (
    <div
      ref={ref}
      data-slot="browser-page"
      className={cn(
        "pt-(--ag-browser-top) pb-(--ag-browser-bottom) [scroll-padding-top:var(--ag-browser-top)]",
        // edge: a browser's page meets its toolbar and the window's foot with a
        // hard edge, as the depicted browser draws it; a fade would be ours, not its.
        scroll === undefined
          ? `overflow-y-auto ${NO_SCROLLBAR}`
          : "overflow-hidden",
        className,
      )}
    >
      {scroll === undefined && <ScrollThumbs scroller={ref} />}
      {children}
    </div>
  )
}

// ── safari ──

const SAFARI_TOOLBAR = 52
const SAFARI_TABS = 32

/** A toolbar glyph's cell, as Safari spaces them: on the plain bar a box; in
 *  glass a square the capsule's height, so a capsule is as long as its glyphs (one
 *  alone a circle, a group a pill), never padded round them. */
const GLYPH = "flex shrink-0 items-center justify-center [&_svg]:size-[17px]"
const PLAIN_CELL = "h-7 w-8"
const GLASS_CELL = "size-9"
/** A glyph only a wide window has room for (the chrome is the container): below
 *  it, the room goes to the address, which a mid-size window would otherwise cut
 *  (a `full` address's path is the part that matters). */
const WIDE = "hidden @xl/browser:flex"
/** A glass capsule: Safari 26's controls, each group of them one piece of glass. */
const CAPSULE = cn(
  glassVariants({ shape: "surface" }),
  "flex h-9 items-center [--glass-radius:18px]",
)

/** The three window buttons, in their own colours (active window). */
function TrafficLights() {
  return (
    <span data-slot="browser-window-buttons" className="flex shrink-0 gap-2">
      {["#ff5f57", "#febc2e", "#28c840"].map((c) => (
        <span
          key={c}
          className="size-3 rounded-full shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.18)]"
          style={{ background: c }}
        />
      ))}
    </span>
  )
}

function SafariToolbar({
  address,
  editing,
  selected,
  load,
  back,
  forward,
  icon,
  tabs,
  tab,
  glass,
  full,
  link,
}: ChromeProps) {
  const cell = cn(GLYPH, glass ? GLASS_CELL : PLAIN_CELL)
  // A narrow window keeps back, forward, share and the field, as Safari's does.
  const navigation = (
    <>
      <span className={cn(cell, WIDE)}>
        <PanelLeft strokeWidth={1.75} />
      </span>
      <span className={cn(cell, !back && "opacity-35")}>
        <ChevronLeft strokeWidth={2} />
      </span>
      <span className={cn(cell, !forward && "opacity-35")}>
        <ChevronRight strokeWidth={2} />
      </span>
    </>
  )
  const actions = (
    <>
      <span className={cell}>
        {link === undefined ? (
          <Share strokeWidth={1.75} />
        ) : (
          // Live: the system's share sheet with the page's address, else a copy.
          // A button of its own size in the glyph's cell, concentric with it.
          <Button
            variant="ghost"
            size={glass ? "icon" : "icon-sm"}
            aria-label="share this address"
            onClick={() => share(link)}
            className="pointer-events-auto"
          >
            <Share strokeWidth={1.75} />
          </Button>
        )}
      </span>
      <span className={cn(cell, WIDE)}>
        <Plus strokeWidth={1.75} />
      </span>
      <span className={cn(cell, WIDE)}>
        <Copy strokeWidth={1.75} />
      </span>
    </>
  )
  return (
    <div data-slot="browser-chrome" className="@container/browser select-none">
      <div
        data-slot="browser-toolbar"
        className={cn(
          "grid items-center gap-3 px-4 [grid-template-columns:minmax(max-content,1fr)_minmax(0,560px)_minmax(max-content,1fr)]",
          glass
            ? "text-foreground/75"
            : "border-black/10 border-b bg-[#f6f6f6] text-black/55 scheme-dark:border-black/60 scheme-dark:bg-[#2b2b2d] scheme-dark:text-white/55",
        )}
        style={{ height: SAFARI_TOOLBAR }}
      >
        <div className="flex items-center gap-3">
          <TrafficLights />
          {glass ? (
            <span className={CAPSULE}>{navigation}</span>
          ) : (
            <span className="flex items-center">{navigation}</span>
          )}
        </div>
        <AddressField
          address={address}
          editing={editing}
          selected={selected}
          load={load}
          icon={icon}
          glass={glass}
          full={full}
          link={link}
        />
        <div className="flex items-center justify-end">
          {glass ? <span className={CAPSULE}>{actions}</span> : actions}
        </div>
      </div>
      {tabs && <SafariTabs tabs={tabs} tab={tab} glass={glass} />}
    </div>
  )
}

/** The system's share sheet with `url`, where there is one (a phone, Safari); else
 *  the address copied. A share the person cancels is theirs, not a failure. */
async function share(url: string) {
  if (navigator.share) {
    try {
      await navigator.share({ url })
    } catch (e) {
      if ((e as Error).name !== "AbortError") throw e
    }
    return
  }
  await navigator.clipboard.writeText(url)
}

/** An address as the field shows it at rest: the host alone, or with `full` the
 *  whole address (Safari's "show full website address"), its scheme and `www.`
 *  dropped. Read from the text, never through `URL`, which would turn a host in
 *  other scripts into punycode (👤.to into xn--mq8h.to). */
function shownAt(address: string, full: boolean): string {
  const bare = address.replace(/^[a-z]+:\/\//i, "").replace(/^www\./, "")
  return full ? bare.replace(/\/$/, "") : (bare.split(/[/?#]/)[0] ?? bare)
}

/** The address field: at rest the page's host, centred, with the site's icon and
 *  the reload glyph (a stop glyph while it loads); focused, the whole text, left,
 *  with the caret, or selected; the load's bar along its foot. */
function AddressField({
  address,
  editing,
  selected,
  load,
  icon,
  glass,
  full,
  link,
}: Pick<
  ChromeProps,
  | "address"
  | "editing"
  | "selected"
  | "load"
  | "icon"
  | "glass"
  | "full"
  | "link"
>) {
  return (
    <div
      data-slot="browser-address"
      data-editing={editing || undefined}
      className={cn(
        "group/address relative flex min-w-0 items-center overflow-hidden whitespace-nowrap text-[13px]",
        editing
          ? "justify-start px-3"
          : "justify-center gap-1.5 px-3 @lg/browser:px-9",
        glass
          ? cn(CAPSULE, "text-foreground")
          : "h-[30px] rounded-[9px] bg-black/[0.055] text-black/85 scheme-dark:bg-white/[0.09] scheme-dark:text-white/85",
        !glass && editing && "bg-white scheme-dark:bg-[#1c1c1e]",
        editing && "shadow-[0_0_0_3px_rgb(10_132_255/0.45)]",
      )}
    >
      {editing ? (
        <>
          <span
            className={cn("truncate", selected && "rounded-[3px]")}
            style={
              selected ? { background: "rgb(10 132 255 / 0.32)" } : undefined
            }
          >
            {address}
          </span>
          {!selected && (
            <span
              data-slot="browser-caret"
              className="h-[15px] w-[1.5px] shrink-0"
              style={{ background: BLUE }}
            />
          )}
        </>
      ) : (
        <>
          {icon && (
            <Image
              src={icon}
              alt=""
              className="size-3.5 shrink-0 rounded-[3px]"
            />
          )}
          <span className="truncate">{shownAt(address, full)}</span>
          <span
            className={cn(
              "absolute right-2.5 opacity-60 [&_svg]:size-[13px]",
              WIDE,
              // Live, the copy stands where the reload glyph is, under the hand.
              link !== undefined &&
                "group-focus-within/address:opacity-0 group-hover/address:opacity-0",
            )}
          >
            {load ? <X strokeWidth={2} /> : <RotateCw strokeWidth={2} />}
          </span>
          {link !== undefined && (
            <>
              {/* The field is the link: a press opens the page's address. */}
              <a
                href={link}
                target="_blank"
                rel="noreferrer"
                className="pointer-events-auto absolute inset-0 rounded-[inherit] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0a84ff]"
              >
                <span className="sr-only">open {shownAt(address, true)}</span>
              </a>
              <CopyValue
                value={link}
                aria-label="copy this address"
                className="pointer-events-auto absolute right-1 size-6 opacity-0 group-focus-within/address:opacity-100 group-hover/address:opacity-100 [&_svg]:size-[13px]"
              />
            </>
          )}
        </>
      )}
      {load && (
        <span
          data-slot="browser-load"
          // Safari 26's: a hairline along the field's foot, inset from its round
          // ends, a paler blue than its accent.
          className="absolute inset-x-4 bottom-0.5 h-[1.5px] overflow-hidden rounded-full"
          style={{ opacity: load.opacity }}
        >
          <span
            className="block h-full rounded-full"
            style={{ width: `${load.progress * 100}%`, background: LOAD_BLUE }}
          />
        </span>
      )}
    </div>
  )
}

/** Safari's tab bar: every tab an equal share of the width, the front one lit. */
function SafariTabs({
  tabs,
  tab,
  glass,
}: {
  tabs: BrowserTab[]
  tab: number
  glass: boolean
}) {
  return (
    <div
      data-slot="browser-tabs"
      className={cn(
        "flex items-stretch text-[12px]",
        glass
          ? "items-center gap-1 px-4 pb-1"
          : "border-black/10 border-b bg-[#e8e8ea] text-black/55 scheme-dark:border-black/60 scheme-dark:bg-[#232325] scheme-dark:text-white/55",
      )}
      style={{ height: SAFARI_TABS }}
    >
      {tabs.map((t, i) => (
        <span
          // biome-ignore lint/suspicious/noArrayIndexKey: tabs are positional, as in a tab bar
          key={i}
          data-slot="browser-tab"
          data-active={i === tab || undefined}
          className={cn(
            "flex min-w-0 flex-1 items-center justify-center gap-1.5 px-3",
            glass
              ? i === tab
                ? cn(CAPSULE, "h-7 [--glass-radius:14px]")
                : "h-7 text-foreground/60"
              : i === tab
                ? "bg-[#f6f6f6] text-black/85 scheme-dark:bg-[#2b2b2d] scheme-dark:text-white/85"
                : cn(i > 0 && i !== tab + 1 && "border-black/10 border-l"),
          )}
        >
          {t.icon && (
            <Image
              src={t.icon}
              alt=""
              className="size-3.5 shrink-0 rounded-[3px]"
            />
          )}
          <span className="truncate">{t.title}</span>
        </span>
      ))}
    </div>
  )
}

// ── safari on an iPhone ──

/** The iPhone 16 Pro's screen width in points: every length of the phone's chrome
 *  is a share of it (cqw of the browser, which fills the screen), as contacts-app
 *  draws, so it is the same screen at any frame size. */
const SCREEN_PT = 402
const pt = (n: number) => `${((n * 100) / SCREEN_PT).toFixed(3)}cqw`

/** iOS 26 Safari's metrics, in points: the status bar's band; the controls (a
 *  glass circle each side of the address capsule, all one height), how far they
 *  stand off the foot and the sides; their glyphs; the plain bar's height. */
const IOS = {
  status: 54,
  bar: 48,
  barBottom: 26,
  barX: 16,
  gap: 10,
  glyph: 21,
  type: 17,
  plain: 92,
}

/** A glass control on the phone: one piece of glass, round at its height. */
const IOS_GLASS = cn(
  glassVariants({ shape: "surface" }),
  "flex items-center justify-center [--glass-radius:calc(var(--ag-ios-bar)/2)]",
)

/** iOS 26 Safari's controls at the foot: back in a glass circle, the address in a
 *  glass capsule (the page menu's glyph, the host or the whole address, reload),
 *  the tabs in a glass circle. Plain is the bar iOS drew before: opaque, the
 *  address field above the five buttons. */
function SafariIosToolbar({
  address,
  editing,
  selected,
  load,
  back,
  glass,
  full,
  link,
}: ChromeProps) {
  const glyph = "size-(--ag-ios-glyph) shrink-0"
  const field = (
    <div
      data-slot="browser-address"
      data-editing={editing || undefined}
      className={cn(
        "relative flex h-full min-w-0 flex-1 items-center gap-(--ag-ios-gap) px-(--ag-ios-gap) text-(length:--ag-ios-type)",
        glass
          ? IOS_GLASS
          : "rounded-[calc(var(--ag-ios-bar)/2)] bg-black/[0.06] scheme-dark:bg-white/[0.1]",
        editing && "shadow-[0_0_0_2px_rgb(10_132_255/0.6)]",
      )}
    >
      <TextAlignJustify className={glyph} strokeWidth={2} />
      <span
        className={cn(
          "min-w-0 flex-1 truncate",
          editing ? "text-left" : "text-center",
        )}
      >
        <span
          className={cn(selected && "rounded-[3px]")}
          style={
            selected ? { background: "rgb(10 132 255 / 0.32)" } : undefined
          }
        >
          {editing ? address : shownAt(address, full)}
        </span>
        {editing && !selected && (
          <span
            data-slot="browser-caret"
            className="ml-px inline-block h-[1.1em] w-[1.5px] align-middle"
            style={{ background: BLUE }}
          />
        )}
      </span>
      {load ? (
        <X className={glyph} strokeWidth={2} />
      ) : (
        <RotateCw className={glyph} strokeWidth={2} />
      )}
      {link !== undefined && (
        // The field is the link: a press opens the page's address.
        <a
          href={link}
          target="_blank"
          rel="noreferrer"
          className="pointer-events-auto absolute inset-0 rounded-[inherit] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0a84ff]"
        >
          <span className="sr-only">open {shownAt(address, true)}</span>
        </a>
      )}
      {load && (
        <span
          data-slot="browser-load"
          className="absolute inset-x-[calc(var(--ag-ios-bar)/2)] bottom-[3px] h-[1.5px] overflow-hidden rounded-full"
          style={{ opacity: load.opacity }}
        >
          <span
            className="block h-full rounded-full"
            style={{ width: `${load.progress * 100}%`, background: LOAD_BLUE }}
          />
        </span>
      )}
    </div>
  )
  const vars = {
    "--ag-ios-bar": pt(IOS.bar),
    "--ag-ios-gap": pt(IOS.gap),
    "--ag-ios-glyph": pt(IOS.glyph),
    "--ag-ios-type": pt(IOS.type),
  } as React.CSSProperties
  return (
    <div
      data-slot="browser-chrome"
      className={cn(
        "select-none",
        glass
          ? "text-foreground"
          : "border-black/10 border-t bg-[#f6f6f6] text-black/80 scheme-dark:border-white/10 scheme-dark:bg-[#1c1c1e] scheme-dark:text-white/85",
      )}
      style={{
        ...vars,
        paddingInline: pt(IOS.barX),
        paddingBottom: pt(IOS.barBottom),
        paddingTop: glass ? 0 : pt(IOS.gap),
      }}
    >
      <div
        data-slot="browser-toolbar"
        className="flex h-(--ag-ios-bar) items-center gap-(--ag-ios-gap)"
      >
        <span
          className={cn(
            "h-full shrink-0 aspect-square",
            glass ? IOS_GLASS : "flex items-center justify-center",
            !back && "[&>svg]:opacity-35",
          )}
        >
          <ChevronLeft className={glyph} strokeWidth={2.2} />
        </span>
        {field}
        <span
          className={cn(
            "h-full shrink-0 aspect-square",
            glass ? IOS_GLASS : "flex items-center justify-center",
          )}
        >
          <Copy className={glyph} strokeWidth={2} />
        </span>
      </div>
    </div>
  )
}

/** One chrome per look. TODO (browser.notes.md): Chrome, Arc. */
const CHROMES: Record<BrowserLook, Chrome> = {
  safari: {
    place: "top",
    window: cn(
      "aspect-[16/10] w-full rounded-[16px]",
      // Dark, the hairline alone: a wide soft shadow on a near-black ground
      // falls through too few greys and bands; the window's depth there is the
      // light round it, the page's to give.
      "shadow-[0_0_0_0.5px_rgb(0_0_0/0.22),0_24px_60px_-16px_rgb(0_0_0/0.45)] scheme-dark:shadow-[0_0_0_0.5px_rgb(255_255_255/0.16)]",
    ),
    insets: ({ glass, tabbed }) => ({
      top: `${glass ? SAFARI_TOOLBAR + (tabbed ? SAFARI_TABS : 0) : 0}px`,
      bottom: "0px",
    }),
    Toolbar: SafariToolbar,
  },
  "safari-ios": {
    place: "bottom",
    // The phone's screen, whole: its corners and its edge are the frame's. Its
    // own width is the unit every length is a share of.
    window: "@container absolute inset-0",
    insets: ({ glass }) => ({
      top: pt(IOS.status),
      bottom: glass ? pt(IOS.bar + IOS.barBottom + IOS.gap) : "0px",
    }),
    Toolbar: SafariIosToolbar,
  },
}

// ── a scripted visit ──

export interface BrowserSessionProps
  extends Omit<
    BrowserProps,
    | "url"
    | "address"
    | "editing"
    | "selected"
    | "load"
    | "back"
    | "forward"
    | "scroll"
    | "link"
    | "children"
  > {
  /** The page the visit opens on. */
  start: string
  steps: BrowserStep[]
  /** Every page the visit reaches, by its URL (any spelling `urlOf` reads the same).
   *  A page its server changes between loads (a file saved, then a `reload`) is a
   *  function of the serving: 0 the first load of its address, 1 the next. */
  pages: Record<
    string,
    React.ReactNode | ((serving: number) => React.ReactNode)
  >
  /** The moment drawn, 0..1 of the visit, for a still. Inside a driver (a Player)
   *  omit it: the driver provides the moment. */
  progress?: number
  /** Accessible description (a recording is one picture). */
  alt: string
  /** The address of the page on screen is live (`Browser`'s `link`): the field
   *  opens it, copies it, the share glyph shares it. The rest stays a recording. */
  linked?: boolean
}

/** A visit played on the playhead: the frame at the moment (lib/browser-session)
 *  drawn by `Browser`, the page at its address from `pages`. Display-only, like
 *  every recording here: one picture to assistive tech, its pages inert. Its clip,
 *  for the driver: `browserTimeline(start, steps).clip`. */
export function BrowserSession({
  start,
  steps,
  pages,
  progress,
  alt,
  linked = false,
  ...frame
}: BrowserSessionProps) {
  const timeline = React.useMemo(
    () => browserTimeline(start, steps),
    [start, steps],
  )
  const drawn = React.useMemo(() => {
    const byUrl = new Map(
      Object.entries(pages).map(([u, page]) => [urlOf(u), page]),
    )
    for (const u of timeline.pages)
      if (!byUrl.has(u))
        throw new Error(`browser: the visit reaches ${u}, which pages lacks`)
    return byUrl
  }, [pages, timeline])
  const f = browserFrameAt(timeline, usePlayhead(progress).at * timeline.total)
  const page = drawn.get(f.url)
  const shown = typeof page === "function" ? page(f.serving) : page
  // The page drawn, as it was written, as a real address: what `linked` opens.
  const written = timeline.spelled[f.url] ?? f.url
  const link = linked
    ? /^[a-z][a-z0-9+.-]*:\/\//i.test(written)
      ? written
      : `https://${written}`
    : undefined
  return (
    <Browser
      {...frame}
      // A recording is one picture, said once; linked, a group whose one live
      // part is the address (`link`), the page still a picture inside it.
      role={linked ? "group" : "img"}
      aria-label={alt}
      inert={!linked}
      link={link}
      url={f.url}
      address={f.address}
      editing={f.editing}
      selected={f.selected}
      load={f.load}
      back={f.back}
      forward={f.forward}
      scroll={f.scroll}
    >
      {linked ? (
        <div inert aria-hidden className="contents">
          {shown}
        </div>
      ) : (
        shown
      )}
    </Browser>
  )
}

// ── a live page ──

/** A site in the page, live: framed where it allows it, else its unfurl drawn in
 *  the frame's place with why (a browser cannot tell a refused frame from a loaded
 *  one, so the answer is read beforehand: `mise live-page <url>`, lib/browser-framing).
 *  Put it in a `Browser` whose `url` is the page's. */
export function BrowserLive({ page }: { page: LivePage }) {
  if (page.frames)
    return (
      <iframe
        data-slot="browser-live"
        src={page.url}
        title={hostOf(page.url)}
        loading="lazy"
        sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
        className="block size-full border-0 bg-white"
      />
    )
  return (
    <div
      data-slot="browser-live"
      data-refused=""
      className="flex min-h-full flex-col items-center justify-center gap-4 bg-foreground/[0.03] p-8"
    >
      <WebPreview facts={page.facts} className="w-full max-w-sm" />
      <p className="max-w-sm text-balance text-center font-mono text-[11px] text-foreground/50 lowercase">
        {hostOf(page.url)} keeps itself out of frames ({page.reason}): its
        preview stands in
      </p>
    </div>
  )
}

// ── a captured page ──

/** A site as it looks, captured (`mise browser-shot`, lib/browser-capture): the
 *  picture for the scheme it stands in, as wide as the page, read straight from
 *  the manifest. Put it in a `Browser` (or a session's `pages`). `children` lie
 *  over the picture in the page's own coordinates (lib/browser-capture
 *  `atFound`): a press drawn on a button that is in the picture. */
export function BrowserCapturePage({
  capture,
  sizes = "1024px",
  className,
  children,
}: {
  capture: BrowserCapture
  /** How wide it is drawn, for the loader (`Image`'s `sizes`). */
  sizes?: string
  className?: string
  children?: React.ReactNode
}) {
  return (
    <div
      data-slot="browser-capture"
      className={cn("relative [container-type:inline-size]", className)}
    >
      {(["light", "dark"] as const).map((scheme) => {
        const shot = capture[scheme]
        return (
          <Image
            key={scheme}
            src={shot.src}
            alt=""
            width={shot.width}
            height={shot.height}
            blurDataURL={shot.blurDataURL}
            sizes={sizes}
            className={cn(
              "block h-auto w-full",
              scheme === "light"
                ? "scheme-dark:hidden"
                : "hidden scheme-dark:block",
            )}
          />
        )
      })}
      {children}
    </div>
  )
}
browser-session.ts
// A VISIT, scripted: what a person does in a browser told as steps (type an address,
// follow a link, go back, scroll), timed into a clip any driver moves through, and
// folded into the frame the browser draws at an instant: what the address field
// holds, whether it has focus, how far the page has loaded, which page is drawn and
// how far down it is. The browser (ui/browser) draws the frame; a Player, a scroll
// stage or a fixed progress moves time. Framework-free.
//
// The pacing is one table (TYPE_MS, LOAD_MS, ...): a step's time is decided here and
// nowhere else, so a played visit and a scrubbed one are the same frames.

import type { Chapter, Clip } from "@/registry/base-nova/lib/clip"

/** One thing a person does in the browser, after `delay` ms (default none). */
export type BrowserStep = (
  | /** Click the address field, type `text`, press return: it loads. `to`: the
   *  address redirects (a short link), one load that lands there, the field
   *  turning to `to` as the page comes; never a second load. `pasted`: the
   *  start of `text` arrives at once, pasted, and only the rest is typed (an
   *  address known by heart, a route added to it: `👤.to/ada` pasted, `/gh`
   *  typed). */
  { kind: "type"; text: string; to?: string; pasted?: string }
  /** A link followed on the page: the address changes and `url` loads. */
  | { kind: "go"; url: string }
  /** The back button: the page before loads again. */
  | { kind: "back" }
  /** The reload button: the page loads again from its server, which may serve it
   *  changed (a file saved since): the frame's `serving` counts it. */
  | { kind: "reload" }
  /** The page scrolled to `to`, 0 (its top) to 1 (its foot), over `ms`. */
  | { kind: "scroll"; to: number; ms?: number }
) & { delay?: number }

/** One character typed into the address field. A person typing a host they know,
 *  slower than the terminal's 28 ms, which reads as a machine. */
export const TYPE_MS = 70
/** The field takes focus and its address is selected before the first key. */
const FOCUS_MS = 380
/** A paste landing in the field, before the first key after it. */
const PASTE_MS = 240
/** Return pressed after the last key. */
const RETURN_MS = 260
/** A page loading, from the request to the bar's end; a page from the cache (back)
 *  loads in a third of it. */
export const LOAD_MS = 1200
const CACHED_MS = 400
/** The share of a load at which the new page is drawn: the old one stays until the
 *  new one has something to show, as a browser commits a navigation. */
const COMMIT = 0.45
/** The bar fades after it fills. */
const FADE_MS = 220
const SCROLL_MS = 900
/** The last page held on screen after the last step, so it is seen. */
const HOLD_MS = 1400

/** A load in progress: how far the bar has filled, 0..1, and how visible it is
 *  (it fades out once full). */
export interface BrowserLoad {
  progress: number
  opacity: number
}

/** The browser at an instant. */
export interface BrowserFrame {
  /** The page drawn: its URL, as `urlOf` spells it. */
  url: string
  /** What the address field holds: the text being typed while it has focus, else
   *  the address of the page loading or shown. */
  address: string
  /** The field has focus (a person is typing in it). */
  editing: boolean
  /** Focused and not yet typed in: the whole address is selected, so the first key
   *  replaces it. */
  selected: boolean
  load: BrowserLoad | null
  /** How far down the drawn page is, 0..1. */
  scroll: number
  /** Which load of the drawn page's address is shown, 0 the first: each typed,
   *  followed or reloaded load of it counts one, back (from the cache) none. A page
   *  whose server changes between loads draws the serving it is on. */
  serving: number
  /** Whether back and forward have anywhere to go. */
  back: boolean
  forward: boolean
  /** The step under way or last done, -1 before the first. */
  step: number
}

export interface BrowserTimeline {
  start: string
  steps: BrowserStep[]
  /** When each step begins (after its delay) and ends, ms. */
  starts: number[]
  ends: number[]
  total: number
  /** Every page the visit draws, in the order it reaches them, the start first. */
  pages: string[]
  /** Each page's address as it was written (typed, linked, or the start), by its
   *  URL: what the field shows at rest. Never the URL's own spelling, which turns a
   *  host in other scripts into punycode (👤.to into xn--mq8h.to). */
  spelled: Record<string, string>
  /** The visit as a clip: one chapter per page, titled with its address. */
  clip: Clip
}

/** A URL as the browser keeps it: a bare host typed gets https, the result is
 *  absolute and normalised (a trailing slash on a bare host). The one spelling the
 *  timeline, the pages a visit draws and the address field agree on. */
export function urlOf(text: string): string {
  return new URL(/^[a-z][a-z0-9+.-]*:/i.test(text) ? text : `https://${text}`)
    .href
}

/** Where a typed address or a followed link goes. */
const destination = (
  s: Extract<BrowserStep, { kind: "type" } | { kind: "go" }>,
): string => urlOf(s.kind === "type" ? (s.to ?? s.text) : s.url)

/** A type step's keys after the focus: a paste (if any), then each key typed. */
function keysMs(s: Extract<BrowserStep, { kind: "type" }>): number {
  const pasted = s.pasted?.length ?? 0
  return (pasted > 0 ? PASTE_MS : 0) + (s.text.length - pasted) * TYPE_MS
}

function stepMs(s: BrowserStep): number {
  switch (s.kind) {
    case "type":
      return FOCUS_MS + keysMs(s) + RETURN_MS + LOAD_MS
    case "go":
      return LOAD_MS
    case "back":
      return CACHED_MS
    case "reload":
      return LOAD_MS
    case "scroll":
      return s.ms ?? SCROLL_MS
  }
}

/** Times a visit that opens on `start`. THROWS on a scroll outside 0..1, a delay
 *  below zero, a back with nowhere to go: a script that cannot happen. */
export function browserTimeline(
  start: string,
  steps: BrowserStep[],
): BrowserTimeline {
  const starts: number[] = []
  const ends: number[] = []
  const spelled: Record<string, string> = { [urlOf(start)]: start }
  for (const s of steps)
    if (s.kind === "type" || s.kind === "go")
      spelled[destination(s)] = s.kind === "type" ? (s.to ?? s.text) : s.url
  // A chapter is titled as the address was written (a typed one as it was typed,
  // the short link a redirect starts from), never through `URL`'s punycode.
  const chapters: Chapter[] = [{ start: 0, title: bare(start) }]
  const history = [urlOf(start)]
  const reached = [...history]
  let here = 0
  let at = 0
  for (const s of steps) {
    if ((s.delay ?? 0) < 0)
      throw new Error(`browser-session: delay ${s.delay} is below zero`)
    if (s.kind === "scroll" && (s.to < 0 || s.to > 1))
      throw new Error(`browser-session: scroll to ${s.to} is outside 0..1`)
    if (s.kind === "type" && s.pasted && !s.text.startsWith(s.pasted))
      throw new Error(
        `browser-session: "${s.pasted}" is pasted, but "${s.text}" does not start with it`,
      )
    at += s.delay ?? 0
    starts.push(at)
    if (s.kind !== "scroll") {
      if (s.kind === "back") {
        if (here === 0)
          throw new Error("browser-session: back with no page before")
        here--
      } else if (s.kind === "reload") {
        // The same page, in the same place in the history.
      } else {
        history.splice(here + 1, Infinity, destination(s))
        here++
      }
      reached.push(history[here] as string)
      const page = history[here] as string
      chapters.push({
        start: at,
        title: bare(s.kind === "type" ? s.text : (spelled[page] ?? page)),
        code: s.kind === "type",
      })
    }
    at += stepMs(s)
    ends.push(at)
  }
  const total = at + HOLD_MS
  // A visit that navigates at once never shows its opening page as a chapter.
  const opening = chapters[1]?.start === 0 ? chapters.slice(1) : chapters
  return {
    start: urlOf(start),
    steps,
    starts,
    ends,
    total,
    pages: [...new Set(reached)],
    spelled,
    clip: { duration: total, chapters: opening },
  }
}

/** An address as written, its scheme, `www.` and a trailing slash dropped. */
function bare(address: string): string {
  return address
    .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
    .replace(/^www\./, "")
    .replace(/\/$/, "")
}

const clamp01 = (n: number) => Math.min(1, Math.max(0, n))
const easeInOut = (t: number) =>
  t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
/** A load's bar: quick at first, slowing toward the end, as a bar that cannot know
 *  how much is left. */
const loadCurve = (t: number) => 1 - (1 - t) ** 2.2

/** The frame at `ms`: every step done so far applied, the one under way partly. */
export function browserFrameAt(
  timeline: BrowserTimeline,
  ms: number,
): BrowserFrame {
  const history = [timeline.start]
  let here = 0
  let url = timeline.start
  let address = timeline.spelled[url] ?? url
  let editing = false
  let selected = false
  let load: BrowserLoad | null = null
  let scroll = 0
  let step = -1
  /** How many times each address has loaded, the start once. */
  const loads: Record<string, number> = { [timeline.start]: 0 }
  // A finished load's bar still fading at `ms`.
  const fading = (end: number): BrowserLoad | null =>
    ms < end + FADE_MS
      ? { progress: 1, opacity: 1 - (ms - end) / FADE_MS }
      : null

  for (const [i, s] of timeline.steps.entries()) {
    const from = timeline.starts[i] as number
    const to = timeline.ends[i] as number
    if (ms < from) break
    step = i
    const t = ms >= to ? 1 : (ms - from) / (to - from)
    if (s.kind === "scroll") {
      const was = scroll
      scroll = was + (s.to - was) * easeInOut(t)
      continue
    }
    // A navigation: where it goes, and when its load starts.
    let loadFrom = from
    if (s.kind === "type") {
      const focused = from + FOCUS_MS
      const pasted = s.pasted?.length ?? 0
      // The first key after the paste (or after the focus, with none).
      const typedFrom = focused + (pasted > 0 ? PASTE_MS : 0)
      loadFrom = focused + keysMs(s) + RETURN_MS
      if (ms < loadFrom) {
        editing = true
        selected = ms < focused
        if (!selected)
          address =
            ms < typedFrom
              ? (s.pasted ?? "")
              : s.text.slice(
                  0,
                  pasted + Math.floor((ms - typedFrom) / TYPE_MS) + 1,
                )
        break
      }
    }
    const target =
      s.kind === "back"
        ? (history[here - 1] as string)
        : s.kind === "reload"
          ? (history[here] as string)
          : destination(s)
    if (s.kind === "back") here--
    else if (s.kind !== "reload") {
      history.splice(here + 1, Infinity, target)
      here++
    }
    const p = clamp01((ms - loadFrom) / (to - loadFrom))
    // A redirect shows the address typed while it loads, and where it landed once
    // the page comes: one load, the field turning as the page arrives.
    address =
      s.kind === "type" && s.to !== undefined && p < COMMIT
        ? s.text
        : (timeline.spelled[target] ?? target)
    if (p >= COMMIT) {
      // A reload keeps the place on the page, as a browser restores it.
      if (url !== target) scroll = 0
      url = target
      // Back draws the page from the cache: the serving it last showed.
      if (s.kind !== "back") loads[target] = (loads[target] ?? -1) + 1
    }
    load = p < 1 ? { progress: loadCurve(p), opacity: 1 } : fading(to)
  }
  return {
    url,
    address,
    editing,
    selected,
    load,
    scroll,
    serving: loads[url] as number,
    back: here > 0,
    forward: here < history.length - 1,
    step,
  }
}
browser-capture.ts
// A SITE AS IT LOOKS, CAPTURED: the manifest `mise browser-shot` writes beside its
// pictures (`<out>.json`) and a page imports as it is, the one spelling of a
// capture on both sides. The page in both schemes as `MediaAsset`s whose `src` is
// already the address it is served at, its content's hash in it (`?v=`), so a
// page captured again is a new address and nothing builds a path by hand; and the
// places of things found on it (`--find`), for a story to press a button drawn in
// the picture. Framework-free: a build script reads it as well as a component.

import type { MediaAsset } from "@/registry/base-nova/lib/media-asset"

/** Where a thing is on the captured page, CSS px from its top-left corner. */
export interface CaptureBox {
  x: number
  y: number
  width: number
  height: number
}

export interface BrowserCapture {
  /** The page captured. */
  url: string
  /** The page's width in CSS px: what every found box is measured against. */
  width: number
  /** Things found on it by name (`--find save=…`). */
  found?: Record<string, CaptureBox>
  dark: MediaAsset
  light: MediaAsset
}

/** One page a site captures, as its declaration says it (`CapturePlan`). */
export interface CaptureSpec {
  url: string
  /** On a phone (its mobile layout, one screen tall), else a desktop `width`. */
  phone?: boolean
  /** CSS px (1280), a desktop's. */
  width?: number
  /** How many screens down it is captured (2.5 on a desktop, 1 on a phone). */
  tall?: number
  /** Things the page must show, by name and selector: their places are kept
   *  (`found`), and the capture fails when one is missing (an old deploy). */
  find?: Record<string, string>
  /** A page that may refuse a headless browser (X does): left out, its reason
   *  kept in the manifest's `missing`, instead of failing the run. */
  optional?: boolean
}

/** A SITE'S CAPTURES AS CODE: what `mise capture <file>` (the registry's checkout)
 *  makes. Its pictures go to `folder` (under the site's `public/`, served at
 *  its root), its manifest to `manifest`, which the site imports as it is. Run
 *  again, it makes the same thing: a page unchanged keeps its address (the
 *  content's hash), a page no longer declared loses its files. The file's
 *  default export is the plan, or a function making it (a plan read from
 *  the site's own data). */
export interface CapturePlan<Meta = Record<string, never>> {
  folder: string
  manifest: string
  /** What the site keeps with the captures (whose they are). */
  meta: Meta
  shots: Record<string, CaptureSpec>
}

/** What `mise capture` writes: the plan's `meta`, each shot by its name, and
 *  each optional shot that was refused with why. */
export interface CaptureManifest<Meta = Record<string, never>> {
  meta: Meta
  captures: Record<string, BrowserCapture>
  missing: Record<string, string>
}

/** The thing called `name` on the page. Screams when the capture did not look
 *  for it: a story pointing at nothing is wrong, never empty. */
export function foundOn(capture: BrowserCapture, name: string): CaptureBox {
  const box = capture.found?.[name]
  if (!box)
    throw new Error(
      `browser-capture: nothing called ${name} was found on ${capture.url}; capture it with --find ${name}=<selector>`,
    )
  return box
}

/** Where a found thing is, as the style that lays a box exactly over it inside
 *  `BrowserCapturePage` (@ag/browser): shares of the page's width (`cqw` of the
 *  page, its container), so it stays on the thing at any size the picture is
 *  drawn. Plain values, so a server component computes it as well. */
export function atFound(
  capture: BrowserCapture,
  name: string,
): {
  position: "absolute"
  left: string
  top: string
  width: string
  height: string
} {
  const box = foundOn(capture, name)
  const share = (px: number) => `${(px / capture.width) * 100}cqw`
  return {
    position: "absolute",
    left: share(box.x),
    top: share(box.y),
    width: share(box.width),
    height: share(box.height),
  }
}

/** A found thing's middle, CSS px on the page: where a finger presses it. */
export function middleOf(box: CaptureBox): { x: number; y: number } {
  return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
browser-framing.ts
// WHETHER A SITE CAN BE SHOWN LIVE IN A FRAME, and what to show when it cannot. A
// page put in an <iframe> is drawn only if its server allows it: `X-Frame-Options`
// (DENY, SAMEORIGIN) or the newer `Content-Security-Policy: frame-ancestors`, which
// wins over it where both are sent. A refusing page is not an error the embedding page
// can see: the frame draws the browser's own blank or error page, its load event fires
// all the same, and its document is another origin's, so nothing in the browser can
// tell a refused frame from a loaded one. The answer is in the response headers, which
// only a server or a CLI can read (a browser's fetch of another origin is CORS-blocked
// and never sees them). So it is read where the network is, once, at authoring time
// (`mise live-page <url>`), the way a link's unfurl is, and ships as data: a
// `LivePage` the browser draws as a live frame, or, for a site that refuses, as its
// unfurled facts. A server that wants it live imports the same function; it asks the
// public internet only (`publicFetch`), whoever named the address.
//
// Framework-free: fetch and string work, on a server.

import { publicFetch } from "@/registry/base-nova/lib/public-fetch"
import { hrefOf, type Unfurl } from "@/registry/base-nova/lib/web-preview-facts"
import { unfurl } from "@/registry/base-nova/lib/web-preview-unfurl"

/** A page for the browser to show live: framed, or, refused, drawn from its facts. */
export type LivePage =
  | { url: string; frames: true }
  | {
      url: string
      frames: false
      /** Why, as the header said it (`x-frame-options: deny`). */
      reason: string
      /** The page's unfurl, drawn in its place. */
      facts: Unfurl
    }

export type Framing = { frames: true } | { frames: false; reason: string }

/** One source of a `frame-ancestors` list matched against the origin that frames. */
function admits(source: string, origin: URL): boolean {
  if (source === "*") return true
  if (/^[a-z][a-z0-9+.-]*:$/i.test(source)) return origin.protocol === source
  const m = /^(?:([a-z][a-z0-9+.-]*):\/\/)?(\*\.)?([^/:]+)(?::(\d+|\*))?/i.exec(
    source,
  )
  if (!m) return false
  const [, scheme, wild, host, port] = m
  if (scheme && `${scheme}:` !== origin.protocol) return false
  if (port && port !== "*" && port !== origin.port) return false
  return wild ? origin.hostname.endsWith(`.${host}`) : origin.hostname === host
}

/** Whether `url` may be framed by a page at `origin` (absent: any foreign origin,
 *  so only a page that allows every origin frames). Reads the headers of the page as
 *  served after redirects. THROWS on a page that does not answer 2xx: a frame of an
 *  error page is no live page. */
export async function framing(url: string, origin?: string): Promise<Framing> {
  const href = hrefOf(url)
  const res = await publicFetch(href, {
    headers: {
      "user-agent":
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
      accept: "text/html,application/xhtml+xml",
    },
  })
  if (!res.ok) throw new Error(`framing ${href}: HTTP ${res.status}`)
  await res.body?.cancel()
  const page = new URL(res.url || href)
  const by = origin ? new URL(origin) : null
  // CSP's frame-ancestors decides where it is sent; X-Frame-Options only where not.
  const csp = res.headers.get("content-security-policy")
  const ancestors = csp
    ?.split(/[;,]/)
    .map((d) => d.trim().split(/\s+/))
    .find((d) => d[0]?.toLowerCase() === "frame-ancestors")
  if (ancestors) {
    const sources = ancestors.slice(1)
    const said = `frame-ancestors ${sources.join(" ")}`
    if (sources.includes("'none'")) return { frames: false, reason: said }
    const ok = sources.some((s) =>
      s === "'self'"
        ? by?.origin === page.origin
        : s === "*" || (by !== null && admits(s, by)),
    )
    return ok ? { frames: true } : { frames: false, reason: said }
  }
  const xfo = res.headers.get("x-frame-options")?.trim().toLowerCase()
  if (xfo === "deny") return { frames: false, reason: "x-frame-options: deny" }
  if (xfo === "sameorigin" && by?.origin !== page.origin)
    return { frames: false, reason: "x-frame-options: sameorigin" }
  if (xfo?.startsWith("allow-from"))
    return { frames: false, reason: `x-frame-options: ${xfo}` }
  return { frames: true }
}

/** A page ready for the browser: framed when it allows `origin`, else its unfurl. */
export async function livePage(
  url: string,
  origin?: string,
): Promise<LivePage> {
  const href = hrefOf(url)
  const f = await framing(href, origin)
  return f.frames
    ? { url: href, frames: true }
    : { url: href, frames: false, reason: f.reason, facts: await unfurl(href) }
}