← ui

device-frame

ui

iPhone, MacBook and Studio Display frames for any content, the lid folding

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

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

Latest changes · 2026-10-15
  • The MacBook's display clips with clip-path instead of overflow: hidden. Its corners are uneven (rounder at the top), and Chrome clips such a box through a mask of its own, which made a glass Panel on the screen lose its spill's mask and blur everything inside it.
Updated 2026-10-15 · release notes

also called mockup

shelf devices & demos

uses thermal

used by macos, telegram-chat

the demo above, verbatim

device-frame.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import type { Clip } from "@/registry/base-nova/lib/clip"
import {
  type ThermalKey,
  type ThermalReading,
  thermalAt,
} from "@/registry/base-nova/lib/thermal"
import { Button } from "@/registry/base-nova/ui/button"
import {
  IphoneFrame,
  MacbookFrame,
  StudioDisplayFrame,
} from "@/registry/base-nova/ui/device-frame"
import { usePlayhead } from "@/registry/base-nova/ui/playback"
import { Player } from "@/registry/base-nova/ui/player"

function Screen({ label }: { label: string }) {
  return (
    <div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(ellipse_at_25%_25%,#315c6c,transparent_65%),linear-gradient(145deg,#182b38,#111318)] text-white">
      <span className="font-mono text-sm">{label}</span>
    </div>
  )
}

export default function DeviceFrameDemo() {
  const [closed, setClosed] = React.useState(false)
  return (
    <div className="space-y-16">
      <Sample name="devices" title="three frames, any content">
        <div className="grid w-full items-center gap-12 p-8 md:grid-cols-[1fr_2fr]">
          <div className="mx-auto w-full max-w-56">
            <IphoneFrame>
              <Screen label="your app" />
            </IphoneFrame>
          </div>
          <div className="space-y-12">
            <MacbookFrame>
              <Screen label="your workspace" />
            </MacbookFrame>
            <StudioDisplayFrame>
              <Screen label="your canvas" />
            </StudioDisplayFrame>
          </div>
        </div>
      </Sample>
      <Sample
        name="lid"
        glance
        title="lid"
        description="the frame moves it, you say where"
      >
        <div className="mx-auto w-64 space-y-4">
          <MacbookFrame lid={closed ? "closed" : "open"}>
            <Screen label="your workspace" />
          </MacbookFrame>
          <Button
            variant="outline"
            className="mx-auto flex"
            pressed={closed}
            onClick={() => setClosed((c) => !c)}
            on="open the lid"
            off="close the lid"
          />
        </div>
      </Sample>
      <Sample
        name="positions"
        title="a number for a caller"
        description="that owns time: 0 · 0.5 · 1"
      >
        <div className="grid gap-8 p-8 md:grid-cols-3">
          {[0, 0.5, 1].map((lid) => (
            <MacbookFrame key={lid} lid={lid}>
              <Screen label={`lid ${lid}`} />
            </MacbookFrame>
          ))}
        </div>
      </Sample>
      <Heat />
    </div>
  )
}

// ── heat: the case warms, inside, chill takes the fans, the case cools ──
//
// A thermal track (lib/thermal), dramatized for this page: readings and depths
// keyed in ms, folded by `thermalAt` at the player's moment. A scene does the
// same with `thermal` steps (macos-session); a real app hands its own readings.

const CHILL = 9000

const reading = (
  cpu: number,
  gpu: number,
  ssd: number,
  battery: number,
  surface: number,
  fan: number,
): ThermalReading => ({
  parts: { cpu, gpu, ssd, battery },
  surface,
  fans: [fan],
})

// The case: cool, hot under Apple's fans, then cooler than it started.
const TRACK: ThermalKey[] = [
  { at: 0, reading: reading(52, 48, 38, 31, 33, 0.06), depth: 1 },
  { at: 2000, reading: reading(96, 80, 41, 33, 38, 0.06) },
  { at: 4500, reading: reading(97, 83, 45, 39, 45, 0.07), depth: 2 },
  { at: CHILL, reading: reading(97, 84, 47, 41, 46, 0.08) },
  { at: CHILL + 1000, reading: reading(90, 80, 46, 40, 45, 1) },
  { at: 13000, reading: reading(72, 62, 41, 34, 36, 0.9) },
  { at: 15000, reading: reading(70, 60, 40, 33, 31, 0.85), depth: 1 },
  { at: 20000, reading: reading(70, 60, 40, 32, 30, 0.8) },
]

const CLIP: Clip = {
  duration: 20000,
  chapters: [
    { start: 0, title: "under load, the case heats up" },
    { start: 4500, title: "inside, Apple's fans barely turn" },
    { start: CHILL, title: "chill takes the fans" },
    { start: 15000, title: "the case, cool again" },
  ],
}

function Story() {
  const { at } = usePlayhead(undefined)
  const ms = at * CLIP.duration
  const m = thermalAt(TRACK, ms)
  if (!m) throw new Error("the track has no reading")
  const p = m.parts
  return (
    <div className="space-y-4">
      <MacbookFrame thermal={{ ...m, t: ms / 1000 }}>
        <Screen label={ms < CHILL ? "apple" : "chill"} />
      </MacbookFrame>
      <p className="text-center font-mono text-muted-foreground text-xs tabular-nums">
        cpu {p.cpu?.toFixed(0)} · gpu {p.gpu?.toFixed(0)} · ssd{" "}
        {p.ssd?.toFixed(0)} · battery {p.battery?.toFixed(0)} · skin{" "}
        {m.surface?.toFixed(0)} °C · fans {Math.round((m.fans[0] ?? 0) * 100)}%
      </p>
    </div>
  )
}

function Heat() {
  const hottest = thermalAt(TRACK, CHILL)
  if (!hottest) throw new Error("the track has no reading")
  return (
    <>
      <Sample
        name="heat"
        title="thermal"
        description="a track at the playhead: the skin, inside, chill takes the fans, the skin again"
      >
        <div className="mx-auto max-w-3xl p-8">
          <Player clip={CLIP} label="chill cools a hot Mac">
            <Story />
          </Player>
        </div>
      </Sample>
      <Sample
        name="depth"
        title="depth"
        description="0 the machine · 1 its skin · 2 inside, at the hottest instant"
      >
        <div className="grid gap-8 p-8 md:grid-cols-3">
          {[0, 1, 2].map((depth) => (
            <MacbookFrame
              key={depth}
              thermal={{ ...hottest, depth, t: CHILL / 1000 }}
            >
              <Screen label={`depth ${depth}`} />
            </MacbookFrame>
          ))}
        </div>
      </Sample>
    </>
  )
}

the source, every file an install writes

device-frame.tsx
import type * as React from "react"
import type { ComponentProps, ReactNode } from "react"
import { cn } from "@/lib/utils"
import {
  fanTurnRate,
  type ThermalDepth,
  type ThermalPart,
  type ThermalReading,
} from "@/registry/base-nova/lib/thermal"
import { DeviceAirflow } from "@/registry/base-nova/ui/device-airflow"
import "./device-frame.css"

type DesktopFrameProps = ComponentProps<"div"> & { screenClassName?: string }

/** Where a MacBook's lid is. `"open"` / `"closed"`: the frame moves the lid itself,
 *  on its own curve, whenever the value changes. A number (0 open .. 1 shut): the
 *  lid sits exactly there and nothing animates, for a caller that owns time (a
 *  scrubbed or filmed timeline, where every frame must be exact). */
export type MacbookLid = "open" | "closed" | number

/** How hot the machine runs, for the frame to show where it is: a reading
 *  (lib/thermal, what the machine reports; the frame maps it, the caller never
 *  picks a colour) and how to show it. A driver with a track hands over
 *  `thermalAt`'s moment as is. */
export interface MacbookThermal extends ThermalReading {
  /** Seconds, for a caller that owns time (a filmed or scrubbed timeline): the
   *  fans and the air are drawn at that instant. It is the film's clock, the
   *  one the viewer watches, never a story clock running faster than it: a
   *  story that plays a minute of heat in six seconds still turns its fans at
   *  their own speed. Omitted, they move on their own. */
  t?: number
  /** How many turns each fan has made by `t`, for a held timeline whose fans
   *  change speed. A fan's position (its slats, the air it has thrown) is
   *  everything it has turned so far, never its speed now times `t`, which
   *  jumps whenever the speed does (`thermalAt` adds up fanTurnRate over its
   *  track). Omitted, each fan is taken as having always turned at its speed
   *  at `t` (exact while the fans hold steady). */
  turns?: number[]
  /** How deep the view goes (ThermalDepth), so a story can move through it
   *  mid-scene: 0 the plain machine, 1 its skin (the case's heat, what a palm
   *  feels: cool reads ice, hot reads ember), 2 the insides (parts, fans, the
   *  air), the skin receding behind them and the body's outer details with it.
   *  Live (no `t`), a change fades over a moment; held (`t` given), the number
   *  is exact. Default 2. */
  depth?: ThermalDepth
}

const clamp01 = (v: number) => Math.min(1, Math.max(0, v))

/** Each fan's share, left and right; one reported fan drives both. */
const fanShares = (thermal: MacbookThermal) =>
  [0, 1].map((i) => clamp01(thermal.fans[i] ?? thermal.fans[0] ?? 0))

/** The range each part is painted over, °C: from where it shows to its limit. */
const RANGE: Record<ThermalPart, [number, number]> = {
  cpu: [45, 100],
  gpu: [45, 100],
  memory: [45, 95],
  ssd: [35, 70],
  battery: [30, 50],
}

const heat = (part: ThermalPart, c: number | undefined) => {
  if (c === undefined) return 0
  const [from, to] = RANGE[part]
  return Math.min(1, Math.max(0, (c - from) / (to - from)))
}

/** chill's palette: ice cool, ember warm, red at the limit. */
const ICE = [140, 196, 255]
const EMBER = [255, 140, 64]
const RED = [255, 64, 52]
function tone(h: number): string {
  const [a, b, k] =
    h < 0.7 ? [ICE, EMBER, h / 0.7] : [EMBER, RED, (h - 0.7) / 0.3]
  return `rgb(${a.map((v, i) => Math.round(v + ((b[i] as number) - v) * k)).join(" ")})`
}

/** A 16:10 laptop display with a shallow base. `lid` puts it on a hinge (see
 *  MacbookLid): the lid's face turns in perspective and its thickness is painted
 *  where the same camera puts it (device-frame.css), so shut it is the closed
 *  laptop's front, resting on the base. Without `lid` the frame is flat 2D.
 *  `thermal` shows the machine's insides flat in the base's face, where they
 *  sit (MacbookThermal, MacbookInternals). */
export function MacbookFrame({
  children,
  className,
  screenClassName,
  notch = true,
  lid,
  thermal,
  style,
  ...props
}: DesktopFrameProps & {
  notch?: boolean
  lid?: MacbookLid
  thermal?: MacbookThermal
}) {
  const shut =
    lid === undefined
      ? undefined
      : typeof lid === "number"
        ? Math.min(1, Math.max(0, lid))
        : lid === "closed"
          ? 1
          : 0
  const vars: Record<string, number> = {}
  if (shut !== undefined) vars["--device-lid"] = shut
  if (thermal) {
    const depth = Math.min(2, Math.max(0, thermal.depth ?? 2))
    const inside = clamp01(depth - 1)
    vars["--device-skin"] = Math.min(1, depth) * (1 - inside * 0.6)
    vars["--device-xray"] = inside
  }
  return (
    <div
      {...props}
      data-device="macbook"
      data-hinged={lid === undefined ? undefined : ""}
      data-lid-motion={typeof lid === "string" ? "" : undefined}
      data-thermal={
        thermal ? (thermal.t === undefined ? "live" : "held") : undefined
      }
      className={cn("device-desktop", className)}
      style={{ ...style, ...vars } as React.CSSProperties}
    >
      <div className="device-lid">
        <div className={cn("device-display", screenClassName)}>{children}</div>
        {notch && <span aria-hidden className="device-notch" />}
        <span aria-hidden className="device-camera" />
      </div>
      <div aria-hidden className="device-base">
        {thermal?.surface !== undefined && (
          <MacbookSkin surface={thermal.surface} fans={fanShares(thermal)} />
        )}
        {thermal && <MacbookInternals thermal={thermal} />}
      </div>
      {lid !== undefined && (
        <span aria-hidden className="device-lid-rim">
          {thermal?.surface !== undefined && (
            <span
              className="device-lid-skin"
              style={{
                background: lidSkin(thermal.surface, fanShares(thermal)),
              }}
            />
          )}
        </span>
      )}
      <span aria-hidden className="device-rubber left" />
      <span aria-hidden className="device-rubber right" />
    </div>
  )
}

/** The base's outline in its own units: corners 1.2 on top, 25 below (its
 *  border-radius, 0.12 and 2.5 cqw). */
const SHELL =
  "M 1.7 0.5 H 998.3 Q 999.5 0.5 999.5 1.7 V 12 A 25 24.5 0 0 1 974.5 36.5 H 25.5 A 25 24.5 0 0 1 0.5 12 V 1.7 Q 0.5 0.5 1.7 0.5 Z"

/** The skin's heat across the machine, left to right (0..1 of its width): from
 *  a cool room's 26 °C to hot at 46, so every degree a case gains or sheds
 *  shows, down to the last of its cooling; hottest over the chip and the
 *  battery under it, coolest where the fans move air. One reading for every
 *  face that shows it: the base's, and a shut lid's, which a bag heats as
 *  much. */
function skinStops(surface: number, fans: number[]) {
  const skin = clamp01((surface - 26) / 20)
  const airy = (side: number) => 1 - (fans[side] as number) * 0.45
  const weights: [number, number][] = [
    [0, airy(0) * 0.6],
    [0.16, airy(0) * 0.75],
    [0.356, 0.85],
    [0.5, 1],
    [0.644, 0.85],
    [0.84, airy(1) * 0.75],
    [1, airy(1) * 0.6],
  ]
  return weights.map(([offset, weight]) => {
    const heat = skin * weight
    return { offset, heat, alpha: 0.3 + heat * 0.7 }
  })
}

/** The shut lid's face, warm with the same skin: drawn over the lid's rim,
 *  which is the closed laptop's front. */
function lidSkin(surface: number, fans: number[]): string {
  const stops = skinStops(surface, fans).map(
    (s) =>
      `color-mix(in srgb, ${tone(s.heat)} ${Math.round(s.alpha * 30)}%, transparent) ${s.offset * 100}%`,
  )
  return `linear-gradient(90deg, ${stops.join(", ")})`
}

/** The skin (MacbookThermal.surface), the first level of the view: the
 *  base's face as a thermal camera sees the case, ice where it is cool and
 *  ember as it warms, hottest over the chip and the battery under it,
 *  coolest where the fans move air. Always drawn when shown, so a cooled case
 *  reads cool rather than blank. */
function MacbookSkin({ surface, fans }: { surface: number; fans: number[] }) {
  const stops = skinStops(surface, fans)
  const id = `device-skin-${stops.map((s) => Math.round(s.heat * 100)).join("-")}`
  return (
    <svg aria-hidden className="device-skin" viewBox="0 0 1000 37">
      <defs>
        <linearGradient
          id={id}
          gradientUnits="userSpaceOnUse"
          x1="0"
          y1="0"
          x2="1000"
          y2="0"
        >
          {stops.map((s) => (
            <stop
              key={`skin-${s.offset}`}
              offset={s.offset}
              stopColor={tone(s.heat)}
              stopOpacity={s.alpha}
            />
          ))}
        </linearGradient>
      </defs>
      <path
        d={SHELL}
        fill={`url(#${id})`}
        fillOpacity={0.3}
        stroke={`url(#${id})`}
      />
    </svg>
  )
}

/** The machine's insides as the front view sees them: a side elevation
 *  through the base's face (a 14″ MacBook Pro). It is a thin slab, so every
 *  part sits in one band and parts at different depths overlap where they
 *  share an x: the board and the chip at the back, the fans at the ends, the
 *  battery's cells translucent in front. Each is a hairline in the frame's own
 *  neutral so the aluminium keeps its tone.
 *
 *  The picture is how it cools, told plainly rather than to the millimetre.
 *  Each fan is a blower seen edge-on (its housing, its impeller drum with
 *  slats sweeping across on a cosine, near ones bright, far ones faint). The
 *  air is a fine, subtle fluid: each fan pushes it cool along the base toward
 *  the middle, over the components; it takes their heat (its colour warms
 *  over the battery, most over the chip) and leaves along the whole top at
 *  the hinge. Denser and quicker as a fan turns faster, still when it stops.
 *  The parts' own heat is a faint glow, never the subject. Drawn in the
 *  base's own units (1000 × 37, its 100 × 3.7 cqw), so nothing is stretched. */
function MacbookInternals({ thermal }: { thermal: MacbookThermal }) {
  const p = thermal.parts
  const h = {
    cpu: heat("cpu", p.cpu),
    gpu: heat("gpu", p.gpu),
    memory: heat("memory", p.memory),
    ssd: heat("ssd", p.ssd),
    battery: heat("battery", p.battery),
  }
  const chip = Math.max(h.cpu, h.gpu)
  const fans = fanShares(thermal)
  const rates = fans.map(fanTurnRate)
  const t = thermal.t
  const turns =
    t === undefined
      ? undefined
      : rates.map((rate, i) => thermal.turns?.[i] ?? rate * t)
  // Ids named by what they hold: frames on one page share a definition when
  // it is the same and never collide when it is not, with no hook (the frame
  // stays a server component).
  const id = `device-${tone(chip).replace(/\W/g, "")}`
  // The band every part shares, clear of the lip notch at the top centre
  // (its foot is at 13): the board along its foot, the chip on the board.
  const top = 15
  const bottom = 31
  const board = 29
  const glow = (cx: number, cy: number, rx: number, ry: number, v: number) =>
    v > 0 && (
      <ellipse
        key={`g${cx}-${cy}`}
        cx={cx}
        cy={cy}
        rx={rx}
        ry={ry}
        fill={tone(v)}
        opacity={0.04 + v * 0.26}
        filter={`url(#${id}-heat)`}
      />
    )
  // Inset from the corners, as a 14″'s are.
  const FAN = [160, 840]
  const SLATS = 18
  return (
    <span
      className="device-internals"
      data-held={t === undefined ? undefined : ""}
    >
      <svg aria-hidden className="device-internals-art" viewBox="0 0 1000 37">
        <defs>
          <filter id={`${id}-heat`} x="-1" y="-2" width="3" height="5">
            <feGaussianBlur stdDeviation="6" />
          </filter>
        </defs>

        {/* heat, under everything drawn */}
        {glow(500, 25, 60, 10, chip)}
        {glow(445, 26, 12, 8, h.memory)}
        {glow(555, 26, 12, 8, h.memory)}
        {glow(595, 27, 18, 7, h.ssd)}
        {glow(356, 23, 110, 7, h.battery)}
        {glow(644, 23, 110, 7, h.battery)}

        {/* the back: the chip (cpu | gpu), memory beside it, the ssd */}
        <g className="device-parts">
          <rect x={455} y={board - 8} width={90} height={8} rx={1} />
          <line x1={500} y1={board - 8} x2={500} y2={board} />
          <rect x={438} y={board - 6} width={14} height={6} rx={0.8} />
          <rect x={548} y={board - 6} width={14} height={6} rx={0.8} />
          <rect x={580} y={board - 3.5} width={30} height={3.5} rx={0.8} />
        </g>
        <g className="device-labels">
          <text x={477.5} y={board - 4}>
            cpu
          </text>
          <text x={522.5} y={board - 4}>
            gpu
          </text>
        </g>

        {/* each fan, a blower edge-on, its slats turning (the two mirror) */}
        {fans.map((fan, side) => {
          const cx = FAN[side] as number
          const dir = side === 0 ? 1 : -1
          // A still fan keeps a rate of 1 only so its pace stays defined; its
          // slats are paused.
          const motion = {
            "--rate": rates[side] || 1,
            "--turns": turns?.[side] ?? 0,
            "--dir": dir,
          } as React.CSSProperties
          return (
            <g key={`fan-${cx}`} style={motion}>
              <g className="device-parts">
                {/* the housing */}
                <rect x={cx - 72} y={17} width={144} height={14} rx={3} />
                {/* the drum's plates and its slats */}
                <line x1={cx - 58} y1={19} x2={cx + 58} y2={19} />
                <line x1={cx - 58} y1={29} x2={cx + 58} y2={29} />
                <g
                  className="device-fan"
                  data-still={fan === 0 ? "" : undefined}
                >
                  {Array.from({ length: SLATS }, (_, k) => k).map((k) => (
                    <line
                      key={`slat-${cx}-${k}`}
                      className="device-blade"
                      x1={cx}
                      y1={19}
                      x2={cx}
                      y2={29}
                      style={{ "--k": k / SLATS } as React.CSSProperties}
                    />
                  ))}
                </g>
              </g>
            </g>
          )
        })}

        {/* the front: the battery's cells, translucent over what is behind */}
        <g className="device-parts">
          {[245, 357, 531, 643].map((x) => (
            <rect
              key={`cell-${x}`}
              className="device-cell"
              x={x}
              y={top + 1}
              width={110}
              height={bottom - top - 1}
              rx={1.5}
            />
          ))}
        </g>
      </svg>
      {/* the air (device-airflow): particles carried in at the sides,
          through the fans, over the parts and out at the hinge */}
      <DeviceAirflow
        fans={fans}
        rates={rates}
        turns={turns}
        warmth={chip}
        t={t}
      />
    </span>
  )
}

/** A 16:9 desktop display with its own stand, independent of app content. */
export function StudioDisplayFrame({
  children,
  className,
  screenClassName,
  ...props
}: DesktopFrameProps) {
  return (
    <div
      {...props}
      data-device="studio"
      className={cn("device-desktop", className)}
    >
      <div className="device-lid">
        <div className={cn("device-display", screenClassName)}>{children}</div>
        <span aria-hidden className="device-camera" />
      </div>
      <div aria-hidden className="device-stand" />
      <div aria-hidden className="device-foot" />
    </div>
  )
}

/** Hardware only. App navigation and content belong to the child. */
export function IphoneFrame({
  children,
  bare = false,
  screenClassName,
  className,
  ...props
}: ComponentProps<"div"> & {
  bare?: boolean
  screenClassName?: string
  children?: ReactNode
}) {
  return (
    <div
      {...props}
      data-device={bare ? undefined : "iphone"}
      className={cn("device-frame", className)}
    >
      {!bare && (
        <>
          <span className="device-button action" />
          <span className="device-button vol-up" />
          <span className="device-button vol-down" />
          <span className="device-button power" />
          <span aria-hidden className="device-earpiece" />
          <span aria-hidden className="device-antenna left top" />
          <span aria-hidden className="device-antenna right top" />
          <span aria-hidden className="device-antenna left bottom" />
          <span aria-hidden className="device-antenna right bottom" />
        </>
      )}
      <div className={cn("device-screen", screenClassName)}>
        {!bare && <IphoneStatusBar />}
        {children}
      </div>
      {!bare && <span aria-hidden className="device-glass-edge" />}
    </div>
  )
}

function IphoneStatusBar() {
  return (
    <div aria-hidden="true">
      <div className="device-island">
        <span className="device-lens" />
      </div>
      <div className="device-status">
        <span>9:41</span>
        <span className="radios">
          <svg aria-hidden="true" viewBox="0 0 17 11" width="17" height="11">
            <g fill="currentColor">
              <rect x="0" y="7" width="3" height="4" rx="1" />
              <rect x="4.5" y="5" width="3" height="6" rx="1" />
              <rect x="9" y="2.5" width="3" height="8.5" rx="1" />
              <rect x="13.5" y="0" width="3" height="11" rx="1" />
            </g>
          </svg>
          <svg aria-hidden="true" viewBox="0 0 26 11" width="26" height="11">
            <rect
              x="0.6"
              y="0.6"
              width="21"
              height="9.8"
              rx="2.8"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.2"
              opacity="0.5"
            />
            <rect
              x="23.4"
              y="3.6"
              width="2"
              height="3.8"
              rx="1"
              fill="currentColor"
              opacity="0.5"
            />
            <rect
              x="2.2"
              y="2.2"
              width="14"
              height="6.6"
              rx="1.6"
              fill="currentColor"
            />
          </svg>
        </span>
      </div>
    </div>
  )
}
device-frame.css
/* The lid's position, a number so a frame-owned lid can transition it (an
   unregistered custom property flips at once). Declared first: @property cannot sit
   inside a layer. */
@property --device-lid {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@layer components {
  .device-desktop {
    position: relative;
    width: 100%;
    container-type: inline-size;
  }
  .device-desktop .device-lid {
    position: relative;
    padding: 1.2% 1.2% 2.8%;
    border: 0.15cqw solid #28292b;
    border-radius: 2.7cqw 2.7cqw 0.8cqw 0.8cqw;
    background: linear-gradient(#08090a, #080809 95%, #030304);
    box-shadow:
      0 12px 28px #0004,
      inset 0 0 0 0.15cqw #494b4e50,
      inset 0 0 0.45cqw #6b6d7030;
  }
  /* Clipped by a path, never `overflow: hidden`: Chrome clips a box with uneven
     (or elliptical) corners through a mask of its own, and inside one a glass's
     masked blur loses its mask and blurs everything under it (a panel's spill
     blurred the whole page on the display). `clip-path` cuts the same corners and
     keeps it. */
  .device-desktop .device-display {
    position: relative;
    clip-path: inset(0 round 1cqw 1cqw 0.2cqw 0.2cqw);
    aspect-ratio: 16 / 10;
    border-radius: 1cqw 1cqw 0.2cqw 0.2cqw;
    background: #15191d;
    color: #eee;
  }
  .device-desktop .device-camera {
    position: absolute;
    z-index: 3;
    top: 1.45cqw;
    left: 50%;
    width: 0.45cqw;
    height: 0.45cqw;
    border-radius: 50%;
    background: radial-gradient(
      circle at 40% 35%,
      #294254,
      #0b0f16 45%,
      #030404 70%
    );
    box-shadow: 0 0 0 0.18cqw #0a0b0d;
    transform: translateX(-50%);
  }
  .device-desktop[data-device="macbook"] .device-lid {
    width: 84%;
    margin-inline: auto;
  }
  /* The hinge (MacbookFrame `lid`, 0 open .. 1 shut, as --device-lid). Only set up
     when hinged.
     lid="open" | "closed": the frame owns the motion. --device-lid is a registered
     number (below), so it interpolates, and everything that reads it (the angle,
     the shut slab) moves together on one curve. A numeric lid is the caller's time
     and never transitions. */
  .device-desktop[data-lid-motion] {
    transition: --device-lid 1.1s cubic-bezier(0.65, 0, 0.35, 1);
  }
  /* One camera for the whole hinge, in cqw as bare numbers (CSS cannot divide a
     length by a length): the lid's border box W = 84 by H = 55.1125 (84% of the
     frame; padding 1.2 / 2.8, border 0.15, a 16:10 display), its top corner radius
     R = 2.7, thickness T = 1, and the eye at the hinge at distance P = 6.25 H, the
     distance at which the lid, swung flat toward the viewer, spans exactly the
     base's width (W P / (P - H) = 100). For the lid at angle θ:
       top    the lid's top edge, height above the hinge: H cos θ · P / (P - H sin θ)
       back   the back edge of its thickness: (H cos θ + T sin θ) · P / (P - H sin θ + T cos θ)
       band   back - top, the rim as the eye sees it (0 open, T P / (P - H) shut)
       ry     the top corners' projected height: R cos θ · P / (P - H sin θ) */
  .device-desktop[data-hinged] {
    --lid-sin: sin(calc(var(--device-lid, 0) * 90deg));
    --lid-cos: cos(calc(var(--device-lid, 0) * 90deg));
    --lid-scale: calc(344.45 / (344.45 - 55.1125 * var(--lid-sin)));
    --lid-top: calc(55.1125 * var(--lid-cos) * var(--lid-scale));
    --lid-back: calc(
      (55.1125 * var(--lid-cos) + var(--lid-sin)) *
      344.45 /
      (344.45 - 55.1125 * var(--lid-sin) + var(--lid-cos))
    );
    --lid-band: max(0, calc(var(--lid-back) - var(--lid-top)));
    --lid-ry: calc(2.7 * var(--lid-cos) * var(--lid-scale));
  }
  /* The lid's face turns in true perspective: the eye at the hinge (perspective()
     in the lid's own transform puts it at the transform origin), at distance P. */
  /* Open, the lid stands BEHIND the base, as the flat frame's does: the base's
     top edge covers the lid's foot and the shadow it casts, so an open hinged
     MacBook and a flat one are the same drawing. The moment it starts to close
     it comes in front, where a lid swinging toward the eye belongs (z 1 at
     --device-lid 0, z 3 above it). */
  .device-desktop[data-hinged] .device-lid {
    z-index: calc(1 + 2 * round(up, var(--device-lid, 0), 1));
    transform-origin: 50% 100%;
    transform: perspective(344.45cqw)
      rotateX(calc(var(--device-lid, 0) * -90deg));
  }
  /* The lid's thickness, painted where the camera puts it: the lid's own outline
     (projected width, projected corner radii) raised by the band and set just behind
     the face, so what shows is the rim above the lid's top edge and around its
     corners, and nothing past them. Open, the band is zero and the rim hides behind
     the face; closing, it grows; shut, the face is edge-on and the rim is the closed
     laptop's front: the base's width, lit along its top, the dark seam at its foot.
     Its paint is the base's own: the same end shading, the same aluminium. */
  .device-lid-rim {
    position: absolute;
    z-index: 2;
    left: 50%;
    bottom: calc((3.55 + var(--lid-top) - var(--lid-ry)) * 1cqw);
    width: calc(84cqw * var(--lid-scale));
    height: calc((var(--lid-band) + var(--lid-ry)) * 1cqw);
    translate: -50% 0;
    /* The lid's projected top corners, unchanged: the rim is the outline shifted
       up, not grown. As the lid comes down the corner's height (ry) flattens and a
       corner that wide and that flat is visually square, so its width follows it
       down; both floor at the edge's own rounding (0.45cqw), which is all a shut
       lid's corner shows from the front: its plan-view curve lies flat in depth. */
    border-radius:
      calc(min(2.7 * var(--lid-scale), max(0.45, 4 * var(--lid-ry))) * 1cqw)
      calc(min(2.7 * var(--lid-scale), max(0.45, 4 * var(--lid-ry))) * 1cqw) 0 0
      / calc(max(0.45, var(--lid-ry)) * 1cqw)
      calc(max(0.45, var(--lid-ry)) * 1cqw) 0 0;
    background:
      linear-gradient(
        90deg,
        #141416,
        #505054 0.6%,
        #202023 2%,
        transparent 7%,
        transparent 93%,
        #202023 98%,
        #505054 99.4%,
        #141416
      ),
      linear-gradient(#5a5a5f, #2e2e32 22%, #36363a 72%, #1a1a1d 90%, #0b0b0c);
    box-shadow: 0 0.14cqw 0 #060607;
    /* Below a tenth of a cqw of band there is nothing to see, and the rim's
       antialiased corners would fringe the face's. */
    opacity: clamp(0, var(--lid-band) * 10, 1);
  }
  .device-base {
    position: relative;
    z-index: 2;
    height: 3.7cqw;
    margin-top: -0.15cqw;
    background:
      linear-gradient(
        90deg,
        #141416,
        #505054 0.6%,
        #202023 2%,
        transparent 7%,
        transparent 93%,
        #202023 98%,
        #505054 99.4%,
        #141416
      ),
      linear-gradient(
        #424245,
        #27272b 18%,
        #1c1c20 62%,
        #101013 84%,
        #39393c 93%,
        #131315
      );
    border-radius: 0.12cqw 0.12cqw 2.5cqw 2.5cqw;
    box-shadow:
      inset 0 0.12cqw #80808550,
      0 0.5cqw 0.8cqw #0005;
  }
  .device-base::before {
    content: "";
    position: absolute;
    top: 0;
    left: 42%;
    width: 16%;
    height: 1.3cqw;
    border-radius: 0 0 1.4cqw 1.4cqw;
    background: linear-gradient(
      90deg,
      #080809,
      #55555a 7%,
      #46464c 50%,
      #55555a 93%,
      #080809
    );
    box-shadow: 0 0.15cqw 0.2cqw #0008;
  }

  /* The insides (MacbookInternals): the base's face as a strip of its parts.
     Parts are hairlines in one neutral, crisp at any size (non-scaling
     strokes); heat carries the only colour. The fans and the heat pipes move
     at the fans' pace; held (`data-held`, a caller's `t`), each sits at that
     instant: a paused animation with a negative delay is a pure function of
     time. */
  .device-internals {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }
  /* The parts (svg) and the air (canvas, device-airflow) share the base's
     box, the air on top. */
  .device-skin,
  .device-internals-art,
  .device-airflow {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  /* Parts are thinner and quieter than the air, so the flow reads first. */
  .device-parts {
    fill: none;
    stroke: rgb(255 255 255 / 0.24);
    stroke-width: 0.35px;
    vector-effect: non-scaling-stroke;
  }
  .device-parts * {
    vector-effect: non-scaling-stroke;
  }
  .device-labels text {
    fill: rgb(255 255 255 / 0.45);
    font-family: var(--font-mono, ui-monospace, monospace);
    font-size: 7px;
    text-anchor: middle;
    dominant-baseline: middle;
  }
  /* One turn per --pace (--rate is fanTurnRate, turns per second). Held, a
     slat sits at the fan's --turns so far, so a fan speeding up mid-film
     never jumps. --dir is the way the near slats travel (the two fans
     mirror). */
  .device-blade {
    --pace: calc(1s / var(--rate));
  }
  /* A blower's impeller seen edge-on: a ring of vertical slats, each sliding
     across the drum on a cosine (ease-in-out halves), so they bunch at its
     ends where they turn away; the near half bright, the far half faint
     through the housing. `--k` spreads them round the ring; a stopped fan
     keeps its slats where they stand. */
  .device-blade {
    stroke: rgb(255 255 255 / 0.6);
    animation: device-blade var(--pace) linear infinite;
    animation-delay: calc(var(--k) * var(--pace) * -1);
  }
  .device-fan[data-still] .device-blade {
    animation-play-state: paused;
  }
  @keyframes device-blade {
    0% {
      translate: calc(var(--dir, 1) * -56px) 0;
      opacity: 0.9;
      animation-timing-function: ease-in-out;
    }
    50% {
      translate: calc(var(--dir, 1) * 56px) 0;
      opacity: 0.9;
    }
    50.001% {
      translate: calc(var(--dir, 1) * 56px) 0;
      opacity: 0.22;
      animation-timing-function: ease-in-out;
    }
    100% {
      translate: calc(var(--dir, 1) * -56px) 0;
      opacity: 0.22;
    }
  }
  .device-internals[data-held] .device-blade {
    animation-play-state: paused;
    animation-duration: 1s;
    animation-delay: calc((var(--turns) + var(--k)) * -1s);
  }
  @media (prefers-reduced-motion: reduce) {
    .device-blade {
      animation-play-state: paused;
    }
  }
  .device-cell {
    fill: rgb(255 255 255 / 0.04);
    stroke: rgb(255 255 255 / 0.22);
  }
  .device-skin {
    pointer-events: none;
  }
  /* A shut lid's face, warm with the same skin, laid over the rim's aluminium. */
  .device-lid-skin {
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
  }
  /* The heat is the fill; the edge is only a hairline catching it. */
  .device-skin path {
    stroke-width: 0.5px;
    stroke-opacity: 0.5;
    vector-effect: non-scaling-stroke;
  }
  .device-desktop[data-device="studio"] .device-lid {
    z-index: 2;
    border-radius: 1cqw;
    padding: 2%;
    border-color: #232426;
    background: linear-gradient(135deg, #1b1b1c, #111112);
  }
  .device-desktop[data-device="studio"] .device-display {
    aspect-ratio: 16 / 9;
    border-radius: 0;
  }
  .device-stand {
    position: relative;
    margin-inline: auto;
    width: 24%;
    height: 18cqw;
    background:
      radial-gradient(
        circle at 50% -1.4cqw,
        transparent 3.35cqw,
        #dedfe0 3.4cqw,
        #c4c6c7 3.85cqw,
        transparent 3.9cqw
      ),
      linear-gradient(115deg, #a9abad, #d0d2d3 48%, #b8babc 90%), #bfc1c3;
    mask-image: radial-gradient(
      circle at 50% -1.4cqw,
      transparent 3.35cqw,
      #000 3.4cqw
    );
  }
  .device-stand::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(transparent 89%, #f0f1f1 96%, #95989a 99%);
    pointer-events: none;
  }
  .device-foot {
    position: relative;
    margin-inline: auto;
    width: 24%;
    height: 1.3cqw;
    border-radius: 0.15cqw;
    background: linear-gradient(
      90deg,
      #77797b,
      #b2b4b5 2%,
      #d1d2d3 50%,
      #b2b4b5 98%,
      #77797b
    );
    box-shadow:
      inset 0 0.2cqw #55575b,
      0 0.2cqw 0.1cqw #0008;
  }
  .device-foot::before,
  .device-foot::after {
    content: "";
    position: absolute;
    bottom: -0.25cqw;
    width: 9%;
    height: 0.3cqw;
    background: #242527;
    border-radius: 0 0 0.1cqw 0.1cqw;
  }
  .device-foot::before {
    left: 2%;
  }
  .device-foot::after {
    right: 2%;
  }
  .device-desktop[data-device="studio"] .device-camera {
    top: 0.75cqw;
  }
  .device-notch {
    position: absolute;
    z-index: 2;
    top: 1.1cqw;
    left: 44%;
    width: 12%;
    height: 1.9cqw;
    border-radius: 0 0 0.6cqw 0.6cqw;
    background: #030404;
  }
  .device-rubber {
    position: absolute;
    bottom: -0.45cqw;
    width: 6%;
    height: 0.7cqw;
    border-radius: 0 0 0.6cqw 0.6cqw;
    background: linear-gradient(#111113, #202023, #080809);
  }
  .device-rubber.left {
    left: 7%;
  }
  .device-rubber.right {
    right: 7%;
  }
  .device-frame[data-device="iphone"] {
    position: relative;
    width: 100%;
    aspect-ratio: 356 / 730;
    padding: 2%;
    container-type: inline-size;
    background: #030404;
    border-radius: 16% / 7.8%;
    box-shadow: 0 16px 32px #0006;
  }

  /* The metal is an actual rounded ring, not a diagonal gradient visible
     through mismatched corners. The mask also confines its grain to the rim. */
  .device-frame[data-device="iphone"]::before {
    position: absolute;
    z-index: 8;
    inset: 0;
    border-radius: inherit;
    padding: 1.1cqw;
    background-image:
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23888' filter='url(%23n)' opacity='.28' d='M0 0h160v160H0z'/%3E%3C/svg%3E"),
      conic-gradient(
        from 0deg,
        #898b8a,
        #434544 12%,
        #757776 25%,
        #353736 37%,
        #8b8d8c 50%,
        #484a49 62%,
        #8e908f 75%,
        #454746 87%,
        #898b8a
      );
    background-blend-mode: soft-light, normal;
    filter: blur(0.09cqw);
    mask:
      linear-gradient(#fff 0 0) content-box,
      linear-gradient(#fff 0 0);
    mask-composite: exclude;
    content: "";
    pointer-events: none;
  }
  .device-frame[data-device="iphone"]::after {
    position: absolute;
    z-index: 8;
    inset: 0;
    border-radius: inherit;
    box-shadow:
      inset 0 0 0.28cqw 0.18cqw #050707c0,
      inset 0 0 0.28cqw 0.9cqw #06080870;
    content: "";
    pointer-events: none;
  }

  .device-button {
    position: absolute;
    z-index: 0;
    width: 0.7cqw;
    background-image:
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23888' filter='url(%23n)' opacity='.28' d='M0 0h160v160H0z'/%3E%3C/svg%3E"),
      linear-gradient(180deg, #161818, transparent 8%, transparent 90%, #151717),
      linear-gradient(90deg, #242626, #858887 35%, #5b5e5d 65%, #171919);
    background-blend-mode: soft-light, normal, normal;
    box-shadow:
      0 0 0.18cqw #0009,
      inset 0.08cqw 0 0.15cqw #c5c7c650;
    border-radius: 0.5cqw;
  }

  /* Hardware above the display, outside its clipping/compositing group. The
     extra pixel hides the antialiased edge of backdrop-filter layers beneath. */
  .device-glass-edge {
    position: absolute;
    z-index: 7;
    inset: 0;
    padding: calc(2% + 1px);
    border-radius: inherit;
    background: #030404;
    mask:
      linear-gradient(#fff 0 0) content-box,
      linear-gradient(#fff 0 0);
    mask-composite: exclude;
    pointer-events: none;
  }
  .device-button.action {
    top: 20.5%;
    left: -0.65cqw;
    height: 4.5%;
    border-radius: 0.5cqw 0 0 0.5cqw;
  }
  .device-button.vol-up {
    top: 28.5%;
    left: -0.65cqw;
    height: 7.2%;
    border-radius: 0.5cqw 0 0 0.5cqw;
  }
  .device-button.vol-down {
    top: 37.8%;
    left: -0.65cqw;
    height: 7.2%;
    border-radius: 0.5cqw 0 0 0.5cqw;
  }
  .device-button.power {
    top: 31%;
    right: -0.65cqw;
    height: 12%;
    border-radius: 0 0.5cqw 0.5cqw 0;
  }

  .device-frame[data-device="iphone"] > .device-screen {
    --device-safe-gap: 4cqw;
    --device-island-height: 7.6cqw;
    position: relative;
    height: 100%;
    overflow: hidden;
    border-radius: 14.7cqw;
    /* Clip backdrop-filter descendants and the screen background together.
       Overflow's rounded antialiasing alone can leave an unfiltered bright rim. */
    clip-path: inset(0 round 14.7cqw);
    background: var(--device-screen-background, #101214);
    color: var(--device-screen-ink, #eee);
  }
  .device-island {
    position: absolute;
    z-index: 5;
    top: var(--device-safe-gap);
    left: 50%;
    width: 28cqw;
    height: var(--device-island-height);
    background: #000;
    border-radius: 4cqw;
    transform: translateX(-50%);
  }

  .device-earpiece {
    position: absolute;
    z-index: 9;
    top: 1.25cqw;
    left: 33%;
    width: 34%;
    height: 0.35cqw;
    border-radius: 0 0 1.5cqw 1.5cqw;
    background: linear-gradient(#020303, #0d0f0f);
    box-shadow: 0 0.1cqw 0.16cqw #777b7a25;
  }
  .device-antenna {
    position: absolute;
    z-index: 9;
    width: 0.9cqw;
    height: 0.65cqw;
    background: #252727;
    box-shadow:
      0 0.15cqw #8d909040,
      0 -0.1cqw #99999930;
  }
  .device-antenna.left {
    left: 0.05cqw;
  }
  .device-antenna.right {
    right: 0.05cqw;
  }
  .device-antenna.top {
    top: 10%;
  }
  .device-antenna.bottom {
    bottom: 11%;
  }
  .device-lens {
    position: absolute;
    right: 1.6cqw;
    top: 50%;
    transform: translateY(-50%);
    width: 3.1cqw;
    height: 3.1cqw;
    border-radius: 50%;
    background: radial-gradient(
      circle at 40% 35%,
      #25304a 0%,
      #0c1020 24%,
      #15152b 39%,
      #050609 60%
    );
    box-shadow: 0 0 0 0.35cqw #07080a;
    opacity: 0.65;
  }

  .device-status {
    position: absolute;
    z-index: 4;
    top: var(--device-safe-gap);
    right: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    height: var(--device-island-height);
    padding: 0 9.8cqw 0 11.8cqw;
    color: var(--device-status-ink, #eee);
    font-size: 3.65cqw;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
    letter-spacing: 0.01em;
  }
  .device-status svg {
    display: block;
    height: 3.1cqw;
    width: auto;
  }
  .device-status .radios {
    display: flex;
    align-items: center;
    gap: 1.4cqw;
  }

  /* The thermal depth (MacbookThermal.depth): --device-skin and --device-xray,
     each 0..1. Last so it overrides what it dims: as the insides come in the
     skin and the body's outer details (the lid notch, the feet) recede, so the
     inside reads first. Live, a change fades; held, the number is exact. */
  .device-desktop[data-thermal] .device-skin,
  .device-desktop[data-thermal] .device-lid-skin {
    opacity: var(--device-skin);
  }
  .device-desktop[data-thermal] .device-internals {
    opacity: var(--device-xray);
  }
  .device-desktop[data-thermal] .device-base::before,
  .device-desktop[data-thermal] .device-rubber {
    opacity: calc(1 - var(--device-xray) * 0.7);
  }
  .device-desktop[data-thermal="live"] .device-skin,
  .device-desktop[data-thermal="live"] .device-lid-skin,
  .device-desktop[data-thermal="live"] .device-internals,
  .device-desktop[data-thermal="live"] .device-base::before,
  .device-desktop[data-thermal="live"] .device-rubber {
    transition: opacity 0.8s cubic-bezier(0.65, 0, 0.35, 1);
  }
}
device-airflow.tsx
"use client"

// The air a MacBook's fans move, as a particle flow inside the base's face
// (device-frame's MacbookInternals draws everything else). Fine particles with
// short fading trails are carried along the airflow: in at each end of the
// base, through that end's fan, inward over the components, bent up and out
// at the hinge toward the viewer. A particle's place is a pure function of
// time (its streamline, its start and its speed), so a held frame (`t`) is
// exact and a live one simply reads the clock. Drawn in the base's own units
// (1000 × 37) scaled to the canvas.
import * as React from "react"

const W = 1000
const H = 37
/** The base's outline in its own units (device-frame's SHELL). */
const SHELL =
  "M 1.7 0.5 H 998.3 Q 999.5 0.5 999.5 1.7 V 12 A 25 24.5 0 0 1 974.5 36.5 H 25.5 A 25 24.5 0 0 1 0.5 12 V 1.7 Q 0.5 0.5 1.7 0.5 Z"
const FAN = [160, 840]
const LINES = 22
const PARTICLES = 320 // per side: grain, not objects
const TRAIL = 0.012 // of a streamline's length: a fleck, not a streak

/** A stable pseudo-random 0..1 per particle and purpose: scattered, never
 *  in step, and the same every frame. */
function hash(i: number, salt: number): number {
  const v = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453
  return v - Math.floor(v)
}

type Point = [number, number]

/** One streamline as points evenly spaced along it: in at the side edge,
 *  through the fan, inward, then up to the hinge. The vent runs the whole
 *  hinge, so the exits are spread evenly from just past the fan's outlet to
 *  a little past the middle: higher lines leave sooner, over the battery;
 *  the lowest run on over the chip and cross the centre, so the two fans'
 *  currents overlap and the air rises over the whole chip, where its heat
 *  is, with no gap between them. */
function streamline(side: number, k: number): Point[] {
  const dir = side === 0 ? 1 : -1
  const cx = FAN[side] as number
  const outlet = cx + dir * 72
  const y = 16 + k * 0.68
  const edge = side === 0 ? 6 : 994
  const first = outlet + dir * 24
  const last = 500 + dir * 14
  const exit = first + ((last - first) * k) / (LINES - 1)
  const bend = exit - dir * 14
  // The turn never starts behind the outlet.
  const ease =
    dir === 1
      ? Math.max(outlet + 2, bend - 22)
      : Math.min(outlet - 2, bend + 22)
  const curve: Point[] = [
    [outlet, y],
    [ease, y],
    [bend, y * 0.55],
    [exit, 0],
  ]
  const raw: Point[] = []
  for (let i = 0; i <= 40; i++) {
    const s = i / 40
    raw.push([edge + (outlet - edge) * s, y])
  }
  for (let i = 1; i <= 60; i++) {
    const s = i / 60
    const [p0, p1, p2, p3] = curve as [Point, Point, Point, Point]
    const u = 1 - s
    raw.push([
      u ** 3 * p0[0] +
        3 * u * u * s * p1[0] +
        3 * u * s * s * p2[0] +
        s ** 3 * p3[0],
      u ** 3 * p0[1] +
        3 * u * u * s * p1[1] +
        3 * u * s * s * p2[1] +
        s ** 3 * p3[1],
    ])
  }
  return resample(raw, 160)
}

/** Points evenly spaced by arc length, so a particle's speed is constant. */
function resample(points: Point[], n: number): Point[] {
  const lengths = [0]
  for (let i = 1; i < points.length; i++) {
    const [ax, ay] = points[i - 1] as Point
    const [bx, by] = points[i] as Point
    lengths.push((lengths[i - 1] as number) + Math.hypot(bx - ax, by - ay))
  }
  const total = lengths[lengths.length - 1] as number
  const out: Point[] = []
  let j = 0
  for (let i = 0; i < n; i++) {
    const target = (i / (n - 1)) * total
    while (j < lengths.length - 2 && (lengths[j + 1] as number) < target) j++
    const a = points[j] as Point
    const b = points[j + 1] as Point
    const span = (lengths[j + 1] as number) - (lengths[j] as number) || 1
    const f = (target - (lengths[j] as number)) / span
    out.push([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f])
  }
  return out
}

const LINES_BY_SIDE = [0, 1].map((side) =>
  Array.from({ length: LINES }, (_, k) => streamline(side, k)),
)

const at = (line: Point[], s: number): Point => {
  const f = Math.min(1, Math.max(0, s)) * (line.length - 1)
  const i = Math.floor(f)
  const a = line[i] as Point
  const b = (line[i + 1] ?? a) as Point
  return [a[0] + (b[0] - a[0]) * (f - i), a[1] + (b[1] - a[1]) * (f - i)]
}

/** A point anywhere in the current: `u` across it (0 the highest streamline,
 *  1 the lowest), `s` along it, between the two streamlines it falls on. */
function place(lines: Point[][], u: number, s: number): Point {
  const f = u * (lines.length - 1)
  const i = Math.floor(f)
  const a = at(lines[i] as Point[], s)
  const b = at((lines[i + 1] ?? lines[i]) as Point[], s)
  return [a[0] + (b[0] - a[0]) * (f - i), a[1] + (b[1] - a[1]) * (f - i)]
}

/** The air's own blue, saturated enough to read as cold over dark aluminium. */
const ICE = [96, 168, 255]
const EMBER = [255, 140, 64]

export interface AirflowProps {
  /** Each fan's speed, 0..1, left then right. */
  fans: number[]
  /** How much heat the air picks up over the chip, 0..1 (it warms a little). */
  warmth: number
  /** Each fan's turns per second (the frame's fanTurnRate). */
  rates: number[]
  /** Turns each fan has made by `t`, held; live, rate × the clock. */
  turns?: number[]
  /** Seconds, for a caller that owns time; omitted, it reads the clock. */
  t?: number
}

/** How far along its path a fan throws the air per turn: a blower moves a
 *  set volume each revolution, so the air keeps pace with the slats. */
const AIR_PER_TURN = 0.07

export function DeviceAirflow({ fans, rates, turns, warmth, t }: AirflowProps) {
  const canvas = React.useRef<HTMLCanvasElement>(null)
  // The body is drawn here first, at the visible canvas's size.
  const offscreen = React.useRef<HTMLCanvasElement | null>(null)
  const props = React.useRef({ fans, rates, turns, warmth, t })
  props.current = { fans, rates, turns, warmth, t }

  const draw = React.useCallback((time: number) => {
    const el = canvas.current
    if (!el) return
    const ctx = el.getContext("2d")
    if (!ctx) return
    const dpr = window.devicePixelRatio || 1
    const { width, height } = el.getBoundingClientRect()
    if (el.width !== Math.round(width * dpr)) el.width = Math.round(width * dpr)
    if (el.height !== Math.round(height * dpr))
      el.height = Math.round(height * dpr)
    ctx.setTransform(1, 0, 0, 1, 0, 0)
    ctx.clearRect(0, 0, el.width, el.height)
    ctx.setTransform(el.width / W, 0, 0, el.height / H, 0, 0)
    ctx.save()
    ctx.clip(new Path2D(SHELL))
    ctx.globalCompositeOperation = "lighter"
    ctx.lineCap = "round"
    const px = W / el.width // one device pixel, in base units across
    const { fans: f, warmth: w } = props.current
    const colour = (x: number, alpha: number) => {
      // Cool from the fans, only tinted warm toward the middle: mixed further,
      // blue and orange meet as grey and the air over the chip reads as a
      // void.
      const toward = 1 - Math.min(1, Math.abs(x - 500) / 340)
      const k = Math.min(1, w * toward * 0.22)
      const rgb = ICE.map((v, n) =>
        Math.round(v + ((EMBER[n] as number) - v) * k),
      )
      return `rgb(${rgb.join(" ")} / ${alpha})`
    }
    // The current's body: one smooth shape per fan, never a set of lines:
    // under its highest streamline (in at the side, up to the hinge past the
    // fan), along the hinge to the centre, down, and back along the bottom of
    // the band. The two shapes meet exactly at the centre and are drawn
    // together, unblurred, so they join with no seam and no gap under the
    // chip; the whole body is thinned where it leaves at the top, then
    // softened once as it is laid on.
    offscreen.current ??= document.createElement("canvas")
    const off = offscreen.current
    if (off.width !== el.width) off.width = el.width
    if (off.height !== el.height) off.height = el.height
    const o = off.getContext("2d")
    if (o) {
      o.setTransform(1, 0, 0, 1, 0, 0)
      o.clearRect(0, 0, off.width, off.height)
      o.setTransform(el.width / W, 0, 0, el.height / H, 0, 0)
      for (const side of [0, 1]) {
        const fan = Math.min(1, Math.max(0, f[side] ?? f[0] ?? 0))
        if (fan === 0) continue
        const lines = LINES_BY_SIDE[side] as Point[][]
        const top = lines[0] as Point[]
        const low = lines[lines.length - 1] as Point[]
        const floor = (low[0] as Point)[1]
        const shape = new Path2D()
        top.forEach(([x, y], i) => {
          if (i === 0) shape.moveTo(x, y)
          else shape.lineTo(x, y)
        })
        shape.lineTo(500, 0)
        shape.lineTo(500, floor)
        shape.lineTo((low[0] as Point)[0], floor)
        shape.closePath()
        const edge = (top[0] as Point)[0]
        const strength = 0.09 + 0.15 * fan
        const along = o.createLinearGradient(edge, 0, 500, 0)
        along.addColorStop(0, colour(edge, 0))
        for (const stop of [0.03, 0.25, 0.5, 0.75, 1]) {
          const x = edge + (500 - edge) * stop
          along.addColorStop(stop, colour(x, strength))
        }
        o.fillStyle = along
        o.fill(shape)
      }
      // Leaving: thin it in the last few units before the hinge, so the
      // exhaust never reads as heating the screen, while the air rising over
      // the chip stays visible.
      o.globalCompositeOperation = "destination-out"
      const leave = o.createLinearGradient(0, 0, 0, 8)
      leave.addColorStop(0, "rgb(0 0 0 / 0.6)")
      leave.addColorStop(1, "rgb(0 0 0 / 0)")
      o.fillStyle = leave
      o.fillRect(0, 0, W, 8)
      o.globalCompositeOperation = "source-over"
      ctx.save()
      ctx.setTransform(1, 0, 0, 1, 0, 0)
      ctx.filter = `blur(${1.6 * (el.width / W)}px)`
      ctx.drawImage(off, 0, 0)
      ctx.restore()
    }
    for (const side of [0, 1]) {
      const fan = Math.min(1, Math.max(0, f[side] ?? f[0] ?? 0))
      if (fan === 0) continue
      const lines = LINES_BY_SIDE[side] as Point[][]
      const turned =
        props.current.turns?.[side] ??
        (props.current.rates[side] as number) * time
      // The particles: each its own place across the current (between two
      // streamlines), a slow sideways wander, its own start, speed and trail,
      // all hashed so nothing lines up, and a held frame is exact.
      for (let i = 0; i < PARTICLES; i++) {
        const u0 = hash(i, side * 7 + 1)
        const start = hash(i, side * 7 + 2)
        const speed = 0.6 + hash(i, side * 7 + 3) * 0.8
        const wander = hash(i, side * 7 + 4) * Math.PI * 2
        const trail = TRAIL * (0.5 + hash(i, side * 7 + 5))
        // The fan sets the pace: the air goes as far as the fan has turned.
        const s = (start + turned * AIR_PER_TURN * speed) % 1
        const across = (s2: number) =>
          Math.min(
            1,
            Math.max(0, u0 + 0.05 * Math.sin(s2 * 9 + wander + time * 0.7)),
          )
        const p = place(lines, across(s), s)
        const q = place(lines, across(s - trail), s - trail)
        // Faint as it enters, fading as it leaves at the top.
        // Present from the intake, fading as it leaves at the top.
        const life = Math.min(1, s / 0.02) * Math.min(1, p[1] / 14)
        const alpha = life * (0.04 + fan * 0.1)
        const g = ctx.createLinearGradient(q[0], q[1], p[0], p[1])
        g.addColorStop(0, colour(p[0], 0))
        g.addColorStop(1, colour(p[0], alpha))
        ctx.strokeStyle = g
        ctx.lineWidth = 0.7 * px
        ctx.beginPath()
        ctx.moveTo(q[0], q[1])
        ctx.lineTo(p[0], p[1])
        ctx.stroke()
      }
    }
    ctx.restore()
  }, [])

  // Held: draw the instant asked for. Live: read the clock every frame.
  React.useEffect(() => {
    if (t !== undefined) {
      draw(t)
      return
    }
    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches
    if (reduced) {
      draw(0)
      return
    }
    let frame = 0
    const loop = (now: number) => {
      draw(now / 1000)
      frame = requestAnimationFrame(loop)
    }
    frame = requestAnimationFrame(loop)
    return () => cancelAnimationFrame(frame)
  }, [t, draw])

  // A held frame must redraw when the fans or the heat change at the same t.
  // biome-ignore lint/correctness/useExhaustiveDependencies: redraw on the inputs
  React.useEffect(() => {
    if (t !== undefined) draw(t)
  }, [fans, turns, warmth])

  return <canvas ref={canvas} aria-hidden className="device-airflow" />
}