{"name":"Spotmind Kit","url":"https://spotmind.app/kit","license":"MIT","model":"copy-in (no package; copy the source and own it)","sourceUrlPattern":"https://spotmind.app/kit/source/{slug}","itemUrlPattern":"https://spotmind.app/kit/{slug}","counts":{"items":33,"themes":4},"items":[{"slug":"background/contour-field","title":"Contour Field","type":"background","summary":"Domain-warped fBm contour lines in a duotone — F1Peak's signature generative background, re-implemented as a dependency-free raw-WebGL2 component with live, adjustable props (colours, scale, speed, warp, line count, thickness). Respects reduced-motion.","tags":["background","webgl","generative","shader","duotone"],"origin":"f1peak","provenance":["f1peak","forge"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"ContourField.tsx","files":[{"path":"ContourField.tsx","lang":"tsx","source":"\"use client\";\nimport * as React from \"react\";\n\nexport interface ContourFieldProps {\n  /** primary duotone colour (hex) */\n  colorA?: string;\n  /** secondary duotone colour (hex) */\n  colorB?: string;\n  /** field zoom — bigger = finer detail */\n  scale?: number;\n  /** animation speed */\n  speed?: number;\n  /** domain-warp strength */\n  warp?: number;\n  /** number of contour bands */\n  lines?: number;\n  /** line thickness (0..1) */\n  thickness?: number;\n  /** overall opacity */\n  intensity?: number;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nconst FRAG = `#version 300 es\nprecision highp float;\nout vec4 frag;\nuniform vec2 uRes; uniform float uTime;\nuniform vec3 uColA; uniform vec3 uColB;\nuniform float uScale, uSpeed, uWarp, uLines, uThick, uIntensity;\nfloat hash(vec2 p){ p=fract(p*vec2(123.34,345.45)); p+=dot(p,p+34.345); return fract(p.x*p.y); }\nfloat noise(vec2 p){ vec2 i=floor(p),f=fract(p);\n  float a=hash(i),b=hash(i+vec2(1,0)),c=hash(i+vec2(0,1)),d=hash(i+vec2(1,1));\n  vec2 u=f*f*(3.-2.*f); return mix(mix(a,b,u.x),mix(c,d,u.x),u.y); }\nfloat fbm(vec2 p){ float v=0.,a=0.5; for(int i=0;i<6;i++){ v+=a*noise(p); p=p*2.0+vec2(1.7,9.2); a*=0.5; } return v; }\nvoid main(){\n  vec2 uv=(gl_FragCoord.xy-0.5*uRes)/uRes.y;\n  float t=uTime*uSpeed;\n  vec2 p=uv*uScale;\n  vec2 q=vec2(fbm(p+vec2(0.0,0.3*t)), fbm(p+vec2(5.2,1.3)-vec2(0.0,0.22*t)));\n  float f=fbm(p+uWarp*q+0.1*t);\n  float bands=f*uLines;\n  float edge=abs(fract(bands)-0.5)*2.0;\n  float line=smoothstep(uThick+0.05, uThick, edge);\n  float glow=smoothstep(uThick+0.35, uThick, edge)*0.25;\n  vec3 col=mix(uColA,uColB,smoothstep(0.2,0.8,f));\n  float a=(line+glow)*uIntensity;\n  frag=vec4(col, clamp(a,0.0,1.0));\n}`;\n\nconst VERT = `#version 300 es\nvoid main(){ vec2 v[3]=vec2[3](vec2(-1,-1),vec2(3,-1),vec2(-1,3)); gl_Position=vec4(v[gl_VertexID],0,1); }`;\n\nfunction hexToRgb(hex: string): [number, number, number] {\n  const h = hex.replace(\"#\", \"\");\n  const n = h.length === 3 ? h.split(\"\").map((c) => c + c).join(\"\") : h;\n  return [parseInt(n.slice(0, 2), 16) / 255, parseInt(n.slice(2, 4), 16) / 255, parseInt(n.slice(4, 6), 16) / 255];\n}\n\nexport function ContourField({\n  colorA = \"#c2f53b\",\n  colorB = \"#ff2e43\",\n  scale = 2.4,\n  speed = 0.5,\n  warp = 1.4,\n  lines = 7,\n  thickness = 0.06,\n  intensity = 1,\n  className,\n  style,\n}: ContourFieldProps) {\n  const ref = React.useRef<HTMLCanvasElement | null>(null);\n  const props = React.useRef({ colorA, colorB, scale, speed, warp, lines, thickness, intensity });\n  props.current = { colorA, colorB, scale, speed, warp, lines, thickness, intensity };\n\n  React.useEffect(() => {\n    const canvas = ref.current;\n    if (!canvas) return;\n    const gl = canvas.getContext(\"webgl2\", { alpha: true, antialias: true, premultipliedAlpha: false });\n    if (!gl) return;\n\n    const compile = (type: number, src: string) => {\n      const s = gl.createShader(type)!;\n      gl.shaderSource(s, src);\n      gl.compileShader(s);\n      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || \"shader error\");\n      return s;\n    };\n    const prog = gl.createProgram()!;\n    gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));\n    gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));\n    gl.linkProgram(prog);\n    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || \"link error\");\n    gl.useProgram(prog);\n\n    const U = (n: string) => gl.getUniformLocation(prog, n);\n    const uRes = U(\"uRes\"), uTime = U(\"uTime\"), uColA = U(\"uColA\"), uColB = U(\"uColB\");\n    const uScale = U(\"uScale\"), uSpeed = U(\"uSpeed\"), uWarp = U(\"uWarp\"), uLines = U(\"uLines\"), uThick = U(\"uThick\"), uInt = U(\"uIntensity\");\n\n    gl.enable(gl.BLEND);\n    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n      const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n      if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\n      gl.viewport(0, 0, canvas.width, canvas.height);\n    };\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    resize();\n\n    let raf = 0;\n    const start = performance.now();\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const draw = (now: number) => {\n      const p = props.current;\n      resize();\n      gl.uniform2f(uRes, canvas.width, canvas.height);\n      gl.uniform1f(uTime, reduce ? 0 : (now - start) / 1000);\n      gl.uniform3fv(uColA, hexToRgb(p.colorA));\n      gl.uniform3fv(uColB, hexToRgb(p.colorB));\n      gl.uniform1f(uScale, p.scale);\n      gl.uniform1f(uSpeed, p.speed);\n      gl.uniform1f(uWarp, p.warp);\n      gl.uniform1f(uLines, p.lines);\n      gl.uniform1f(uThick, p.thickness);\n      gl.uniform1f(uInt, p.intensity);\n      gl.clearColor(0, 0, 0, 0);\n      gl.clear(gl.COLOR_BUFFER_BIT);\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n      raf = reduce ? 0 : requestAnimationFrame(draw);\n    };\n    raf = requestAnimationFrame(draw);\n\n    return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension(\"WEBGL_lose_context\")?.loseContext(); };\n  }, []);\n\n  return <canvas ref={ref} className={className} style={{ display: \"block\", width: \"100%\", height: \"100%\", ...style }} />;\n}\n"}]},{"slug":"component/chart-frame","title":"Chart Frame","type":"component","summary":"Reusable chart chrome: useSize (ResizeObserver real-pixel measure), ChartPanel (titled, measured plot area), ChartEmpty (an honest empty state - never a blank axis pretending to have data). Generic.","tags":["charts","layout","resizeobserver"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":["react"],"kit":[]},"themeAware":false,"entry":"frame.tsx","files":[{"path":"frame.tsx","lang":"tsx","source":"\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport s from \"../compare.module.css\";\n\n/** measure a container so charts render at real pixels (no viewBox distortion) */\nexport function useSize<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [size, setSize] = useState({ w: 0, h: 0 });\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([e]) => setSize({ w: Math.round(e.contentRect.width), h: Math.round(e.contentRect.height) }));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, size] as const;\n}\n\n/** honest empty state for any chart — never a blank axis pretending to have data */\nexport function ChartEmpty({ title, msg, tier }: { title: string; msg: string; tier?: \"telemetry\" | \"derived\" }) {\n  return (\n    <div className={s.panelWell}>\n      <div className={s.panelTop}>\n        <span className=\"kicker\">{title}</span>\n        {tier && <span className=\"tier\" data-tier={tier}>{tier === \"telemetry\" ? \"FEED\" : \"DERIVED\"}</span>}\n      </div>\n      <div className={s.chartEmpty}><p>{msg}</p></div>\n    </div>\n  );\n}\n\n/** panel chrome shared by the SVG charts: title + tier chip + a measured plot area */\nexport function ChartPanel({\n  title,\n  tier,\n  note,\n  children,\n}: {\n  title: string;\n  tier: \"telemetry\" | \"derived\";\n  note?: string;\n  children: (w: number, h: number) => ReactNode;\n}) {\n  const [ref, { w, h }] = useSize<HTMLDivElement>();\n  return (\n    <div className={s.panelWell}>\n      <div className={s.panelTop}>\n        <span className=\"kicker\">{title}</span>\n        {note && <span className={`${s.panelNote} tag`}>{note}</span>}\n        <span className=\"tier\" data-tier={tier}>{tier === \"telemetry\" ? \"FEED\" : \"DERIVED\"}</span>\n      </div>\n      <div className={s.chartArea} ref={ref}>\n        {w > 0 && h > 0 ? children(w, h) : null}\n      </div>\n    </div>\n  );\n}\n"}]},{"slug":"component/diverging-bars","title":"Diverging Bars","type":"component","summary":"Two bars diverging from a centre line, each split into an accented sub-segment (e.g. shots -> on target). Widths scale to the larger total.","tags":["dataviz","comparison","stat"],"origin":"zq-kit","provenance":["wc26"],"deps":{"npm":["react"],"kit":[]},"themeAware":true,"entry":"DivergingBars.tsx","files":[{"path":"DivergingBars.tsx","lang":"tsx","source":"import * as React from \"react\";\n\nexport interface DivergingBarsProps {\n  /** left value + its split into a highlighted (e.g. on-target) sub-portion. */\n  left: { total: number; accent?: number; color: string };\n  right: { total: number; accent?: number; color: string };\n  /** dim opacity for the non-accent portion. */\n  dim?: number;\n  height?: number;\n  className?: string;\n}\n\n/**\n * Two bars diverging from a centre line, each optionally split into an accented\n * sub-segment (e.g. shots -> on-target). Widths scale to the larger total.\n */\nexport function DivergingBars({ left, right, dim = 0.32, height = 22, className }: DivergingBarsProps) {\n  const mx = Math.max(left.total, right.total, 1);\n  const seg = (w: number, color: string, op: number) => (\n    <div style={{ width: `${w}%`, height: \"100%\", background: color, opacity: op }} />\n  );\n  const la = left.accent ?? 0;\n  const ra = right.accent ?? 0;\n  return (\n    <div className={className} style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\" }}>\n      <div style={{ display: \"flex\", justifyContent: \"flex-end\", height }}>\n        <div style={{ width: `${(left.total / mx) * 100}%`, height: \"100%\", display: \"flex\" }}>\n          {seg(left.total ? ((left.total - la) / left.total) * 100 : 0, left.color, dim)}\n          {seg(left.total ? (la / left.total) * 100 : 0, left.color, 1)}\n        </div>\n      </div>\n      <div style={{ display: \"flex\", justifyContent: \"flex-start\", height }}>\n        <div style={{ width: `${(right.total / mx) * 100}%`, height: \"100%\", display: \"flex\" }}>\n          {seg(right.total ? (ra / right.total) * 100 : 0, right.color, 1)}\n          {seg(right.total ? ((right.total - ra) / right.total) * 100 : 0, right.color, dim)}\n        </div>\n      </div>\n    </div>\n  );\n}\n"}]},{"slug":"component/donut","title":"Donut","type":"component","summary":"Proportional donut / ring chart from summed segment values. Pure geometry + colour props; center is free for a label.","tags":["dataviz","svg","ring","share"],"origin":"zq-kit","provenance":["wc26"],"deps":{"npm":["react"],"kit":[]},"themeAware":true,"entry":"Donut.tsx","files":[{"path":"Donut.tsx","lang":"tsx","source":"import * as React from \"react\";\n\nexport interface DonutSegment {\n  value: number;\n  color: string;\n}\n\nexport interface DonutProps {\n  segments: DonutSegment[];\n  /** px size of the square SVG. */\n  size?: number;\n  /** ring thickness in viewBox units (viewBox is 120). */\n  thickness?: number;\n  trackColor?: string;\n  className?: string;\n  children?: React.ReactNode;\n}\n\n/**\n * Generic donut/ring chart. Segments are drawn proportionally to their summed value.\n * Pure geometry + colour props — design-agnostic.\n */\nexport function Donut({\n  segments,\n  size = 106,\n  thickness = 15,\n  trackColor = \"rgba(255,255,255,0.06)\",\n  className,\n}: DonutProps) {\n  const r = 60 - thickness / 2 - 2;\n  const C = 2 * Math.PI * r;\n  const total = segments.reduce((s, x) => s + Math.max(0, x.value), 0) || 1;\n  let offset = 0;\n  return (\n    <svg className={className} width={size} height={size} viewBox=\"0 0 120 120\" style={{ flex: \"0 0 auto\" }}>\n      <circle cx=\"60\" cy=\"60\" r={r} fill=\"none\" stroke={trackColor} strokeWidth={thickness} />\n      {segments.map((seg, i) => {\n        const frac = Math.max(0, seg.value) / total;\n        const len = frac * C;\n        const el = (\n          <circle\n            key={i}\n            cx=\"60\"\n            cy=\"60\"\n            r={r}\n            fill=\"none\"\n            stroke={seg.color}\n            strokeWidth={thickness}\n            strokeDasharray={`${len} ${C - len}`}\n            strokeDashoffset={-offset}\n            transform=\"rotate(-90 60 60)\"\n          />\n        );\n        offset += len;\n        return el;\n      })}\n    </svg>\n  );\n}\n"}]},{"slug":"component/radial-gauge","title":"Radial Gauge","type":"component","summary":"Single-arc radial gauge (0..1) with a rounded cap, sweeping from the top. Pure geometry + colour props.","tags":["dataviz","svg","gauge","telemetry"],"origin":"zq-kit","provenance":["f1peak","wc26"],"deps":{"npm":["react"],"kit":[]},"themeAware":true,"entry":"RadialGauge.tsx","files":[{"path":"RadialGauge.tsx","lang":"tsx","source":"import * as React from \"react\";\n\nexport interface RadialGaugeProps {\n  /** 0..1 fill fraction. */\n  value: number;\n  color: string;\n  size?: number;\n  thickness?: number;\n  trackColor?: string;\n  className?: string;\n}\n\n/** Generic single-arc radial gauge (0..1), rounded cap, sweeping from top. */\nexport function RadialGauge({\n  value,\n  color,\n  size = 98,\n  thickness = 9,\n  trackColor = \"rgba(255,255,255,0.06)\",\n  className,\n}: RadialGaugeProps) {\n  const r = 50 - thickness / 2 - 1;\n  const C = 2 * Math.PI * r;\n  const L = Math.max(0, Math.min(1, value)) * C;\n  return (\n    <svg className={className} width={size} height={size} viewBox=\"0 0 100 100\">\n      <circle cx=\"50\" cy=\"50\" r={r} fill=\"none\" stroke={trackColor} strokeWidth={thickness} />\n      <circle\n        cx=\"50\"\n        cy=\"50\"\n        r={r}\n        fill=\"none\"\n        stroke={color}\n        strokeWidth={thickness}\n        strokeLinecap=\"round\"\n        strokeDasharray={`${L} ${C - L}`}\n        transform=\"rotate(-90 50 50)\"\n      />\n    </svg>\n  );\n}\n"}]},{"slug":"component/status-pip","title":"Status Pip","type":"component","summary":"Lifecycle state chip for processes and services: a solid square pip + mono uppercase label, coloured by theme tokens. Only the starting state animates, as a hard-step blink with no glow; reduced motion disables it.","tags":["status","lifecycle","process","indicator"],"origin":"","provenance":[],"deps":{"npm":["react"],"kit":[]},"themeAware":true,"entry":"StatusPip.tsx","files":[{"path":"StatusPip.tsx","lang":"tsx","source":"import * as React from \"react\";\n\nexport type PipState = \"idle\" | \"starting\" | \"running\" | \"degraded\" | \"stopped\" | \"error\";\n\nexport interface StatusPipProps {\n  state: PipState;\n  /** Text beside the pip; defaults to the state name. */\n  label?: string;\n  /** Pip square size in px. */\n  size?: number;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nconst STATE_COLOR: Record<PipState, string> = {\n  idle: \"var(--ink-faint, #8a8f98)\",\n  starting: \"var(--accent, #c2f53b)\",\n  running: \"var(--good, #3ecf6e)\",\n  degraded: \"var(--warn, #e8b13c)\",\n  stopped: \"var(--ink-dim, #a7abb4)\",\n  error: \"var(--bad, #ff2e43)\",\n};\n\n/**\n * Lifecycle state chip for processes, services, runnables: a solid square pip\n * plus a mono uppercase label. Colour comes from theme tokens (--good / --warn /\n * --bad / --accent / --ink-*), so it follows whatever theme surface it sits on.\n * Only `starting` animates, and it blinks in hard steps (no fade, no glow);\n * the blink is disabled under prefers-reduced-motion.\n */\nexport function StatusPip({ state, label, size = 8, className, style }: StatusPipProps) {\n  const blinking = state === \"starting\";\n  return (\n    <span className={className} style={{ display: \"inline-flex\", alignItems: \"center\", gap: 7, ...style }}>\n      <style>{`@keyframes kit-pip-blink{0%,49%{opacity:1}50%,100%{opacity:.25}}@media (prefers-reduced-motion:reduce){.kit-pip-blink{animation:none!important}}`}</style>\n      <span\n        aria-hidden\n        className={blinking ? \"kit-pip-blink\" : undefined}\n        style={{\n          width: size,\n          height: size,\n          borderRadius: 1,\n          background: STATE_COLOR[state],\n          flex: \"0 0 auto\",\n          animation: blinking ? \"kit-pip-blink 1.1s linear infinite\" : undefined,\n        }}\n      />\n      <span\n        style={{\n          fontFamily: \"var(--font-mono, monospace)\",\n          fontSize: 10,\n          letterSpacing: 1.2,\n          textTransform: \"uppercase\",\n          color: \"var(--ink-dim, #a7abb4)\",\n        }}\n      >\n        {label ?? state}\n      </span>\n    </span>\n  );\n}\n"}]},{"slug":"component/time-widgets","title":"Time Widgets","type":"component","summary":"Two SSR-safe time components: LocalTime renders an ISO instant in the viewer's own timezone (mount-gated, no hydration mismatch), and CountdownClock ticks down to an absolute instant (correct regardless of clock skew). Built on @zq/kit's useMounted/useNow.","tags":["time","timezone","countdown","ssr","hydration"],"origin":"wc26","provenance":["wc26"],"deps":{"npm":["react"],"kit":["useMounted","useNow"]},"themeAware":true,"entry":"time.tsx","files":[{"path":"time.tsx","lang":"tsx","source":"\"use client\";\n\nimport * as React from \"react\";\nimport { useMounted, useNow } from \"@zq/kit/hooks\";\n\n/**\n * Kickoff time/date rendered in the USER's local timezone. Server components can't know the\n * viewer's TZ, so we gate on mount: the first (SSR-matching) render shows a stable placeholder,\n * then we fill the browser-local value. Fixes the \"everything shows the server's timezone\" bug.\n */\nexport function LocalTime({\n  iso,\n  kind = \"time\",\n  fallback = \"·\",\n}: {\n  iso: string | null | undefined;\n  kind?: \"time\" | \"date\" | \"daytime\" | \"weekday\";\n  fallback?: string;\n}) {\n  const mounted = useMounted();\n  if (!iso) return <>{\"\"}</>;\n  if (!mounted) return <span suppressHydrationWarning>{fallback}</span>;\n  const d = new Date(iso);\n  const opts: Intl.DateTimeFormatOptions =\n    kind === \"date\"\n      ? { month: \"short\", day: \"numeric\" }\n      : kind === \"daytime\"\n        ? { month: \"short\", day: \"numeric\", hour: \"numeric\", minute: \"2-digit\" }\n        : kind === \"weekday\"\n          ? { weekday: \"long\", month: \"long\", day: \"numeric\" }\n          : { hour: \"numeric\", minute: \"2-digit\" };\n  return <span suppressHydrationWarning>{d.toLocaleString([], opts)}</span>;\n}\n\nfunction parts(ms: number): { d: number; h: number; m: number; s: number } {\n  const s = Math.max(0, Math.floor(ms / 1000));\n  return { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 };\n}\n\n/**\n * Countdown to an absolute instant (ISO). Ticks every second client-side. The target is an\n * absolute UTC instant so it's correct regardless of the viewer's clock skew/TZ. Renders nothing\n * once the instant has passed (the caller swaps to a live/result state).\n */\nexport function CountdownClock({ iso, className }: { iso: string; className?: string }) {\n  const mounted = useMounted();\n  const target = React.useMemo(() => (iso ? new Date(iso).getTime() : 0), [iso]);\n  const now = useNow(mounted);\n  if (!mounted || !target) return <span className={className} suppressHydrationWarning />;\n  const remaining = target - now;\n  if (remaining <= 0) return <span className={className} suppressHydrationWarning />;\n  const { d, h, m, s } = parts(remaining);\n  const text = d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}:${String(m).padStart(2, \"0\")}:${String(s).padStart(2, \"0\")}` : `${m}:${String(s).padStart(2, \"0\")}`;\n  return (\n    <span className={className} suppressHydrationWarning>\n      {text}\n    </span>\n  );\n}\n"}]},{"slug":"fontpair/foundry-industrial","title":"Foundry Industrial","type":"fontpair","summary":"Massive condensed display over a neutral grotesque body, mono reserved for code. Poster headlines that never fight the reading line.","tags":["condensed","industrial","editorial"],"origin":"spotmind","provenance":["spotmind"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"fonts.ts","files":[{"path":"fonts.ts","lang":"ts","source":"// Foundry Industrial - Barlow Condensed (display) + Barlow (body) + JetBrains Mono (code).\n// Next.js recipe (next/font/google); expose each as a CSS variable and map your tokens to them.\nimport { Barlow, Barlow_Condensed, JetBrains_Mono } from \"next/font/google\";\n\nexport const display = Barlow_Condensed({ subsets: [\"latin\"], weight: [\"600\", \"700\"], variable: \"--font-display\" });\nexport const body = Barlow({ subsets: [\"latin\"], weight: [\"400\", \"500\", \"600\"], variable: \"--font-body\" });\nexport const mono = JetBrains_Mono({ subsets: [\"latin\"], weight: [\"400\", \"500\"], variable: \"--font-mono\" });\n\n// <html className={`${display.variable} ${body.variable} ${mono.variable}`}>\n// CSS: --f-display: var(--font-display), \"Arial Narrow\", sans-serif; etc."}]},{"slug":"fontpair/geometric-signal","title":"Geometric Signal","type":"fontpair","summary":"A tight geometric display against a relaxed grotesque body: confident product headlines with an easy reading rhythm underneath.","tags":["geometric","product","clean"],"origin":"spotmind","provenance":["spotmind"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"fonts.ts","files":[{"path":"fonts.ts","lang":"ts","source":"// Geometric Signal - Montserrat (display) + Barlow (body) + JetBrains Mono (code).\nimport { Barlow, JetBrains_Mono, Montserrat } from \"next/font/google\";\n\nexport const display = Montserrat({ subsets: [\"latin\"], weight: [\"700\", \"800\"], variable: \"--font-display\" });\nexport const body = Barlow({ subsets: [\"latin\"], weight: [\"400\", \"500\", \"600\"], variable: \"--font-body\" });\nexport const mono = JetBrains_Mono({ subsets: [\"latin\"], weight: [\"400\", \"500\"], variable: \"--font-mono\" });"}]},{"slug":"fontpair/roboto-workhorse","title":"Roboto Workhorse","type":"fontpair","summary":"One family, committed weight contrast, mono for the technical voice. The quiet setup for tools and dashboards that should disappear behind their data.","tags":["single-family","tool","quiet"],"origin":"","provenance":[],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"fonts.ts","files":[{"path":"fonts.ts","lang":"ts","source":"// Roboto Workhorse - Roboto carries display AND body on weight contrast; JetBrains Mono for code.\nimport { JetBrains_Mono, Roboto } from \"next/font/google\";\n\nexport const roboto = Roboto({ subsets: [\"latin\"], weight: [\"400\", \"500\", \"700\"], variable: \"--font-roboto\" });\nexport const mono = JetBrains_Mono({ subsets: [\"latin\"], weight: [\"400\", \"500\"], variable: \"--font-mono\" });\n\n// display = Roboto 700, body = Roboto 400/500 - the pair lives in the weights, not two families."}]},{"slug":"hook/engine-frame","title":"Engine Frame Hooks","type":"hook","summary":"The engine React bridge: useEngineClock (mount-once playback driver), useEngineFrame (re-render every animation frame for 60fps motion), useEngineVersion (throttled ~5/s via useSyncExternalStore). Smooth AND cheap live UI.","tags":["raf","60fps","useSyncExternalStore","playback"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":["react"],"kit":[]},"themeAware":false,"entry":"useEngine.ts","files":[{"path":"useEngine.ts","lang":"ts","source":"\"use client\";\n\nimport { useEffect, useReducer, useSyncExternalStore } from \"react\";\nimport { engine } from \"../engine/SessionEngine\";\n\n/** Re-renders the component on (throttled) engine state changes. */\nexport function useEngineVersion(): number {\n  return useSyncExternalStore(engine.subscribe, engine.getVersion, engine.getVersion);\n}\n\n/**\n * Re-renders the component on EVERY animation frame, for smooth sub-throttle\n * motion the 180 ms uiVersion bump is too coarse for: moving car markers, live\n * telemetry numerals. The playback clock is driven once by useEngineClock; this\n * only re-reads `engine.t` per frame (returned for convenience). Mount it on the\n * specific live sub-tree that needs 60 fps, not the whole cockpit.\n */\nexport function useEngineFrame(active = true): number {\n  const [, tick] = useReducer((n: number) => (n + 1) | 0, 0);\n  useEffect(() => {\n    if (!active) return;\n    let raf = 0;\n    const loop = () => {\n      tick();\n      raf = requestAnimationFrame(loop);\n    };\n    raf = requestAnimationFrame(loop);\n    return () => cancelAnimationFrame(raf);\n  }, [active]);\n  return engine.t;\n}\n\n/**\n * Drives the playback clock. Mount ONCE at the session root; components then read\n * `engine.t` (throttled) or call the hot readers with `performance.now()` for 60fps canvas.\n * `frame()` is idempotent within a frame, so extra callers are harmless.\n */\nexport function useEngineClock(active = true): void {\n  useEffect(() => {\n    if (!active) return;\n    let raf = 0;\n    const tick = () => {\n      engine.frame(performance.now());\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [active]);\n}\n\nexport { engine };\n"}]},{"slug":"hook/kit-hooks","title":"Live Socket & Frame Hooks","type":"hook","summary":"The shared client-hook bundle: useLiveSocket (auto-reconnecting JSON WebSocket), useRaf, useInterval, useNow, useMounted, useMediaQuery.","tags":["hooks","websocket","live","raf","ssr"],"origin":"zq-kit","provenance":["f1peak","wc26"],"deps":{"npm":["react"],"kit":[]},"themeAware":false,"entry":"hooks.ts","files":[{"path":"hooks.ts","lang":"ts","source":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/** True after the first client paint — gate time-relative / random content to avoid hydration mismatch. */\nexport function useMounted(): boolean {\n  const [m, setM] = useState(false);\n  useEffect(() => setM(true), []);\n  return m;\n}\n\n/** requestAnimationFrame loop. `cb` receives the high-res timestamp. */\nexport function useRaf(cb: (t: number) => void, active = true): void {\n  const ref = useRef(cb);\n  ref.current = cb;\n  useEffect(() => {\n    if (!active) return;\n    let raf = 0;\n    const tick = (t: number) => {\n      ref.current(t);\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [active]);\n}\n\n/** setInterval that survives re-renders. Pass ms=null to pause. */\nexport function useInterval(cb: () => void, ms: number | null): void {\n  const ref = useRef(cb);\n  ref.current = cb;\n  useEffect(() => {\n    if (ms == null) return;\n    const id = setInterval(() => ref.current(), ms);\n    return () => clearInterval(id);\n  }, [ms]);\n}\n\n/** A 1s ticker that re-renders the component — for live clocks driven by Date.now(). */\nexport function useNow(active = true, ms = 1000): number {\n  const [now, setNow] = useState(() => Date.now());\n  useInterval(() => setNow(Date.now()), active ? ms : null);\n  return now;\n}\n\n/** Reactive media query. */\nexport function useMediaQuery(query: string): boolean {\n  const [match, setMatch] = useState(false);\n  useEffect(() => {\n    const m = window.matchMedia(query);\n    const on = () => setMatch(m.matches);\n    on();\n    m.addEventListener(\"change\", on);\n    return () => m.removeEventListener(\"change\", on);\n  }, [query]);\n  return match;\n}\n\nexport type LiveStatus = \"connecting\" | \"open\" | \"closed\";\n\nexport interface UseLiveSocket<T> {\n  /** Last parsed message received. */\n  last: T | null;\n  status: LiveStatus;\n}\n\n/**\n * Generic auto-reconnecting JSON WebSocket subscription. The server pushes; the client\n * only receives. Promoted from F1Peak's LiveLink to serve any ZQ live feed.\n * Pass `url=null` to stay disconnected (e.g. nothing live to watch).\n */\nexport function useLiveSocket<T = unknown>(\n  url: string | null,\n  opts?: { onMessage?: (msg: T) => void; reconnectMs?: number },\n): UseLiveSocket<T> {\n  const [last, setLast] = useState<T | null>(null);\n  const [status, setStatus] = useState<LiveStatus>(\"connecting\");\n  const cb = useRef(opts?.onMessage);\n  cb.current = opts?.onMessage;\n  const reconnectMs = opts?.reconnectMs ?? 3000;\n\n  useEffect(() => {\n    if (!url) {\n      setStatus(\"closed\");\n      return;\n    }\n    let ws: WebSocket | null = null;\n    let timer: ReturnType<typeof setTimeout> | null = null;\n    let killed = false;\n\n    const connect = () => {\n      setStatus(\"connecting\");\n      try {\n        ws = new WebSocket(url);\n      } catch {\n        timer = setTimeout(connect, reconnectMs);\n        return;\n      }\n      ws.onopen = () => !killed && setStatus(\"open\");\n      ws.onmessage = (e) => {\n        let msg: T;\n        try {\n          msg = JSON.parse(e.data as string) as T;\n        } catch {\n          return;\n        }\n        if (killed) return;\n        setLast(msg);\n        cb.current?.(msg);\n      };\n      ws.onclose = () => {\n        if (killed) return;\n        setStatus(\"closed\");\n        timer = setTimeout(connect, reconnectMs);\n      };\n      ws.onerror = () => ws?.close();\n    };\n    connect();\n\n    return () => {\n      killed = true;\n      if (timer) clearTimeout(timer);\n      ws?.close();\n    };\n  }, [url, reconnectMs]);\n\n  return { last, status };\n}\n"}]},{"slug":"palette/apex-duotone","title":"APEX Duotone","type":"palette","summary":"Acid lime striking against signal red on layered cool indigo. A high-energy duotone for data-dense, broadcast-style surfaces.","tags":["duotone","dark","high-energy","data"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"palette.css","files":[{"path":"palette.css","lang":"css","source":"/* APEX Duotone - shipped in a live Formula 1 session viewer. */\n[data-palette=\"apex-duotone\"] {\n  --bg: #111420;\n  --surface: #1a1f30;\n  --surface-2: #222840;\n  --ink: #f3f6ff;\n  --ink-dim: #b6bfd6;\n  --accent: #c2f53b;\n  --accent-2: #ff2e43;\n  --good: #00d17a;\n  --warn: #ffd900;\n  --bad: #ff2d3d;\n}"}]},{"slug":"palette/daylight-signal","title":"Daylight Signal","type":"palette","summary":"A light publication system: faint violet-tinted paper, deep readable ink, one committed violet and a green kept only for genuinely live elements.","tags":["light","editorial","reading"],"origin":"spotmind","provenance":["spotmind"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"palette.css","files":[{"path":"palette.css","lang":"css","source":"/* Daylight Signal - designed and shipped as this site's v2 light system. */\n[data-palette=\"daylight-signal\"] {\n  --bg: oklch(0.976 0.006 290);\n  --surface: oklch(1 0 0);\n  --surface-2: oklch(0.945 0.02 290);\n  --ink: oklch(0.22 0.025 290);\n  --ink-dim: oklch(0.44 0.02 290);\n  --accent: oklch(0.5 0.21 290);\n  --accent-2: oklch(0.42 0.19 290);\n  --good: oklch(0.56 0.14 165);\n  --warn: oklch(0.7 0.13 85);\n  --bad: oklch(0.55 0.2 25);\n}"}]},{"slug":"palette/foundry","title":"Foundry","type":"palette","summary":"Pale graphite with a violet undertone, one vibrant violet lead and a signal green reserved for live things. Industrial without going black.","tags":["pale-dark","industrial","violet"],"origin":"spotmind","provenance":["spotmind"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"palette.css","files":[{"path":"palette.css","lang":"css","source":"/* Foundry - shipped as this site's v3 system. */\n[data-palette=\"foundry\"] {\n  --bg: oklch(0.27 0.012 290);\n  --surface: oklch(0.31 0.014 290);\n  --surface-2: oklch(0.225 0.012 290);\n  --ink: oklch(0.955 0.005 290);\n  --ink-dim: oklch(0.79 0.012 290);\n  --accent: oklch(0.72 0.17 290);\n  --accent-2: oklch(0.8 0.17 160);\n  --good: oklch(0.8 0.17 160);\n  --warn: oklch(0.8 0.14 65);\n  --bad: oklch(0.66 0.22 25);\n}"}]},{"slug":"palette/ink-poster","title":"Ink & Poster","type":"palette","summary":"Painted, illustrated shades: deep oil-blue canvas, warm bone ink, ember lead with gold, teal and plum supports. Built for solid cel-step shading, never glow.","tags":["painted","poster","cel-shade","dark"],"origin":"spotmind","provenance":["spotmind"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"palette.css","files":[{"path":"palette.css","lang":"css","source":"/* Ink & Poster - this site's v4 system: solid shading steps, no glow. */\n[data-palette=\"ink-poster\"] {\n  --bg: oklch(0.26 0.032 240);\n  --surface: oklch(0.3 0.036 240);\n  --surface-2: oklch(0.215 0.03 240);\n  --ink: oklch(0.945 0.012 85);\n  --ink-dim: oklch(0.79 0.02 85);\n  --accent: oklch(0.66 0.14 45);\n  --accent-2: oklch(0.78 0.11 85);\n  --good: oklch(0.72 0.1 160);\n  --warn: oklch(0.78 0.11 85);\n  --bad: oklch(0.58 0.18 25);\n}"}]},{"slug":"palette/neutral-contract","title":"Neutral Contract","type":"palette","summary":"A calm periwinkle-accented baseline that flatters almost any component. The default starting point when a design has not chosen its voice yet.","tags":["dark","baseline","calm"],"origin":"","provenance":[],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"palette.css","files":[{"path":"palette.css","lang":"css","source":"/* Neutral Contract - the library's own baseline colour system. */\n[data-palette=\"neutral-contract\"] {\n  --bg: #0c0e13;\n  --surface: #14171e;\n  --surface-2: #1b1f28;\n  --ink: #f1f3f7;\n  --ink-dim: #aab0bd;\n  --accent: #6ea8fe;\n  --accent-2: #b66efe;\n  --good: #36d399;\n  --warn: #f5b544;\n  --bad: #ff5d6c;\n}"}]},{"slug":"palette/tifo-matchday","title":"TIFO Matchday","type":"palette","summary":"Near-black canvas, one strike-orange lead and a brass second. Editorial broadsheet energy for live-event surfaces.","tags":["dark","editorial","single-accent"],"origin":"wc26","provenance":["wc26"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"palette.css","files":[{"path":"palette.css","lang":"css","source":"/* TIFO Matchday - shipped in a live World Cup tracker. */\n[data-palette=\"tifo-matchday\"] {\n  --bg: #08080a;\n  --surface: #131317;\n  --surface-2: #1a1a20;\n  --ink: #f4f4f2;\n  --ink-dim: #b6b6bc;\n  --accent: #ff5a3c;\n  --accent-2: #e0b23a;\n  --good: #37c26b;\n  --warn: #e0b23a;\n  --bad: #ff5a3c;\n}"}]},{"slug":"pattern/event-replay-history","title":"Event-Replay History","type":"pattern","summary":"Per-lap / per-driver series (lap times, race trace, gap-to-leader, sectors) built by REPLAYING the event stream rather than reading cursor selectors. Memoized on (bundle, events.length). Pure + engine-agnostic.","tags":["replay","timeseries","selectors","memoized"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"history.ts","files":[{"path":"history.ts","lang":"ts","source":"// Event-replay history selectors. The merged cursor selectors (selectors.ts) only\n// expose the CURRENT value at engine.t; to build per-lap / per-driver *series* we\n// replay the preserved, unmerged patch log in bundle.events and fold a running\n// state, emitting on the field-change of interest. Each selector is memoized on\n// (bundle identity, events.length) — the same pattern as timelineMarkers — so a\n// chart can call it every render for free.\n\nimport type { SessionEngine } from \"./SessionEngine\";\nimport { applyPatch, asEntries, asArray } from \"../shared/merge\";\nimport { parseLapTime } from \"../shared/feeds\";\n\n/** seconds from a feed value: \"1:18.305\" (lap) or \"28.456\" (sector) → seconds, else null */\nfunction parseSec(v: unknown): number | null {\n  if (typeof v !== \"string\" || !v) return null;\n  if (v.includes(\":\")) {\n    const ms = parseLapTime(v);\n    return ms == null ? null : ms / 1000;\n  }\n  const n = Number(v);\n  return Number.isFinite(n) && n > 0 ? n : null;\n}\n\n/** \"+1.234\"/\"1.234\" → 1.234s; \"+1 LAP\" / blank → null (lapped breaks the series) */\nfunction gapToSec(v: unknown): number | null {\n  if (typeof v !== \"string\") return null;\n  const m = /^\\+?(\\d+(?:\\.\\d+)?)$/.exec(v.trim());\n  return m ? Number(m[1]) : null;\n}\n\n/** memo keyed on bundle identity + event count (events only ever append) */\nfunction replayCache<T>() {\n  let bundle: unknown = null;\n  let len = -1;\n  let val: T;\n  return (e: SessionEngine, compute: () => T): T => {\n    const n = e.bundle?.events.length ?? -1;\n    if (e.bundle === bundle && n === len) return val;\n    bundle = e.bundle;\n    len = n;\n    val = compute();\n    return val;\n  };\n}\n\nexport interface LapPoint {\n  /** completed lap number (TimingData.NumberOfLaps at completion) */\n  lap: number;\n  /** lap time in ms */\n  ms: number;\n  /** raw feed value, e.g. \"1:18.305\" */\n  value: string;\n  /** the driver's personal best at the moment it was set */\n  personalBest: boolean;\n  /** set a new session-overall fastest (a purple lap) */\n  overallBest: boolean;\n  /** out-lap / in-lap / clearly non-representative (pit or traffic) — heuristic */\n  outIn: boolean;\n}\n\nconst lapCache = replayCache<Record<string, LapPoint[]>>();\n\n/**\n * Per-driver sequence of completed laps with times, recovered by replaying\n * TimingData. A new lap is emitted whenever a driver's LastLapTime.Value changes.\n * REAL feed data (lap times are exact); outIn is a labelled heuristic (a lap far\n * above the driver's running best is treated as an out/in/compromised lap).\n */\nexport function lapTimeSeries(e: SessionEngine): Record<string, LapPoint[]> {\n  return lapCache(e, () => {\n    const out: Record<string, LapPoint[]> = {};\n    const b = e.bundle;\n    if (!b) return out;\n    let td: any;\n    const lastVal: Record<string, string> = {};\n    const best: Record<string, number> = {};\n    let overall = Infinity;\n    for (const [, feed, patch] of b.events) {\n      if (feed !== \"TimingData\") continue;\n      td = applyPatch(td, patch);\n      for (const [num, line] of asEntries<any>(td?.Lines)) {\n        const v = line?.LastLapTime?.Value;\n        if (!v || typeof v !== \"string\") continue;\n        if (lastVal[num] === v) continue;\n        lastVal[num] = v;\n        const ms = parseLapTime(v);\n        if (ms === null || ms <= 0) continue;\n        const arr = (out[num] ??= []);\n        const lap = line.NumberOfLaps != null ? Number(line.NumberOfLaps) : arr.length + 1;\n        const prevBest = best[num] ?? Infinity;\n        const newOverall = ms < overall;\n        if (ms < prevBest) best[num] = ms;\n        if (newOverall) overall = ms;\n        arr.push({\n          lap,\n          ms,\n          value: v,\n          personalBest: Boolean(line.LastLapTime?.PersonalFastest) || ms < prevBest,\n          overallBest: Boolean(line.LastLapTime?.OverallFastest) || newOverall,\n          outIn: ms > prevBest * 1.5 && prevBest !== Infinity,\n        });\n      }\n    }\n    return out;\n  });\n}\n\n/** the session-overall fastest lap ms across all drivers (from the replay), or null */\nexport function overallFastestMs(e: SessionEngine): number | null {\n  let best: number | null = null;\n  const series = lapTimeSeries(e);\n  for (const arr of Object.values(series)) {\n    for (const p of arr) if (best === null || p.ms < best) best = p.ms;\n  }\n  return best;\n}\n\n// ---- position over laps (race trace) ----\n\nexport interface PosPoint { lap: number; pos: number; }\nconst traceCache = replayCache<{ maxLap: number; byDriver: Record<string, PosPoint[]> }>();\n\n/**\n * Per-driver classified position at each lap completion (the race-trace / lap chart).\n * REAL feed data, but it's the official order at the timing line — it lags on-track\n * overtakes between line crossings (stated on the panel).\n */\nexport function raceTrace(e: SessionEngine): { maxLap: number; byDriver: Record<string, PosPoint[]> } {\n  return traceCache(e, () => {\n    const byDriver: Record<string, PosPoint[]> = {};\n    let maxLap = 0;\n    const b = e.bundle;\n    if (!b) return { maxLap, byDriver };\n    let td: any;\n    const lastLap: Record<string, number> = {};\n    for (const [, feed, patch] of b.events) {\n      if (feed !== \"TimingData\") continue;\n      td = applyPatch(td, patch);\n      for (const [num, line] of asEntries<any>(td?.Lines)) {\n        const lap = line?.NumberOfLaps != null ? Number(line.NumberOfLaps) : null;\n        const pos = line?.Position != null ? Number(line.Position) : null;\n        if (lap == null || pos == null || pos < 1 || lastLap[num] === lap) continue;\n        lastLap[num] = lap;\n        (byDriver[num] ??= []).push({ lap, pos });\n        if (lap > maxLap) maxLap = lap;\n      }\n    }\n    return { maxLap, byDriver };\n  });\n}\n\n// ---- gap-to-leader over laps ----\n\nexport interface GapPoint { lap: number; gap: number; }\nconst gapCache = replayCache<{ maxLap: number; byDriver: Record<string, GapPoint[]> }>();\n\n/**\n * Per-driver gap-to-leader (seconds) sampled at each of the driver's lap completions.\n * REAL (GapToLeader), DERIVED only in that it's sampled per lap; lapped cars\n * (\"+1 LAP\" → null) break the series rather than collapsing to 0.\n */\nexport function gapSeries(e: SessionEngine): { maxLap: number; byDriver: Record<string, GapPoint[]> } {\n  return gapCache(e, () => {\n    const byDriver: Record<string, GapPoint[]> = {};\n    let maxLap = 0;\n    const b = e.bundle;\n    if (!b) return { maxLap, byDriver };\n    let td: any;\n    const lastLap: Record<string, number> = {};\n    for (const [, feed, patch] of b.events) {\n      if (feed !== \"TimingData\") continue;\n      td = applyPatch(td, patch);\n      for (const [num, line] of asEntries<any>(td?.Lines)) {\n        const lap = line?.NumberOfLaps != null ? Number(line.NumberOfLaps) : null;\n        if (lap == null || lastLap[num] === lap) continue;\n        lastLap[num] = lap;\n        const pos = Number(line?.Position ?? 99);\n        const gap = pos === 1 ? 0 : gapToSec(line?.GapToLeader);\n        if (gap == null) continue;\n        (byDriver[num] ??= []).push({ lap, gap });\n        if (lap > maxLap) maxLap = lap;\n      }\n    }\n    return { maxLap, byDriver };\n  });\n}\n\n// ---- sector times per lap ----\n\nexport interface SectorLap { lap: number; s: (number | null)[]; }\nconst secCache = replayCache<Record<string, SectorLap[]>>();\n\n/**\n * Per-driver per-lap sector times (seconds, S1/S2/S3) recovered at lap completion.\n * REAL feed values; a sector not yet filled is null (async-safe).\n */\nexport function sectorSeries(e: SessionEngine): Record<string, SectorLap[]> {\n  return secCache(e, () => {\n    const out: Record<string, SectorLap[]> = {};\n    const b = e.bundle;\n    if (!b) return out;\n    let td: any;\n    const lastVal: Record<string, string> = {};\n    for (const [, feed, patch] of b.events) {\n      if (feed !== \"TimingData\") continue;\n      td = applyPatch(td, patch);\n      for (const [num, line] of asEntries<any>(td?.Lines)) {\n        const v = line?.LastLapTime?.Value;\n        if (!v || typeof v !== \"string\" || lastVal[num] === v) continue;\n        lastVal[num] = v;\n        const lap = line?.NumberOfLaps != null ? Number(line.NumberOfLaps) : (out[num]?.length ?? 0) + 1;\n        const secs = asArray<any>(line?.Sectors).map((sec) => parseSec(sec?.Value));\n        (out[num] ??= []).push({ lap, s: [secs[0] ?? null, secs[1] ?? null, secs[2] ?? null] });\n      }\n    }\n    return out;\n  });\n}\n"}]},{"slug":"pattern/momentum-events","title":"Momentum Curve & Incidents","type":"pattern","summary":"A derived session-intensity 'momentum' curve (weighted, smoothed, 0..1 across 200 bins) + kinematic incident detection (cars stopped on track) fused with race control. Memoized.","tags":["momentum","intensity","incidents"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"events.ts","files":[{"path":"events.ts","lang":"ts","source":"// ============================================================================\n// Track 2 / Phase 6 — event engine + momentum.\n// A derived session-INTENSITY curve (the \"race narrative\") under the scrubber,\n// computed by replaying the real event log and weighting what's happening:\n// incidents (red/SC/yellow), fastest-lap pushes, stewards/car events, radio, pits.\n// Plus kinematic incident detection (a car going stationary on track) fused with\n// the official Race Control text. Pure (no React). Memoized on (bundle, events.length).\n//\n// HONEST: every weight comes from a real fed event; intensity is a derived index\n// (badged \"est.\"), not an official metric. Kinematic \"stopped on track\" is detected\n// from real position data, confirmed against Race Control / Retired flags.\n// ============================================================================\n\nimport type { FeedEvent, SessionBundle } from \"../shared/types\";\nimport { asEntries } from \"../shared/merge\";\n\nexport interface MomentumBin {\n  f: number; // 0..1 fraction of the session timeline\n  v: number; // 0..1 normalized intensity\n}\nexport interface Momentum {\n  bins: MomentumBin[];\n  peakF: number; // fraction of the single most intense moment (auto-highlight anchor)\n}\n\ninterface EngineLike {\n  bundle: SessionBundle | null;\n}\n\nlet cacheKey = -1;\nlet cacheBundle: unknown = null;\nlet cache: Momentum | null = null;\n\nconst BINS = 200;\n\n/** weight a single event by how much it moves the session narrative */\nfunction weightOf(feed: string, patch: unknown): number {\n  const p = patch as Record<string, unknown>;\n  if (feed === \"TrackStatus\") {\n    const st = String(p?.Status ?? \"\");\n    return st === \"5\" ? 12 : st === \"4\" || st === \"6\" ? 9 : st === \"2\" ? 6 : st === \"7\" ? 2 : 0.5;\n  }\n  if (feed === \"RaceControlMessages\") {\n    let w = 0;\n    for (const [, m] of asEntries<Record<string, unknown>>(p?.Messages)) {\n      const cat = String(m?.Category ?? \"\");\n      const flag = String(m?.Flag ?? \"\").toUpperCase();\n      if (cat === \"SafetyCar\") w += 8;\n      else if (cat === \"CarEvent\") w += 6;\n      else if (flag === \"RED\") w += 10;\n      else if (flag.includes(\"YELLOW\")) w += 4;\n      else if (flag === \"CHEQUERED\") w += 3;\n      else if (cat === \"Drs\") w += 0.4;\n      else w += 3; // stewards / incident notes / other\n    }\n    return w;\n  }\n  if (feed === \"TimingData\") {\n    let w = 0;\n    for (const [, line] of asEntries<Record<string, any>>(p?.Lines)) {\n      if (line?.LastLapTime?.OverallFastest === true || line?.LastLapTime?.OverallFastest === \"true\") w += 4;\n      else if (line?.LastLapTime?.Value) w += 0.6; // a completed lap = mild activity\n      if (typeof line?.InPit !== \"undefined\") w += 0.5;\n    }\n    return w;\n  }\n  if (feed === \"TeamRadio\") return 2;\n  if (feed === \"PitLaneTimeCollection\") return 1;\n  return 0;\n}\n\n/** smoothed, normalized session-intensity curve (memoized per bundle) */\nexport function momentumCurve(engine: EngineLike): Momentum {\n  const b = engine.bundle;\n  if (!b) return { bins: [], peakF: 0 };\n  if (b === cacheBundle && b.events.length === cacheKey && cache) return cache;\n  const t1 = b.meta.t1 || (b.events.length ? b.events[b.events.length - 1][0] : 1);\n  const raw = new Float64Array(BINS);\n  for (const [t, feed, patch] of b.events as FeedEvent[]) {\n    const w = weightOf(feed, patch);\n    if (!w) continue;\n    const bi = Math.min(BINS - 1, Math.max(0, Math.floor((t / t1) * BINS)));\n    raw[bi] += w;\n  }\n  // gaussian-ish smoothing (3 box passes)\n  let cur = raw;\n  for (let pass = 0; pass < 3; pass++) {\n    const next = new Float64Array(BINS);\n    for (let i = 0; i < BINS; i++) {\n      const a = cur[Math.max(0, i - 1)];\n      const c = cur[Math.min(BINS - 1, i + 1)];\n      next[i] = (a + cur[i] * 2 + c) / 4;\n    }\n    cur = next;\n  }\n  let max = 0, peakI = 0;\n  for (let i = 0; i < BINS; i++) if (cur[i] > max) { max = cur[i]; peakI = i; }\n  const bins: MomentumBin[] = [];\n  for (let i = 0; i < BINS; i++) bins.push({ f: (i + 0.5) / BINS, v: max > 0 ? cur[i] / max : 0 });\n  const out = { bins, peakF: (peakI + 0.5) / BINS };\n  cacheKey = b.events.length;\n  cacheBundle = b;\n  cache = out;\n  return out;\n}\n\n// ---- kinematic incident detection (cars stopping on track), fused with Race Control ----\nexport interface Incident {\n  t: number; // stream-clock ms\n  num: string;\n  kind: \"stopped\" | \"retired\";\n  rc: string | null; // matched Race Control / steward text, when available\n}\n\n/**\n * Cars that go stationary out on track (not in the pit lane), detected from the raw\n * position column (no movement over a window), cross-referenced with Retired flags.\n * The official Race Control text (turn/driver) is attached when a nearby message exists.\n */\nexport function incidents(engine: EngineLike): Incident[] {\n  const b = engine.bundle;\n  if (!b) return [];\n  const out: Incident[] = [];\n  const WIN = 12_000; // ms stationary\n  const MOVE = 250; // world units (≈25 m)\n  for (const [num, c] of Object.entries(b.pos)) {\n    let flagged = false;\n    for (let i = 0; i < c.t.length; i++) {\n      if (c.st[i] !== 1 || (c.x[i] === 0 && c.y[i] === 0)) continue;\n      // find a sample ~WIN ago\n      const t0 = c.t[i] - WIN;\n      let j = i;\n      while (j > 0 && c.t[j] > t0) j--;\n      if (c.t[i] - c.t[j] < WIN * 0.7) continue;\n      if (c.x[j] === 0 && c.y[j] === 0) continue;\n      const moved = Math.hypot(c.x[i] - c.x[j], c.y[i] - c.y[j]);\n      if (moved < MOVE) {\n        if (!flagged) {\n          out.push({ t: c.t[i], num, kind: \"stopped\", rc: rcNear(b, c.t[i], num) });\n          flagged = true;\n        }\n      } else {\n        flagged = false;\n      }\n    }\n  }\n  return out.sort((a, z) => a.t - z.t);\n}\n\nfunction rcNear(b: SessionBundle, t: number, num: string): string | null {\n  for (const [et, feed, patch] of b.events as FeedEvent[]) {\n    if (feed !== \"RaceControlMessages\") continue;\n    if (Math.abs(et - t) > 30_000) continue;\n    for (const [, m] of asEntries<Record<string, unknown>>((patch as Record<string, unknown>)?.Messages)) {\n      const rn = String(m?.RacingNumber ?? \"\");\n      const msg = String(m?.Message ?? \"\");\n      if (rn === num || msg.includes(`(${num})`)) return msg;\n    }\n  }\n  return null;\n}\n"}]},{"slug":"pattern/resample-spine","title":"Resample Spine (GPS to centerline)","type":"pattern","summary":"The projection spine: projects raw GPS samples onto a centerline -> arc-length s (m) + signed lateral offset d (m), with per-track metre calibration. Powers distance-aligned comparison + real lap windows. Pure, server-safe.","tags":["geometry","gps","arc-length","calibration"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"resample.ts","files":[{"path":"resample.ts","lang":"ts","source":"// ============================================================================\n// Track 2 / Phase 2 — THE PROJECTION PRIMITIVE (the spine).\n// Project raw GPS car samples onto the 720-pt median centerline → arc-length `s`\n// (metres) + signed lateral offset `d` (metres). Per-circuit metre calibration\n// (known length ÷ outline arc-length). Pure (no React/DOM) — server-safe, like the\n// outline geometry. Powers real lap/sector boundary crossings, speed-by-distance\n// (Phase 3), gap-in-metres / on-track order / overtakes / curvature (Phase 4).\n//\n// HONEST: world units are RAW GPS (~0.1 m/unit, varies per circuit) — we calibrate,\n// never assume 1:1. The centerline is the clean median racing line, so lateral `d`\n// is relative to that line, not the geometric track centre.\n// ============================================================================\n\nimport type { SessionBundle, TrackOutline } from \"../shared/types\";\n\n/**\n * Official lap lengths (metres) for 2026 circuits, keyed by the F1\n * `sessionInfo.Meeting.Circuit.ShortName` (lowercased). Used only to convert world\n * units → metres; an unknown circuit falls back to the measured ~0.1013 m/unit GPS\n * scale (flagged `calibrated:false`). Add circuits here as needed.\n */\nconst CIRCUIT_LENGTH_M: Record<string, number> = {\n  catalunya: 4657,\n  shanghai: 5451,\n  \"shanghai international circuit\": 5451,\n  melbourne: 5278,\n  \"albert park\": 5278,\n  suzuka: 5807,\n  sakhir: 5412,\n  \"bahrain international circuit\": 5412,\n  jeddah: 6174,\n  miami: 5412,\n  imola: 4909,\n  monaco: 3337,\n  \"monte carlo\": 3337,\n  montreal: 4361,\n  \"gilles villeneuve\": 4361,\n  spielberg: 4318,\n  \"red bull ring\": 4318,\n  silverstone: 5891,\n  hungaroring: 4381,\n  \"spa-francorchamps\": 7004,\n  spa: 7004,\n  zandvoort: 4259,\n  monza: 5793,\n  baku: 6003,\n  \"marina bay\": 4940,\n  singapore: 4940,\n  \"the americas\": 5513,\n  cota: 5513,\n  austin: 5513,\n  \"hermanos rodriguez\": 4304,\n  \"mexico city\": 4304,\n  interlagos: 4309,\n  \"jose carlos pace\": 4309,\n  \"las vegas\": 6201,\n  lusail: 5419,\n  qatar: 5419,\n  \"yas marina\": 5281,\n  \"abu dhabi\": 5281,\n};\n\nconst FALLBACK_M_PER_UNIT = 0.1013;\n\n/** total arc-length of the closed outline, in world units */\nexport function outlineArc(o: TrackOutline): number {\n  const n = o.x.length;\n  let total = 0;\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n;\n    total += Math.hypot(o.x[j] - o.x[i], o.y[j] - o.y[i]);\n  }\n  return total;\n}\n\nfunction circuitKey(sessionInfo: Record<string, unknown> | undefined): string | null {\n  const m = (sessionInfo as { Meeting?: { Circuit?: { ShortName?: string }; Name?: string } } | undefined)?.Meeting;\n  const sn = m?.Circuit?.ShortName;\n  if (sn) return sn.toLowerCase().trim();\n  if (m?.Name) return m.Name.toLowerCase().trim();\n  return null;\n}\n\nexport interface Projection {\n  s: number; // metres along the centerline from the S/F seam\n  d: number; // signed lateral offset (metres), + = left of racing direction\n  frac: number; // 0..1 lap fraction\n  seg: number; // outline segment index (for the next call's hint)\n}\n\nexport interface Projector {\n  o: TrackOutline;\n  total: number; // world units\n  lengthM: number; // calibrated lap length (metres)\n  mPerUnit: number;\n  calibrated: boolean; // false → fell back to the GPS-scale estimate\n  /** project a world-unit point onto the centerline. Pass `hint` (prev seg) for O(1). */\n  project(x: number, y: number, hint?: number): Projection;\n}\n\n/** build a projector for an outline (cumulative arc + nearest-segment search) */\nexport function makeProjector(o: TrackOutline, sessionInfo?: Record<string, unknown>): Projector {\n  const n = o.x.length;\n  const cum = new Float64Array(n + 1);\n  const segLen = new Float64Array(n);\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n;\n    const L = Math.hypot(o.x[j] - o.x[i], o.y[j] - o.y[i]);\n    segLen[i] = L;\n    cum[i + 1] = cum[i] + L;\n  }\n  const total = cum[n];\n  const key = circuitKey(sessionInfo);\n  const known = key ? CIRCUIT_LENGTH_M[key] : undefined;\n  const mPerUnit = known && total > 0 ? known / total : FALLBACK_M_PER_UNIT;\n  const lengthM = total * mPerUnit;\n  const WIN = 45; // ±segments to search around the hint before falling back to a full scan\n\n  const projOnSeg = (x: number, y: number, i: number) => {\n    const j = (i + 1) % n;\n    const ax = o.x[i], ay = o.y[i];\n    const bx = o.x[j], by = o.y[j];\n    const ex = bx - ax, ey = by - ay;\n    const L2 = ex * ex + ey * ey || 1;\n    let t = ((x - ax) * ex + (y - ay) * ey) / L2;\n    if (t < 0) t = 0;\n    else if (t > 1) t = 1;\n    const fx = ax + ex * t, fy = ay + ey * t;\n    const dx = x - fx, dy = y - fy;\n    const dist2 = dx * dx + dy * dy;\n    return { t, dist2, ex, ey };\n  };\n\n  const project = (x: number, y: number, hint?: number): Projection => {\n    let bestI = 0;\n    let bestT = 0;\n    let bestD2 = Infinity;\n    let bestEx = 1, bestEy = 0;\n    const consider = (i: number) => {\n      const r = projOnSeg(x, y, i);\n      if (r.dist2 < bestD2) {\n        bestD2 = r.dist2;\n        bestI = i;\n        bestT = r.t;\n        bestEx = r.ex;\n        bestEy = r.ey;\n      }\n    };\n    if (hint != null) {\n      for (let k = -WIN; k <= WIN; k++) consider((hint + k + n) % n);\n    }\n    // full scan when no hint, or the windowed match is implausibly far (teleport / pit)\n    if (hint == null || bestD2 > 600 * 600) {\n      bestD2 = Infinity;\n      for (let i = 0; i < n; i++) consider(i);\n    }\n    const sUnits = cum[bestI] + bestT * segLen[bestI];\n    // signed lateral: cross product of tangent × (point − foot)\n    const fx = o.x[bestI] + bestEx * bestT, fy = o.y[bestI] + bestEy * bestT;\n    const cross = bestEx * (y - fy) - bestEy * (x - fx);\n    const dMag = Math.sqrt(bestD2) * mPerUnit;\n    return {\n      s: sUnits * mPerUnit,\n      d: cross >= 0 ? dMag : -dMag,\n      frac: total > 0 ? sUnits / total : 0,\n      seg: bestI,\n    };\n  };\n\n  return { o, total, lengthM, mPerUnit, calibrated: Boolean(known), project };\n}\n\n// ---- per-bundle projector cache (geometry is fixed for a bundle) ----\nconst projCache = new WeakMap<object, Projector>();\n\ninterface EngineLike {\n  bundle: SessionBundle | null;\n}\n\nexport function projectorFor(engine: EngineLike): Projector | null {\n  const o = engine.bundle?.meta.outline;\n  if (!o || !o.x?.length) return null;\n  let p = projCache.get(o);\n  if (!p) {\n    p = makeProjector(o, engine.bundle?.meta.sessionInfo);\n    projCache.set(o, p);\n  }\n  return p;\n}\n\n// ---- per-car arc-length track (project every raw GPS sample) ----\nexport interface CarTrack {\n  t: number[]; // stream-clock ms\n  s: number[]; // metres along the lap (0..lengthM, NOT cumulative across laps)\n  d: number[]; // signed lateral metres\n  seg: number[]; // outline segment per sample\n}\n\nconst carTrackCache = new WeakMap<object, Map<string, CarTrack>>();\n\n/** project a car's raw position column onto the centerline (cached per bundle+car) */\nexport function carTrack(engine: EngineLike, num: string): CarTrack | null {\n  const b = engine.bundle;\n  const proj = projectorFor(engine);\n  const c = b?.pos[num];\n  if (!b || !proj || !c || !c.t.length) return null;\n  let byNum = carTrackCache.get(b);\n  if (!byNum) {\n    byNum = new Map();\n    carTrackCache.set(b, byNum);\n  }\n  const hit = byNum.get(num);\n  if (hit) return hit;\n  const t: number[] = [];\n  const s: number[] = [];\n  const d: number[] = [];\n  const seg: number[] = [];\n  let hint: number | undefined;\n  for (let i = 0; i < c.t.length; i++) {\n    if (c.st[i] !== 1) continue;\n    const x = c.x[i], y = c.y[i];\n    if (x === 0 && y === 0) continue; // no-fix sentinel\n    const p = proj.project(x, y, hint);\n    hint = p.seg;\n    t.push(c.t[i]);\n    s.push(p.s);\n    d.push(p.d);\n    seg.push(p.seg);\n  }\n  const out = { t, s, d, seg };\n  byNum.set(num, out);\n  return out;\n}\n\n/** interpolate s (metres) for a car at any time t (handles the S/F wrap) */\nexport function sAt(ct: CarTrack, t: number): number | null {\n  const arr = ct.t;\n  if (!arr.length) return null;\n  let lo = 0, hi = arr.length - 1;\n  if (t <= arr[0]) return ct.s[0];\n  if (t >= arr[hi]) return ct.s[hi];\n  while (lo < hi) {\n    const mid = (lo + hi) >> 1;\n    if (arr[mid] < t) lo = mid + 1;\n    else hi = mid;\n  }\n  const i = Math.max(1, lo);\n  const t0 = arr[i - 1], t1 = arr[i];\n  const f = t1 > t0 ? (t - t0) / (t1 - t0) : 0;\n  let s0 = ct.s[i - 1], s1 = ct.s[i];\n  return s0 + (s1 - s0) * f; // caller handles wrap if needed\n}\n\n// ---- lap boundaries from S/F crossings (the spine's first payoff) ----\nexport interface LapCrossing {\n  t: number; // stream-clock ms when s crosses the seam\n}\n\n/**\n * Detect S/F line crossings from the projected s-track: s jumps from near the\n * lap length back toward 0. Interpolates the exact crossing instant. Gives REAL\n * lap windows in stream-clock ms (the engine otherwise only knows lap *times*,\n * not start/end instants).\n */\nexport function lapCrossings(ct: CarTrack, lengthM: number): number[] {\n  const out: number[] = [];\n  const half = lengthM * 0.5;\n  for (let i = 1; i < ct.t.length; i++) {\n    const a = ct.s[i - 1];\n    const b = ct.s[i];\n    // a wrap = big backward jump (end-of-lap → start) with both ends near the seam\n    if (a - b > half && a > lengthM * 0.6 && b < lengthM * 0.4) {\n      // fraction of the gap [a..length] before wrap\n      const remA = lengthM - a;\n      const span = remA + b; // distance travelled across the seam\n      const f = span > 0 ? remA / span : 0.5;\n      out.push(ct.t[i - 1] + (ct.t[i] - ct.t[i - 1]) * f);\n    }\n  }\n  return out;\n}\n\nexport interface LapWindow {\n  tStart: number;\n  tEnd: number;\n  dur: number; // ms\n}\n\n/** real lap windows (S/F crossing to S/F crossing) for a car, via the projected s-track */\nexport function lapWindows(engine: EngineLike, num: string): LapWindow[] {\n  const ct = carTrack(engine, num);\n  const proj = projectorFor(engine);\n  if (!ct || !proj) return [];\n  const cross = lapCrossings(ct, proj.lengthM);\n  const out: LapWindow[] = [];\n  for (let i = 1; i < cross.length; i++) {\n    const dur = cross[i] - cross[i - 1];\n    if (dur > 50_000 && dur < 200_000) out.push({ tStart: cross[i - 1], tEnd: cross[i], dur });\n  }\n  return out;\n}\n\n/** a car's fastest complete lap window (for the by-distance compare / ghost) */\nexport function fastestLapWindow(engine: EngineLike, num: string): LapWindow | null {\n  const w = lapWindows(engine, num);\n  if (!w.length) return null;\n  return w.reduce((a, b) => (b.dur < a.dur ? b : a));\n}\n\n// ---- speed-by-distance for a lap window (Phase 3 primitive) ----\nexport interface DistanceTrace {\n  s: number[]; // metres, ascending grid\n  v: number[]; // km/h at each s\n  t: number[]; // stream-clock ms at each s (for delta-time)\n  lengthM: number;\n}\n\n/**\n * Resample a car's lap [tStart,tEnd] onto an even s-grid: project its positions to\n * s, pair each with speed (from the car telemetry column), and monotonically map\n * t and v over s. Returns {s,v,t} on a `gridN`-point grid for clean by-distance\n * traces + delta-time. Telemetry reader is injected so this stays React/DOM-free.\n */\nexport function speedByDistance(\n  ct: CarTrack,\n  tStart: number,\n  tEnd: number,\n  lengthM: number,\n  spdAt: (t: number) => number | null,\n  gridN = 400,\n): DistanceTrace | null {\n  // collect (s, t) within the window; unwrap s so it's monotonic from lap start\n  const sRaw: number[] = [];\n  const tRaw: number[] = [];\n  let sBase = 0;\n  let prev = -1;\n  for (let i = 0; i < ct.t.length; i++) {\n    const tt = ct.t[i];\n    if (tt < tStart || tt > tEnd) continue;\n    let s = ct.s[i];\n    if (prev >= 0 && s + sBase < prev - lengthM * 0.5) sBase += lengthM; // crossed the seam mid-window\n    const su = s + sBase;\n    if (su < prev) continue; // non-monotonic blip — skip\n    sRaw.push(su);\n    tRaw.push(tt);\n    prev = su;\n  }\n  if (sRaw.length < 8) return null;\n  const s0 = sRaw[0];\n  const sEnd = sRaw[sRaw.length - 1];\n  const lapLen = sEnd - s0;\n  if (lapLen < lengthM * 0.5) return null; // partial lap\n  const s: number[] = new Array(gridN);\n  const v: number[] = new Array(gridN);\n  const t: number[] = new Array(gridN);\n  let j = 1;\n  for (let g = 0; g < gridN; g++) {\n    const target = s0 + (lapLen * g) / (gridN - 1);\n    while (j < sRaw.length - 1 && sRaw[j] < target) j++;\n    const a = j - 1, b = j;\n    const f = sRaw[b] > sRaw[a] ? (target - sRaw[a]) / (sRaw[b] - sRaw[a]) : 0;\n    const tt = tRaw[a] + (tRaw[b] - tRaw[a]) * f;\n    s[g] = (target - s0); // 0..lapLen metres\n    t[g] = tt;\n    v[g] = spdAt(tt) ?? 0;\n  }\n  return { s, v, t, lengthM: lapLen };\n}\n"}]},{"slug":"pattern/telemetry-analytics","title":"Telemetry Analytics","type":"pattern","summary":"Driving-style + G analytics from telemetry x geometry: lateral-G (v^2 . curvature), throttle %, heavy-brake %, top/avg speed, on-track order. Honest estimates - no steering channel in the feed.","tags":["analytics","physics","telemetry"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"analytics.ts","files":[{"path":"analytics.ts","lang":"ts","source":"// ============================================================================\n// Track 2 / Phase 4 — the \"F1 doesn't hand you this\" analytics tier.\n// Built on the Phase-2 projection spine (resample.ts): lateral G-force (v²·curvature),\n// driving-style aggregates (full-throttle %, heavy braking %, top/avg speed), real\n// gap-in-metres + true on-track order from arc-length s. Pure (no React/DOM).\n//\n// HONEST: every metric is computed from real fed telemetry/geometry. We have NO\n// steering / DRS / brake-bias / tyre-temp channels (2026 cars) — those are refused,\n// never placeholdered. Lateral G is from the median racing line's curvature, so it's\n// a \"racing-line\" estimate (badged), not a per-wheel measurement.\n// ============================================================================\n\nimport type { TrackOutline } from \"../shared/types\";\nimport { projectorFor, carTrack, fastestLapWindow, type LapWindow, type Projector } from \"./resample\";\n\ninterface EngineLike {\n  bundle: { pos: Record<string, unknown>; car: Record<string, { t: number[]; spd: number[]; thr: number[]; brk: number[] }>; meta: { outline: TrackOutline | null } } | null;\n  carTel(num: string, t: number): { spd: number; gear: number; rpm: number; thr: number; brk: number } | null;\n  carPos(num: string, t: number): { x: number; y: number; on: boolean } | null;\n  t: number;\n}\n\n// ---- outline curvature κ (rad per metre), cached per outline ----\nconst curvCache = new WeakMap<object, Float64Array>();\n\n/** signed-magnitude curvature at each outline point, in rad/metre (uses the projector's metre scale) */\nexport function outlineCurvature(proj: Projector): Float64Array {\n  const o = proj.o;\n  const cached = curvCache.get(o);\n  if (cached) return cached;\n  const n = o.x.length;\n  const k = new Float64Array(n);\n  for (let i = 0; i < n; i++) {\n    const a = (i - 1 + n) % n;\n    const b = (i + 1) % n;\n    const h1 = Math.atan2(o.y[i] - o.y[a], o.x[i] - o.x[a]);\n    const h2 = Math.atan2(o.y[b] - o.y[i], o.x[b] - o.x[i]);\n    let dth = h2 - h1;\n    while (dth > Math.PI) dth -= 2 * Math.PI;\n    while (dth < -Math.PI) dth += 2 * Math.PI;\n    const arcUnits = Math.hypot(o.x[i] - o.x[a], o.y[i] - o.y[a]) + Math.hypot(o.x[b] - o.x[i], o.y[b] - o.y[i]);\n    const arcM = arcUnits * proj.mPerUnit || 1;\n    k[i] = Math.abs(dth) / arcM; // rad per metre\n  }\n  // light smoothing (curvature from a discrete line is noisy)\n  const sm = new Float64Array(n);\n  for (let i = 0; i < n; i++) sm[i] = (k[(i - 1 + n) % n] + k[i] * 2 + k[(i + 1) % n]) / 4;\n  curvCache.set(o, sm);\n  return sm;\n}\n\nconst GRAV = 9.80665;\n\nexport interface LapAnalytics {\n  num: string;\n  lapMs: number;\n  topSpeed: number; // km/h\n  avgSpeed: number; // km/h\n  fullThrottlePct: number; // % of the lap at ≥98% throttle\n  heavyBrakePct: number; // % of the lap braking (brk ≥ 50)\n  maxLatG: number; // peak lateral g (racing-line estimate)\n  samples: number;\n}\n\n/** driving-style + G analytics for a car's lap window (telemetry × geometry) */\nexport function lapAnalytics(engine: EngineLike, num: string, lap: LapWindow): LapAnalytics | null {\n  const b = engine.bundle;\n  const proj = projectorFor(engine as never);\n  const car = b?.car[num];\n  if (!b || !proj || !car || !car.t.length) return null;\n  const curv = outlineCurvature(proj);\n  const n = proj.o.x.length;\n  let top = 0, sumV = 0, full = 0, brake = 0, count = 0, maxG = 0;\n  for (let i = 0; i < car.t.length; i++) {\n    const t = car.t[i];\n    if (t < lap.tStart || t > lap.tEnd) continue;\n    const v = car.spd[i];\n    count++;\n    sumV += v;\n    if (v > top) top = v;\n    if (car.thr[i] >= 98) full++;\n    if (car.brk[i] >= 50) brake++;\n    // lateral G at this instant: project the car's smoothed position → curvature\n    const p = engine.carPos(num, t);\n    if (p) {\n      const pr = proj.project(p.x, p.y);\n      const k = curv[Math.min(n - 1, pr.seg)];\n      const vms = v / 3.6;\n      const g = (vms * vms * k) / GRAV;\n      if (g > maxG && g < 8) maxG = g; // clamp absurd spikes from line noise\n    }\n  }\n  if (count < 5) return null;\n  return {\n    num,\n    lapMs: lap.dur,\n    topSpeed: Math.round(top),\n    avgSpeed: Math.round(sumV / count),\n    fullThrottlePct: Math.round((full / count) * 100),\n    heavyBrakePct: Math.round((brake / count) * 100),\n    maxLatG: Math.round(maxG * 10) / 10,\n    samples: count,\n  };\n}\n\n/** convenience: analytics for a driver's fastest lap */\nexport function fastestLapAnalytics(engine: EngineLike, num: string): LapAnalytics | null {\n  const lap = fastestLapWindow(engine as never, num);\n  if (!lap) return null;\n  return lapAnalytics(engine, num, lap);\n}\n\n// ---- live on-track order + gap-in-metres (true geometry, not at-the-line) ----\nexport interface OnTrackRow {\n  num: string;\n  s: number; // metres along the current lap from S/F\n  gapAheadM: number | null; // metres to the car physically ahead on track\n}\n\n/**\n * True on-track order at time t from each car's projected s — this leads the\n * classification line (which only updates at S/F). Cars not on a flying lap\n * (no fix / stopped) are dropped. NOTE: orders by current-lap s only (good for a\n * single-lap snapshot / quali out-laps); cross-lap race order needs lap counting.\n */\nexport function onTrackOrder(engine: EngineLike, t: number): OnTrackRow[] {\n  const b = engine.bundle;\n  const proj = projectorFor(engine as never);\n  if (!b || !proj) return [];\n  const rows: OnTrackRow[] = [];\n  for (const num of Object.keys(b.pos)) {\n    const ct = carTrack(engine as never, num);\n    if (!ct || !ct.t.length) continue;\n    // nearest sample at-or-before t (skip if stale > 5s)\n    let lo = 0, hi = ct.t.length - 1;\n    if (t < ct.t[0] || t > ct.t[hi] + 5000) continue;\n    while (lo < hi) {\n      const mid = (lo + hi + 1) >> 1;\n      if (ct.t[mid] <= t) lo = mid;\n      else hi = mid - 1;\n    }\n    if (t - ct.t[lo] > 5000) continue;\n    rows.push({ num, s: ct.s[lo], gapAheadM: null });\n  }\n  rows.sort((a, b2) => b2.s - a.s); // furthest along first\n  for (let i = 1; i < rows.length; i++) rows[i].gapAheadM = Math.round(rows[i - 1].s - rows[i].s);\n  return rows;\n}\n"}]},{"slug":"ui/badge","title":"Badge","type":"ui","summary":"Status chips with tone semantics (accent, good, warn, bad, neutral) in each language: lit signal chip, hairline broadsheet tag, solid painted block.","tags":["status","variants","theme-aware"],"origin":"","provenance":[],"deps":{"npm":["react"],"kit":[]},"themeAware":true,"entry":"Apex.tsx","files":[{"path":"Apex.tsx","lang":"tsx","source":"\"use client\";\nimport * as React from \"react\";\n\n/* Apex Badge - instrument status chip: mono caps, tinted fill, lit signal dot. */\n\nexport function Badge({ tone = \"neutral\", children }: { tone?: \"accent\" | \"good\" | \"warn\" | \"bad\" | \"neutral\"; children: React.ReactNode }) {\n  const c = tone === \"accent\" ? \"var(--accent)\" : tone === \"neutral\" ? \"var(--ink-dim)\" : `var(--${tone})`;\n  return (\n    <span style={{\n      display: \"inline-flex\", alignItems: \"center\", gap: 5,\n      fontFamily: \"var(--font-mono)\", fontSize: 10.5, fontWeight: 600,\n      textTransform: \"uppercase\", letterSpacing: \"0.08em\",\n      padding: \"3px 9px\", borderRadius: 999, color: c,\n      background: `color-mix(in srgb, ${c} 16%, transparent)`,\n      border: `1px solid color-mix(in srgb, ${c} 40%, transparent)`,\n    }}>\n      {tone !== \"neutral\" && (\n        <span style={{\n          width: 5, height: 5, borderRadius: 999, background: c,\n          boxShadow: `0 0 5px ${c}`, flex: \"0 0 auto\",\n        }} />\n      )}\n      {children}\n    </span>\n  );\n}\n"},{"path":"Tifo.tsx","lang":"tsx","source":"\"use client\";\nimport React from \"react\";\n\n/* Tifo Badge - flat broadsheet chip: sharp corners, hairline tone border, mono caps. */\n\nexport function Badge(props: {\n  tone?: \"accent\" | \"good\" | \"warn\" | \"bad\" | \"neutral\";\n  children: React.ReactNode;\n}) {\n  const { tone = \"neutral\", children } = props;\n  const c: string = {\n    accent: \"var(--accent)\",\n    good: \"var(--good)\",\n    warn: \"var(--warn)\",\n    bad: \"var(--bad)\",\n    neutral: \"var(--ink-dim)\",\n  }[tone];\n\n  return (\n    <span style={{\n      display: \"inline-flex\", alignItems: \"center\", borderRadius: 3,\n      fontFamily: \"var(--font-mono)\", textTransform: \"uppercase\",\n      fontSize: 10.5, letterSpacing: \"0.04em\", fontWeight: 600, lineHeight: 1,\n      padding: \"4px 7px\", background: \"var(--surface-2)\",\n      border: `1px solid color-mix(in srgb, ${c} 45%, var(--line))`,\n      color: c,\n    }}>{children}</span>\n  );\n}\n"},{"path":"Ink.tsx","lang":"tsx","source":"\"use client\";\nimport React from \"react\";\n\n/* Ink Badge - solid painted chip: square tone block + caps label, tiny cel step.\n   No tinting tricks, no glow - flat committed colour. */\n\nexport function Badge(props: {\n  tone?: \"accent\" | \"good\" | \"warn\" | \"bad\" | \"neutral\";\n  children: React.ReactNode;\n}) {\n  const { tone = \"neutral\", children } = props;\n  const c: string = {\n    accent: \"var(--accent)\",\n    good: \"var(--good)\",\n    warn: \"var(--warn)\",\n    bad: \"var(--bad)\",\n    neutral: \"var(--ink-dim)\",\n  }[tone];\n\n  return (\n    <span style={{\n      display: \"inline-flex\", alignItems: \"center\", gap: 7,\n      fontFamily: \"var(--font-display)\", textTransform: \"uppercase\",\n      fontSize: 11, letterSpacing: \"0.07em\", fontWeight: 700, lineHeight: 1,\n      padding: \"4px 8px\", background: \"var(--surface-2)\", color: \"var(--ink)\",\n      boxShadow: `2px 2px 0 color-mix(in srgb, var(--bg) 40%, #000)`,\n      borderRadius: 2,\n    }}>\n      <span style={{ width: 7, height: 7, background: c, boxShadow: `1.5px 1.5px 0 color-mix(in srgb, ${c} 55%, #000)`, flex: \"0 0 auto\" }} />\n      {children}\n    </span>\n  );\n}\n"}]},{"slug":"ui/button","title":"Button","type":"ui","summary":"Primary, secondary and ghost actions in three committed languages: instrument pill, broadsheet plate, painted poster block. Self-contained, theme-aware, copy one file.","tags":["action","variants","theme-aware"],"origin":"","provenance":[],"deps":{"npm":["react"],"kit":[]},"themeAware":true,"entry":"Apex.tsx","files":[{"path":"Apex.tsx","lang":"tsx","source":"\"use client\";\nimport * as React from \"react\";\n\n/* Apex Button - racing-instrument pill: mono caps, accent fill with a soft signal\n   bloom (this family's shipped look), outline + ghost steps. Colour from theme vars. */\n\nconst INK_ON_ACCENT = \"#06070d\";\n\ntype Variant = \"primary\" | \"secondary\" | \"ghost\";\n\nconst base: React.CSSProperties = {\n  display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", gap: 7,\n  fontFamily: \"var(--font-mono)\", fontWeight: 600, fontSize: 11.5, lineHeight: 1,\n  textTransform: \"uppercase\", letterSpacing: \"0.12em\", whiteSpace: \"nowrap\",\n  padding: \"8px 16px\", borderRadius: 999, border: \"1px solid transparent\",\n  cursor: \"pointer\", transition: \"background .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease, filter .16s ease\",\n};\n\nexport function Button({\n  variant = \"primary\", children, style, ...rest\n}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant }) {\n  const [h, setH] = React.useState(false);\n  const v: Record<Variant, React.CSSProperties> = {\n    primary: {\n      background: \"var(--accent)\", color: INK_ON_ACCENT,\n      boxShadow: `0 0 18px -2px color-mix(in srgb, var(--accent) ${h ? 72 : 55}%, transparent)`,\n      filter: h ? \"brightness(1.06)\" : \"none\",\n    },\n    secondary: {\n      background: h ? \"color-mix(in srgb, var(--accent) 14%, transparent)\" : \"transparent\",\n      color: \"var(--accent)\", border: \"1px solid color-mix(in srgb, var(--accent) 42%, var(--line))\",\n    },\n    ghost: {\n      background: h ? \"color-mix(in srgb, var(--accent) 10%, transparent)\" : \"transparent\",\n      color: h ? \"var(--ink)\" : \"var(--ink-dim)\",\n    },\n  };\n  return (\n    <button {...rest} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}\n      style={{ ...base, ...v[variant], ...style }}>\n      {children}\n    </button>\n  );\n}\n"},{"path":"Tifo.tsx","lang":"tsx","source":"\"use client\";\nimport React, { useState } from \"react\";\n\n/* Tifo Button - broadsheet matchday: sharp 3px corners, condensed display caps\n   with wide tracking, flat accent strike-fill. No glow anywhere. */\n\nconst T = \"color 120ms ease, background-color 120ms ease, border-color 120ms ease\";\nconst ACCENT_INK = \"#08080a\";\n\nexport function Button(\n  props: React.ButtonHTMLAttributes<HTMLButtonElement> & {\n    variant?: \"primary\" | \"secondary\" | \"ghost\";\n  }\n) {\n  const { variant = \"secondary\", style, onMouseEnter, onMouseLeave, ...rest } = props;\n  const [hover, setHover] = useState(false);\n\n  const base: React.CSSProperties = {\n    display: \"inline-flex\", alignItems: \"center\", gap: 8, borderRadius: 3,\n    fontFamily: \"var(--font-display)\", textTransform: \"uppercase\",\n    letterSpacing: \"0.14em\", fontWeight: 700, fontSize: 13, lineHeight: 1,\n    padding: \"9px 16px\", cursor: \"pointer\", transition: T,\n  };\n\n  let v: React.CSSProperties;\n  let hv: React.CSSProperties = {};\n  if (variant === \"primary\") {\n    v = { background: \"var(--accent)\", border: \"1px solid var(--accent)\", color: ACCENT_INK };\n    hv = hover ? { background: \"var(--accent-2)\", borderColor: \"var(--accent-2)\" } : {};\n  } else if (variant === \"ghost\") {\n    v = { background: \"transparent\", border: \"1px solid var(--line)\", color: \"var(--ink-dim)\" };\n    hv = hover ? { color: \"var(--ink)\" } : {};\n  } else {\n    v = { background: \"var(--surface-2)\", border: \"1px solid var(--line)\", color: \"var(--ink-dim)\" };\n    hv = hover ? { borderColor: \"var(--accent)\", color: \"var(--ink)\" } : {};\n  }\n\n  return (\n    <button\n      {...rest}\n      onMouseEnter={(e) => { setHover(true); onMouseEnter?.(e); }}\n      onMouseLeave={(e) => { setHover(false); onMouseLeave?.(e); }}\n      style={{ ...base, ...v, ...hv, ...style }}\n    />\n  );\n}\n"},{"path":"Ink.tsx","lang":"tsx","source":"\"use client\";\nimport React, { useState } from \"react\";\n\n/* Ink Button - painted poster plate: condensed display caps, solid accent fill,\n   double-plate cel shadow (the accent's own darker step, then near-black). Zero\n   blur, zero glow - depth is stacked solid colour. Born in the library. */\n\nconst T = \"background-color 140ms ease, transform 140ms cubic-bezier(0.22,1,0.36,1), box-shadow 140ms cubic-bezier(0.22,1,0.36,1)\";\nconst PUNCH = \"color-mix(in srgb, var(--bg) 40%, #000)\";\n\nexport function Button(\n  props: React.ButtonHTMLAttributes<HTMLButtonElement> & {\n    variant?: \"primary\" | \"secondary\" | \"ghost\";\n  }\n) {\n  const { variant = \"primary\", style, onMouseEnter, onMouseLeave, ...rest } = props;\n  const [hover, setHover] = useState(false);\n  const shade = \"color-mix(in srgb, var(--accent) 62%, #000)\";\n\n  const base: React.CSSProperties = {\n    display: \"inline-flex\", alignItems: \"center\", gap: 8, borderRadius: 2,\n    fontFamily: \"var(--font-display)\", textTransform: \"uppercase\",\n    letterSpacing: \"0.06em\", fontWeight: 700, fontSize: 13.5, lineHeight: 1,\n    padding: \"10px 18px\", border: \"none\", cursor: \"pointer\", transition: T,\n    transform: hover ? \"translate(-2px, -2px)\" : \"none\",\n  };\n\n  let v: React.CSSProperties;\n  if (variant === \"primary\") {\n    v = {\n      background: hover ? \"color-mix(in srgb, var(--accent) 88%, #fff)\" : \"var(--accent)\",\n      color: PUNCH,\n      boxShadow: hover ? `5px 5px 0 ${shade}, 9px 9px 0 ${PUNCH}` : `3px 3px 0 ${shade}, 6px 6px 0 ${PUNCH}`,\n    };\n  } else if (variant === \"ghost\") {\n    v = {\n      background: \"transparent\", color: \"var(--ink-dim)\",\n      boxShadow: hover ? `0 3px 0 var(--accent)` : \"0 3px 0 var(--line)\",\n      borderRadius: 0, paddingInline: 6,\n    };\n  } else {\n    v = {\n      background: \"var(--surface-2)\", color: \"var(--ink)\",\n      boxShadow: hover ? `5px 5px 0 ${PUNCH}` : `3px 3px 0 ${PUNCH}`,\n    };\n  }\n\n  return (\n    <button\n      {...rest}\n      onMouseEnter={(e) => { setHover(true); onMouseEnter?.(e); }}\n      onMouseLeave={(e) => { setHover(false); onMouseLeave?.(e); }}\n      style={{ ...base, ...v, ...style }}\n    />\n  );\n}\n"}]},{"slug":"ui/card","title":"Card","type":"ui","summary":"Content panels with kicker, title, body and footer slots: edge-lit instrument panel, hairline broadsheet frame, offset poster plate.","tags":["surface","variants","theme-aware"],"origin":"","provenance":[],"deps":{"npm":["react"],"kit":[]},"themeAware":true,"entry":"Apex.tsx","files":[{"path":"Apex.tsx","lang":"tsx","source":"\"use client\";\nimport * as React from \"react\";\n\n/* Apex Card - rounded instrument panel: accent edge-light, lit kicker dot,\n   lift + accent border on hover. Colour from theme vars. */\n\nexport function Card({ kicker, title, sub, footer, children, style }: {\n  kicker?: React.ReactNode; title?: React.ReactNode; sub?: React.ReactNode; footer?: React.ReactNode; children?: React.ReactNode; style?: React.CSSProperties;\n}) {\n  const [h, setH] = React.useState(false);\n  return (\n    <div\n      onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}\n      style={{\n        position: \"relative\", borderRadius: 16, overflow: \"hidden\",\n        background: \"var(--surface)\", color: \"var(--ink)\", fontFamily: \"var(--font-ui)\",\n        border: `1px solid ${h ? \"color-mix(in srgb, var(--accent) 45%, var(--line))\" : \"var(--line)\"}`,\n        transform: h ? \"translateY(-3px)\" : \"translateY(0)\",\n        boxShadow: h ? \"0 14px 34px -16px color-mix(in srgb, var(--accent) 40%, transparent), 0 10px 28px -18px rgba(0,0,0,0.7)\" : \"none\",\n        transition: \"transform .18s ease, border-color .18s ease, box-shadow .18s ease\",\n        ...style,\n      }}>\n      <div style={{\n        position: \"absolute\", top: 0, left: 0, right: 0, height: 2,\n        background: \"linear-gradient(90deg, var(--accent), transparent 70%)\",\n      }} />\n      {(kicker || title || sub) && (\n        <div style={{ padding: \"16px 18px 12px\" }}>\n          {kicker && (\n            <div style={{ display: \"flex\", alignItems: \"center\", gap: 7, marginBottom: 8 }}>\n              <span style={{\n                width: 6, height: 6, borderRadius: 999, background: \"var(--accent)\",\n                boxShadow: \"0 0 7px var(--accent)\", flex: \"0 0 auto\",\n              }} />\n              <span style={{\n                fontFamily: \"var(--font-mono)\", fontSize: 10, textTransform: \"uppercase\",\n                letterSpacing: \"1.4px\", color: \"var(--ink-faint)\",\n              }}>{kicker}</span>\n            </div>\n          )}\n          {title && <div style={{ fontFamily: \"var(--font-display)\", fontWeight: 700, fontSize: 17, letterSpacing: \"-0.01em\" }}>{title}</div>}\n          {sub && <div style={{ color: \"var(--ink-dim)\", fontSize: 13, marginTop: 3 }}>{sub}</div>}\n        </div>\n      )}\n      {children && <div style={{ padding: \"0 18px 16px\", color: \"var(--ink-dim)\", fontSize: 13.5, lineHeight: 1.55 }}>{children}</div>}\n      {footer && <div style={{ padding: \"12px 18px\", borderTop: \"1px solid var(--line)\", display: \"flex\", alignItems: \"center\", gap: 10, fontSize: 13 }}>{footer}</div>}\n    </div>\n  );\n}\n"},{"path":"Tifo.tsx","lang":"tsx","source":"\"use client\";\nimport React from \"react\";\n\n/* Tifo Card - broadsheet panel: hairline frame, header rule, condensed caps title,\n   mono kicker in the accent. Flat; the type does the work. */\n\nexport function Card(props: {\n  kicker?: React.ReactNode;\n  title?: React.ReactNode;\n  sub?: React.ReactNode;\n  footer?: React.ReactNode;\n  children?: React.ReactNode;\n  style?: React.CSSProperties;\n}) {\n  const { kicker, title, sub, footer, children, style } = props;\n  const hasHeader = kicker !== undefined || title !== undefined || sub !== undefined;\n\n  return (\n    <div style={{\n      borderRadius: 3, background: \"var(--surface)\", border: \"1px solid var(--line)\",\n      overflow: \"hidden\", fontFamily: \"var(--font-ui)\", color: \"var(--ink-dim)\",\n      ...style,\n    }}>\n      {hasHeader ? (\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: 10, padding: \"10px 14px\", borderBottom: \"1px solid var(--line)\" }}>\n          {kicker !== undefined ? (\n            <span style={{ fontFamily: \"var(--font-mono)\", fontWeight: 800, fontSize: 12, lineHeight: 1, color: \"var(--accent)\" }}>{kicker}</span>\n          ) : null}\n          {title !== undefined ? (\n            <h3 style={{\n              margin: 0, fontFamily: \"var(--font-display)\", fontWeight: 800, fontSize: 16,\n              lineHeight: 1.1, textTransform: \"uppercase\", letterSpacing: \"0.16em\", color: \"var(--ink)\",\n            }}>{title}</h3>\n          ) : null}\n          {sub !== undefined ? (\n            <span style={{ marginLeft: \"auto\", fontFamily: \"var(--font-mono)\", fontSize: 10.5, textTransform: \"uppercase\", letterSpacing: \"0.04em\", color: \"var(--ink-faint)\" }}>{sub}</span>\n          ) : null}\n        </div>\n      ) : null}\n      {children !== undefined ? <div style={{ padding: 14, fontSize: 13.5, lineHeight: 1.6 }}>{children}</div> : null}\n      {footer !== undefined ? <div style={{ padding: \"10px 14px\", borderTop: \"1px solid var(--line)\" }}>{footer}</div> : null}\n    </div>\n  );\n}\n"},{"path":"Ink.tsx","lang":"tsx","source":"\"use client\";\nimport React, { useState } from \"react\";\n\n/* Ink Card - a poster plate: solid surface offset by its own darker step and a\n   near-black plate (stacked solid colour = the shading). Condensed caps header\n   over a hard rule. Hover lifts the plate off the stack. */\n\nexport function Card(props: {\n  kicker?: React.ReactNode;\n  title?: React.ReactNode;\n  sub?: React.ReactNode;\n  footer?: React.ReactNode;\n  children?: React.ReactNode;\n  style?: React.CSSProperties;\n}) {\n  const { kicker, title, sub, footer, children, style } = props;\n  const [h, setH] = useState(false);\n  const punch = \"color-mix(in srgb, var(--bg) 40%, #000)\";\n  const hasHeader = kicker !== undefined || title !== undefined || sub !== undefined;\n\n  return (\n    <div\n      onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}\n      style={{\n        borderRadius: 2, background: \"var(--surface)\", color: \"var(--ink-dim)\",\n        fontFamily: \"var(--font-ui)\",\n        boxShadow: h\n          ? `6px 6px 0 color-mix(in srgb, var(--accent) 45%, var(--surface-2)), 12px 12px 0 ${punch}`\n          : `4px 4px 0 color-mix(in srgb, var(--accent) 30%, var(--surface-2)), 8px 8px 0 ${punch}`,\n        transform: h ? \"translate(-2px, -2px)\" : \"none\",\n        transition: \"transform 160ms cubic-bezier(0.22,1,0.36,1), box-shadow 160ms cubic-bezier(0.22,1,0.36,1)\",\n        ...style,\n      }}>\n      {hasHeader ? (\n        <div style={{ display: \"flex\", alignItems: \"baseline\", gap: 10, padding: \"13px 16px 11px\", borderBottom: `3px solid ${punch}` }}>\n          {kicker !== undefined ? (\n            <span style={{ width: 9, height: 9, background: \"var(--accent)\", alignSelf: \"center\", boxShadow: `2px 2px 0 color-mix(in srgb, var(--accent) 55%, #000)` }} aria-hidden />\n          ) : null}\n          {title !== undefined ? (\n            <h3 style={{\n              margin: 0, fontFamily: \"var(--font-display)\", fontWeight: 700, fontSize: 16,\n              lineHeight: 1.05, textTransform: \"uppercase\", letterSpacing: \"0.04em\", color: \"var(--ink)\",\n            }}>{title}</h3>\n          ) : null}\n          {sub !== undefined ? (\n            <span style={{ marginLeft: \"auto\", fontFamily: \"var(--font-display)\", fontWeight: 700, fontSize: 11, textTransform: \"uppercase\", letterSpacing: \"0.06em\", color: \"var(--ink-faint)\" }}>{sub}</span>\n          ) : null}\n        </div>\n      ) : null}\n      {children !== undefined ? <div style={{ padding: \"13px 16px\", fontSize: 13.5, lineHeight: 1.6 }}>{children}</div> : null}\n      {footer !== undefined ? <div style={{ padding: \"11px 16px\", borderTop: \"1px solid var(--line)\", display: \"flex\", alignItems: \"center\", gap: 10 }}>{footer}</div> : null}\n    </div>\n  );\n}\n"}]},{"slug":"ui/input","title":"Input","type":"ui","summary":"Labelled text fields with three focus behaviours: console ring, hairline accent border, sunk well with an accent underline plate.","tags":["form","variants","theme-aware"],"origin":"","provenance":[],"deps":{"npm":["react"],"kit":[]},"themeAware":true,"entry":"Apex.tsx","files":[{"path":"Apex.tsx","lang":"tsx","source":"\"use client\";\nimport * as React from \"react\";\n\n/* Apex Input - pill field with a focus ring in the accent (instrument console feel). */\n\nexport function Input({ label, ...rest }: React.InputHTMLAttributes<HTMLInputElement> & { label?: string }) {\n  const [f, setF] = React.useState(false);\n  return (\n    <label style={{ display: \"block\", fontFamily: \"var(--font-ui)\" }}>\n      {label && (\n        <span style={{\n          display: \"block\", fontFamily: \"var(--font-mono)\", fontSize: 10,\n          textTransform: \"uppercase\", letterSpacing: \"1.2px\",\n          color: \"var(--ink-faint)\", marginBottom: 6,\n        }}>{label}</span>\n      )}\n      <input {...rest}\n        onFocus={(e) => { setF(true); rest.onFocus?.(e); }}\n        onBlur={(e) => { setF(false); rest.onBlur?.(e); }}\n        style={{\n          width: \"100%\", padding: \"9px 15px\", fontSize: 13.5, fontFamily: \"var(--font-ui)\",\n          color: \"var(--ink)\", background: \"var(--surface)\", borderRadius: 999,\n          border: `1px solid ${f ? \"var(--accent)\" : \"var(--line)\"}`,\n          boxShadow: f ? \"0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent)\" : \"none\",\n          outline: \"none\", transition: \"border-color .16s ease, box-shadow .16s ease\",\n        }} />\n    </label>\n  );\n}\n"},{"path":"Tifo.tsx","lang":"tsx","source":"\"use client\";\nimport React, { useState, useId } from \"react\";\n\n/* Tifo Input - flat hairline field, sharp corners, accent border on focus. No ring. */\n\nexport function Input(\n  props: React.InputHTMLAttributes<HTMLInputElement> & { label?: string }\n) {\n  const { label, style, onFocus, onBlur, id, ...rest } = props;\n  const [focus, setFocus] = useState(false);\n  const autoId = useId();\n  const inputId = id ?? (label ? `${autoId}-inp` : undefined);\n\n  return (\n    <span style={{ display: \"flex\", flexDirection: \"column\", gap: 6 }}>\n      {label ? (\n        <label htmlFor={inputId} style={{\n          fontFamily: \"var(--font-mono)\", textTransform: \"uppercase\",\n          fontSize: 10, letterSpacing: \"1.2px\", color: \"var(--ink-faint)\",\n        }}>{label}</label>\n      ) : null}\n      <input\n        {...rest}\n        id={inputId}\n        onFocus={(e) => { setFocus(true); onFocus?.(e); }}\n        onBlur={(e) => { setFocus(false); onBlur?.(e); }}\n        style={{\n          borderRadius: 3, background: \"var(--surface-2)\",\n          border: `1px solid ${focus ? \"var(--accent)\" : \"var(--line)\"}`,\n          color: \"var(--ink)\", fontFamily: \"var(--font-ui)\", fontSize: 13.5,\n          lineHeight: 1.4, padding: \"8px 11px\", outline: \"none\",\n          transition: \"border-color 120ms ease\",\n          ...style,\n        }}\n      />\n    </span>\n  );\n}\n"},{"path":"Ink.tsx","lang":"tsx","source":"\"use client\";\nimport React, { useState, useId } from \"react\";\n\n/* Ink Input - a flat well sunk into the canvas; focus swaps the underline plate\n   to the accent. Labels in condensed caps. No ring, no glow. */\n\nexport function Input(\n  props: React.InputHTMLAttributes<HTMLInputElement> & { label?: string }\n) {\n  const { label, style, onFocus, onBlur, id, ...rest } = props;\n  const [focus, setFocus] = useState(false);\n  const autoId = useId();\n  const inputId = id ?? (label ? `${autoId}-inp` : undefined);\n  const punch = \"color-mix(in srgb, var(--bg) 40%, #000)\";\n\n  return (\n    <span style={{ display: \"flex\", flexDirection: \"column\", gap: 6 }}>\n      {label ? (\n        <label htmlFor={inputId} style={{\n          fontFamily: \"var(--font-display)\", textTransform: \"uppercase\",\n          fontSize: 11, letterSpacing: \"0.07em\", fontWeight: 700, color: \"var(--ink-dim)\",\n        }}>{label}</label>\n      ) : null}\n      <input\n        {...rest}\n        id={inputId}\n        onFocus={(e) => { setFocus(true); onFocus?.(e); }}\n        onBlur={(e) => { setFocus(false); onBlur?.(e); }}\n        style={{\n          borderRadius: 2, background: \"color-mix(in srgb, var(--bg) 82%, #000)\",\n          border: \"none\",\n          boxShadow: focus ? `inset 0 -3px 0 var(--accent), 2px 2px 0 ${punch}` : `inset 0 -3px 0 ${punch}`,\n          color: \"var(--ink)\", fontFamily: \"var(--font-ui)\", fontSize: 13.5,\n          lineHeight: 1.4, padding: \"9px 12px\", outline: \"none\",\n          transition: \"box-shadow 140ms ease\",\n          ...style,\n        }}\n      />\n    </span>\n  );\n}\n"}]},{"slug":"util/chart-axis","title":"Chart Axis & Scales","type":"util","summary":"Dependency-free chart math: linScale with .invert (hit-testing), niceTicks (1/2/5 x 10^n), lap/gap/clock formatters, linePath. The backbone under every SVG chart.","tags":["charts","scale","svg","ticks"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"axis.ts","files":[{"path":"axis.ts","lang":"ts","source":"// Pure, dependency-free chart helpers (SVG- and canvas-agnostic). Shared by every\n// Track-1 chart (compare panels + season standings evolution). No React, no DOM.\n\nexport interface Scale {\n  (v: number): number;\n  invert: (px: number) => number;\n  domain: [number, number];\n  range: [number, number];\n}\n\n/** linear scale domain -> range, with .invert for hit-testing */\nexport function linScale(domain: [number, number], range: [number, number]): Scale {\n  const [d0, d1] = domain;\n  const [r0, r1] = range;\n  const m = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0);\n  const f = ((v: number) => r0 + (v - d0) * m) as Scale;\n  f.invert = (px: number) => (m === 0 ? d0 : d0 + (px - r0) / m);\n  f.domain = domain;\n  f.range = range;\n  return f;\n}\n\n/** \"nice\" round tick values across [min,max] (1/2/5 * 10^n steps) */\nexport function niceTicks(min: number, max: number, count = 5): number[] {\n  if (!isFinite(min) || !isFinite(max) || min === max) return [min];\n  const step0 = (max - min) / Math.max(1, count);\n  const mag = Math.pow(10, Math.floor(Math.log10(step0)));\n  const norm = step0 / mag;\n  const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag;\n  const start = Math.ceil(min / step) * step;\n  const out: number[] = [];\n  for (let v = start; v <= max + step * 1e-6; v += step) out.push(Number(v.toPrecision(12)));\n  return out;\n}\n\n/** lap time ms -> \"1:18.305\" (or \"18.305\" under a minute) */\nexport function fmtLapMs(ms: number): string {\n  if (!isFinite(ms) || ms <= 0) return \"—\";\n  const m = Math.floor(ms / 60000);\n  const s = (ms % 60000) / 1000;\n  return m > 0 ? `${m}:${s.toFixed(3).padStart(6, \"0\")}` : s.toFixed(3);\n}\n\n/** signed gap seconds -> \"+1.234\" / \"−0.500\" (true minus glyph) */\nexport function fmtGap(s: number): string {\n  if (!isFinite(s)) return \"—\";\n  const sign = s > 0 ? \"+\" : s < 0 ? \"−\" : \"\";\n  return `${sign}${Math.abs(s).toFixed(3)}`;\n}\n\n/** ms -> \"M:SS\" short clock for axis labels */\nexport function fmtClockShort(ms: number): string {\n  const total = Math.floor(ms / 1000);\n  const m = Math.floor(total / 60);\n  return `${m}:${String(total % 60).padStart(2, \"0\")}`;\n}\n\n/** path string for a polyline from [x,y] screen points (skips null = gap break) */\nexport function linePath(points: ([number, number] | null)[]): string {\n  let d = \"\";\n  let pen = false;\n  for (const p of points) {\n    if (!p) {\n      pen = false;\n      continue;\n    }\n    d += `${pen ? \"L\" : \"M\"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`;\n    pen = true;\n  }\n  return d;\n}\n"}]},{"slug":"util/cx","title":"cx (classNames)","type":"util","summary":"Tiny isomorphic className joiner - strings, numbers, arrays, and { class: boolean } objects, falsy-skipping. No deps.","tags":["classnames","ssr","helper"],"origin":"zq-kit","provenance":["f1peak","wc26"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"cx.ts","files":[{"path":"cx.ts","lang":"ts","source":"// Tiny className joiner (isomorphic, no deps).\nexport type ClassValue =\n  | string\n  | number\n  | null\n  | false\n  | undefined\n  | Record<string, boolean | null | undefined>\n  | ClassValue[];\n\nexport function cx(...parts: ClassValue[]): string {\n  const out: string[] = [];\n  for (const p of parts) {\n    if (!p) continue;\n    if (typeof p === \"string\" || typeof p === \"number\") out.push(String(p));\n    else if (Array.isArray(p)) {\n      const s = cx(...p);\n      if (s) out.push(s);\n    } else if (typeof p === \"object\") {\n      for (const k in p) if (p[k]) out.push(k);\n    }\n  }\n  return out.join(\" \");\n}\n"}]},{"slug":"util/format","title":"Display Formatters","type":"util","summary":"Pure presentation helpers: clockMMSS, pct, surname, ordinal, luminance/inkOn (readable ink over a colour), rgba/hex. No domain logic.","tags":["format","display","colour","helper"],"origin":"zq-kit","provenance":["f1peak","wc26"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"format.ts","files":[{"path":"format.ts","lang":"ts","source":"// Generic display formatters (isomorphic). No domain logic — pure presentation.\n\n/** Seconds → \"M:SS\" (clock). */\nexport const clockMMSS = (secs: number): string => {\n  const s = Math.max(0, Math.floor(secs));\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n};\n\n/** 0..1 (or 0..100) → \"NN%\". Pass `of100` when the input is already a percentage. */\nexport const pct = (v: number, digits = 0, of100 = false): string =>\n  `${(of100 ? v : v * 100).toFixed(digits)}%`;\n\n/** Last token of a full name (\"Vinícius Júnior\" → \"Júnior\"). */\nexport const surname = (name?: string | null): string => {\n  const p = (name || \"\").trim().split(/\\s+/);\n  return p.length > 1 ? p[p.length - 1] : name || \"\";\n};\n\n/** Ordinal suffix (\"1st\", \"2nd\", \"3rd\", \"11th\"). */\nexport const ordinal = (n: number): string => {\n  const s = [\"th\", \"st\", \"nd\", \"rd\"];\n  const v = n % 100;\n  return n + (s[(v - 20) % 10] || s[v] || s[0]);\n};\n\n/** HTML-escape for values interpolated into raw SVG/markup strings. */\nexport const esc = (s?: string | null): string =>\n  (s || \"\").replace(/&/g, \"&amp;\").replace(/\"/g, \"&quot;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n\n/** Relative luminance of a hex colour (0..1). */\nexport const luminance = (hex?: string | null): number => {\n  if (!hex) return 0;\n  const h = hex.replace(\"#\", \"\");\n  if (h.length < 6) return 0;\n  return (\n    (0.299 * parseInt(h.slice(0, 2), 16) +\n      0.587 * parseInt(h.slice(2, 4), 16) +\n      0.114 * parseInt(h.slice(4, 6), 16)) /\n    255\n  );\n};\n\n/** Readable ink (#08080A / #fff) over a given background hex. */\nexport const inkOn = (hex?: string | null): string => (luminance(hex) > 0.6 ? \"#08080A\" : \"#FFFFFF\");\n\n/** hex → \"rgba(r,g,b,a)\". */\nexport const rgba = (hex: string, a: number): string => {\n  const h = (hex || \"\").replace(\"#\", \"\");\n  return `rgba(${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)},${a})`;\n};\n\n/** Normalize a colour to a leading-# hex, or null. */\nexport const hex = (c?: string | null): string | null =>\n  c ? (c.startsWith(\"#\") ? c : \"#\" + c) : null;\n"}]},{"slug":"util/map-limit","title":"mapLimit (concurrency)","type":"util","summary":"Concurrent async map with a concurrency cap — apply an async fn across an array running at most N at a time, preserving order. A tiny worker pool for fan-out fetches/jobs. No deps.","tags":["async","concurrency","worker-pool","fetch"],"origin":"wc26","provenance":["wc26"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"map-limit.ts","files":[{"path":"map-limit.ts","lang":"ts","source":"// Concurrent async map with a concurrency cap. Applies `fn` across `items`, running at\n// most `limit` at a time, preserving input order. A tiny worker-pool — no deps.\n// Extracted from WC26 (fetching ~40 match details in parallel without hammering the upstream).\n//\n//   const details = await mapLimit(matchIds, 6, (id) => fetchMatch(id));\n\nexport async function mapLimit<T, R>(\n  items: T[],\n  limit: number,\n  fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n  const out: R[] = new Array(items.length);\n  let i = 0;\n  const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {\n    while (i < items.length) {\n      const idx = i++;\n      out[idx] = await fn(items[idx], idx);\n    }\n  });\n  await Promise.all(workers);\n  return out;\n}\n"}]},{"slug":"util/math","title":"Numeric Helpers","type":"util","summary":"Isomorphic data-viz math: clamp, lerp, invLerp, mapRange, round, sqrtScale (softer delta bars), and smoothPath (Catmull-Rom to bezier for smooth line/area charts).","tags":["math","dataviz","interpolation","svg"],"origin":"zq-kit","provenance":["f1peak","wc26"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"math.ts","files":[{"path":"math.ts","lang":"ts","source":"// Isomorphic numeric helpers used across data-viz.\n\nexport const clamp = (v: number, lo: number, hi: number): number =>\n  v < lo ? lo : v > hi ? hi : v;\n\nexport const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;\n\n/** Inverse lerp: where does v sit in [a,b] as 0..1 (clamped). */\nexport const invLerp = (a: number, b: number, v: number): number =>\n  b === a ? 0 : clamp((v - a) / (b - a), 0, 1);\n\n/** Remap v from [inMin,inMax] to [outMin,outMax]. */\nexport const mapRange = (\n  v: number,\n  inMin: number,\n  inMax: number,\n  outMin: number,\n  outMax: number,\n): number => lerp(outMin, outMax, invLerp(inMin, inMax, v));\n\n/** Round to n decimal places. */\nexport const round = (v: number, n = 0): number => {\n  const p = 10 ** n;\n  return Math.round(v * p) / p;\n};\n\n/** Perceptually softer compression for delta bars (matches the broadcast look). */\nexport const sqrtScale = (v: number): number => Math.sign(v) * Math.sqrt(Math.abs(v));\n\n/** Catmull-Rom → cubic-bezier path data through points (smooth area/line charts). */\nexport function smoothPath(pts: { x: number; y: number }[]): string {\n  if (pts.length < 2) return \"\";\n  let d = \"\";\n  for (let i = 0; i < pts.length - 1; i++) {\n    const p0 = pts[i - 1] || pts[i];\n    const p1 = pts[i];\n    const p2 = pts[i + 1];\n    const p3 = pts[i + 2] || p2;\n    const c1x = p1.x + (p2.x - p0.x) / 6;\n    const c1y = p1.y + (p2.y - p0.y) / 6;\n    const c2x = p2.x - (p3.x - p1.x) / 6;\n    const c2y = p2.y - (p3.y - p1.y) / 6;\n    d += `C ${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)} `;\n  }\n  return d;\n}\n"}]},{"slug":"util/swr-cache","title":"SWR Cache","type":"util","summary":"Generic in-memory stale-while-revalidate cache (TTL + inflight de-duplication) that wraps any async fetcher: serve fresh, serve stale + refresh in the background, await cold, serve last-good on error. Decoupled from WC26's ESPN service.","tags":["cache","swr","fetch","server","dedupe"],"origin":"wc26","provenance":["wc26"],"deps":{"npm":[],"kit":[]},"themeAware":false,"entry":"swr-cache.ts","files":[{"path":"swr-cache.ts","lang":"ts","source":"// Generic in-memory stale-while-revalidate cache: TTL + inflight de-duplication.\n// Extracted + decoupled from WC26's ESPN data service (the real caching layer when the\n// upstream sends `Cache-Control: max-age=1` and no ETag, so there's no 304 path).\n// Dependency-free; wraps any async fetcher.\n//   fresh  -> serve cached\n//   stale  -> serve stale immediately + refresh in the background\n//   cold   -> await the first fetch\n//   error  -> serve the last good value if we have one\n\nexport interface SwrEntry<T> {\n  data: T;\n  at: number;\n  inflight?: Promise<T>;\n}\n\nexport interface SwrCache<T> {\n  /** SWR read: fresh -> cached; stale -> stale + bg refresh; cold -> await. */\n  get(key: string): Promise<T>;\n  /** Always fetch (warm the cache + dedupe concurrent calls) — e.g. a live poller. */\n  fresh(key: string): Promise<T>;\n  /** Drop a key (or the whole cache). */\n  invalidate(key?: string): void;\n}\n\nexport function createSwrCache<T>(fetcher: (key: string) => Promise<T>, ttlMs: number): SwrCache<T> {\n  const cache = new Map<string, SwrEntry<T>>();\n  const load = (key: string) =>\n    fetcher(key).then((data) => {\n      cache.set(key, { data, at: Date.now() });\n      return data;\n    });\n\n  return {\n    async get(key) {\n      const now = Date.now();\n      const e = cache.get(key);\n      if (e && now - e.at < ttlMs) return e.data; // fresh\n      if (e && e.inflight) return e.data; // already revalidating -> serve stale\n      const revalidate = load(key)\n        .catch((err) => {\n          if (e) return e.data; // serve stale on error\n          throw err;\n        })\n        .finally(() => {\n          const cur = cache.get(key);\n          if (cur) cur.inflight = undefined;\n        });\n      if (e) {\n        e.inflight = revalidate;\n        return e.data; // stale-while-revalidate\n      }\n      return revalidate; // cold\n    },\n\n    async fresh(key) {\n      const e = cache.get(key);\n      if (e?.inflight) return e.inflight; // dedupe concurrent forced fetches\n      const p = load(key).finally(() => {\n        const cur = cache.get(key);\n        if (cur) cur.inflight = undefined;\n      });\n      cache.set(key, { ...(e ?? { data: undefined as unknown as T, at: 0 }), inflight: p });\n      return p;\n    },\n\n    invalidate(key) {\n      if (key) cache.delete(key);\n      else cache.clear();\n    },\n  };\n}\n"}]},{"slug":"util/track-ribbon","title":"Track Ribbon Geometry","type":"util","summary":"Synthesizes a flat 3D track ribbon from a centerline: centres + scales the circuit, offsets +/- half-width along normals, builds surface/kerb/run-off/edge geometries + per-arc coloured overlays. three geometry only.","tags":["three","geometry","track"],"origin":"f1peak","provenance":["f1peak"],"deps":{"npm":["three"],"kit":[]},"themeAware":false,"entry":"ribbon.ts","files":[{"path":"ribbon.ts","lang":"ts","source":"import type { TrackOutline } from \"@f1peak/viewer\";\n\n/**\n * World→scene transform: centre the circuit on the origin and scale its longest\n * span to ~100 scene units, so the camera framing is constant across circuits.\n * World Y (up) maps to scene −Z so the track reads upright from above.\n */\nexport function trackTransform(o: TrackOutline) {\n  const { minX, minY, maxX, maxY } = o.bounds;\n  const cx = (minX + maxX) / 2;\n  const cy = (minY + maxY) / 2;\n  const span = Math.max(maxX - minX, maxY - minY) || 1;\n  const scale = 100 / span;\n  const toScene = (x: number, y: number): [number, number] => [(x - cx) * scale, -(y - cy) * scale];\n  return { toScene, scale, span };\n}\n\n/**\n * Synthesize a flat ribbon (triangle list, y=0) from the centerline by offsetting\n * each point ±halfWidth along its normal. HONEST: the feed has no width and no\n * elevation, so width is schematic and the ribbon is dead flat (captioned on screen).\n * `segFrac` carries each vertex's lap fraction (0..1) for future mini-sector colouring.\n */\nexport function buildRibbon(o: TrackOutline, halfWidth: number, toScene: (x: number, y: number) => [number, number]) {\n  const n = o.x.length;\n  const left: [number, number][] = [];\n  const right: [number, number][] = [];\n  for (let i = 0; i < n; i++) {\n    const [ax, ay] = toScene(o.x[(i - 1 + n) % n], o.y[(i - 1 + n) % n]);\n    const [bx, by] = toScene(o.x[(i + 1) % n], o.y[(i + 1) % n]);\n    const [px, py] = toScene(o.x[i], o.y[i]);\n    let dx = bx - ax;\n    let dy = by - ay;\n    const len = Math.hypot(dx, dy) || 1;\n    dx /= len;\n    dy /= len;\n    const nx = -dy;\n    const ny = dx;\n    left.push([px + nx * halfWidth, py + ny * halfWidth]);\n    right.push([px - nx * halfWidth, py - ny * halfWidth]);\n  }\n  const verts: number[] = [];\n  const frac: number[] = [];\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n;\n    const l0 = left[i];\n    const r0 = right[i];\n    const l1 = left[j];\n    const r1 = right[j];\n    verts.push(l0[0], 0, l0[1], r0[0], 0, r0[1], l1[0], 0, l1[1]);\n    verts.push(r0[0], 0, r0[1], r1[0], 0, r1[1], l1[0], 0, l1[1]);\n    const f = i / n;\n    for (let k = 0; k < 6; k++) frac.push(f);\n  }\n  return { positions: new Float32Array(verts), segFrac: new Float32Array(frac) };\n}\n\n/** centerline as scene points (slightly above the ribbon to avoid z-fighting) */\nexport function centerlinePoints(o: TrackOutline, toScene: (x: number, y: number) => [number, number]): [number, number, number][] {\n  const n = o.x.length;\n  const pts: [number, number, number][] = [];\n  for (let i = 0; i <= n; i++) {\n    const [x, z] = toScene(o.x[i % n], o.y[i % n]);\n    pts.push([x, 0.06, z]);\n  }\n  return pts;\n}\n\n// ============================================================================\n// Track 2 / Phase 1 — a track surface that READS as racing: real (schematic)\n// width, kerb bands, a run-off apron, and mini-sector vertex-colour via segFrac.\n// Still HONEST: the feed has no width/elevation, so width is schematic + the\n// ribbon is dead flat (captioned on screen). Geometry only — colour in Track3D.\n// ============================================================================\n\n/** per-point scene position + unit normal (perpendicular to the local tangent) */\nfunction frames(o: TrackOutline, toScene: (x: number, y: number) => [number, number]) {\n  const n = o.x.length;\n  const px: number[] = new Array(n);\n  const py: number[] = new Array(n);\n  const nx: number[] = new Array(n);\n  const ny: number[] = new Array(n);\n  for (let i = 0; i < n; i++) {\n    const [ax, ay] = toScene(o.x[(i - 1 + n) % n], o.y[(i - 1 + n) % n]);\n    const [bx, by] = toScene(o.x[(i + 1) % n], o.y[(i + 1) % n]);\n    const [cx, cy] = toScene(o.x[i], o.y[i]);\n    let dx = bx - ax;\n    let dy = by - ay;\n    const len = Math.hypot(dx, dy) || 1;\n    dx /= len;\n    dy /= len;\n    px[i] = cx;\n    py[i] = cy;\n    nx[i] = -dy;\n    ny[i] = dx;\n  }\n  return { n, px, py, nx, ny };\n}\n\n/** triangle list for a flat band between two signed lateral offsets, at height y */\nfunction band(\n  f: ReturnType<typeof frames>,\n  off1: number,\n  off2: number,\n  y: number,\n): Float32Array {\n  const { n, px, py, nx, ny } = f;\n  const verts: number[] = [];\n  for (let i = 0; i < n; i++) {\n    const j = (i + 1) % n;\n    const a0x = px[i] + nx[i] * off1, a0y = py[i] + ny[i] * off1;\n    const b0x = px[i] + nx[i] * off2, b0y = py[i] + ny[i] * off2;\n    const a1x = px[j] + nx[j] * off1, a1y = py[j] + ny[j] * off1;\n    const b1x = px[j] + nx[j] * off2, b1y = py[j] + ny[j] * off2;\n    verts.push(a0x, y, a0y, b0x, y, b0y, a1x, y, a1y);\n    verts.push(b0x, y, b0y, b1x, y, b1y, a1x, y, a1y);\n  }\n  return new Float32Array(verts);\n}\n\nexport interface TrackGeo {\n  runoff: Float32Array;\n  surface: Float32Array;\n  segFrac: Float32Array; // per surface vertex, lap fraction 0..1\n  kerb: Float32Array;\n  kerbColors: Float32Array; // alternating red/white blocks\n  edge: Float32Array; // thin white track-limit lines\n}\n\n/**\n * Layered flat track: run-off apron (widest, dark) → kerb bands (red/white) →\n * asphalt surface → thin white edge lines. Heights are staggered to avoid z-fighting.\n */\nexport function buildTrack(\n  o: TrackOutline,\n  toScene: (x: number, y: number) => [number, number],\n  half = 1.6,\n): TrackGeo {\n  const f = frames(o, toScene);\n  const K = half * 0.22; // kerb width\n  const R = half * 1.5; // run-off apron width beyond the kerb\n  const surface = band(f, -half, half, 0.02);\n  const segFrac = new Float32Array((surface.length / 3) | 0);\n  const per = segFrac.length / f.n; // 6 verts per segment\n  for (let i = 0; i < f.n; i++) for (let k = 0; k < per; k++) segFrac[i * per + k] = i / f.n;\n  // kerbs: a strip just outside each track limit, alternating red/white in blocks\n  const kerbL = band(f, half, half + K, 0.028);\n  const kerbR = band(f, -(half + K), -half, 0.028);\n  const kerb = new Float32Array(kerbL.length + kerbR.length);\n  kerb.set(kerbL, 0);\n  kerb.set(kerbR, kerbL.length);\n  const kerbColors = new Float32Array(kerb.length); // rgb per vertex\n  const block = 4; // segments per colour block\n  const paint = (base: Float32Array, off: number) => {\n    for (let i = 0; i < f.n; i++) {\n      const red = Math.floor(i / block) % 2 === 0;\n      const r = red ? 0.86 : 0.92, g = red ? 0.16 : 0.92, b = red ? 0.13 : 0.92;\n      for (let k = 0; k < 6; k++) {\n        const v = (off + (i * 6 + k)) * 3;\n        kerbColors[v] = r;\n        kerbColors[v + 1] = g;\n        kerbColors[v + 2] = b;\n      }\n    }\n  };\n  paint(kerbL, 0);\n  paint(kerbR, kerbL.length / 3);\n  const runoffL = band(f, half + K, half + K + R, 0.0);\n  const runoffR = band(f, -(half + K + R), -(half + K), 0.0);\n  const runoff = new Float32Array(runoffL.length + runoffR.length);\n  runoff.set(runoffL, 0);\n  runoff.set(runoffR, runoffL.length);\n  // thin white track-limit lines (just inside each kerb)\n  const edgeL = band(f, half - half * 0.04, half, 0.022);\n  const edgeR = band(f, -half, -(half - half * 0.04), 0.022);\n  const edge = new Float32Array(edgeL.length + edgeR.length);\n  edge.set(edgeL, 0);\n  edge.set(edgeR, edgeL.length);\n  return { runoff, surface, segFrac, kerb, kerbColors, edge };\n}\n\n/**\n * A flat coloured strip ON the surface for one mini-sector arc [i0,i1) of the\n * outline — for the live \"alive track\" tint overlay (rebuilt on uiVersion).\n */\nexport function arcStrip(\n  o: TrackOutline,\n  toScene: (x: number, y: number) => [number, number],\n  i0: number,\n  i1: number,\n  half: number,\n  y = 0.05,\n): Float32Array {\n  const f = frames(o, toScene);\n  const verts: number[] = [];\n  for (let i = i0; i < i1; i++) {\n    const a = (i + f.n) % f.n;\n    const j = (i + 1 + f.n) % f.n;\n    const w = half * 0.92;\n    const a0x = f.px[a] + f.nx[a] * -w, a0y = f.py[a] + f.ny[a] * -w;\n    const b0x = f.px[a] + f.nx[a] * w, b0y = f.py[a] + f.ny[a] * w;\n    const a1x = f.px[j] + f.nx[j] * -w, a1y = f.py[j] + f.ny[j] * -w;\n    const b1x = f.px[j] + f.nx[j] * w, b1y = f.py[j] + f.ny[j] * w;\n    verts.push(a0x, y, a0y, b0x, y, b0y, a1x, y, a1y);\n    verts.push(b0x, y, b0y, b1x, y, b1y, a1x, y, a1y);\n  }\n  return new Float32Array(verts);\n}\n"}]}],"themes":[{"slug":"theme/apex","title":"APEX","summary":"F1Peak's broadcast-instrument look: acid-lime + red duotone on layered cool indigo, Archivo display. Vivid, data-forward, not-fully-dark.","origin":"f1peak","tokens":{"--bg":"#111420","--surface":"#1a1f30","--surface-2":"#222840","--line":"rgba(150, 170, 220, 0.18)","--ink":"#f3f6ff","--ink-dim":"#b6bfd6","--ink-faint":"#7e88a3","--accent":"#c2f53b","--accent-2":"#ff2e43","--good":"#00d17a","--warn":"#ffd900","--bad":"#ff2d3d","--font-display":"var(--font-archivo), \"Helvetica Neue\", system-ui, sans-serif","--font-ui":"var(--font-barlow), system-ui, sans-serif","--font-mono":"var(--font-jetbrains), ui-monospace, monospace","--radius":"12px","--radius-sm":"7px","--ring":"0 8px 30px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(150, 170, 220, 0.12)"},"css":"/* APEX — F1Peak's signature look: acid-lime + red duotone on layered cool indigo,\n   Archivo display. Harvested from projects/f1peak/web/packages/kit/src/tokens.css and\n   mapped onto The Forge's canonical token contract. */\n[data-theme=\"apex\"] {\n  --bg: #111420;\n  --surface: #1a1f30;\n  --surface-2: #222840;\n  --line: rgba(150, 170, 220, 0.18);\n\n  --ink: #f3f6ff;\n  --ink-dim: #b6bfd6;\n  --ink-faint: #7e88a3;\n\n  --accent: #c2f53b;\n  --accent-2: #ff2e43;\n\n  --good: #00d17a;\n  --warn: #ffd900;\n  --bad: #ff2d3d;\n\n  --font-display: var(--font-archivo), \"Helvetica Neue\", system-ui, sans-serif;\n  --font-ui: var(--font-barlow), system-ui, sans-serif;\n  --font-mono: var(--font-jetbrains), ui-monospace, monospace;\n\n  --radius: 12px;\n  --radius-sm: 7px;\n  --ring: 0 8px 30px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(150, 170, 220, 0.12);\n}\n"},{"slug":"theme/ink","title":"Ink","summary":"The painted poster system: deep oil-blue canvas, warm bone ink, ember lead with gold support. Built for solid cel-step shading; nothing glows.","origin":"spotmind","tokens":{"--bg":"#28303e","--surface":"#333c4d","--surface-2":"#3d4759","--line":"rgba(213, 205, 185, 0.16)","--ink":"#f1ede2","--ink-dim":"#c3bdae","--ink-faint":"#918c7f","--accent":"#d47b47","--accent-2":"#d9b361","--good":"#7dbd9e","--warn":"#d9b361","--bad":"#cf5f52","--font-display":"var(--font-barlow-condensed), \"Arial Narrow\", system-ui, sans-serif","--font-ui":"var(--font-barlow), system-ui, sans-serif","--font-mono":"var(--font-jetbrains), ui-monospace, monospace","--radius":"4px","--radius-sm":"2px","--ring":"0 0 0 1px rgba(213, 205, 185, 0.14)"},"css":"/* INK - the painted poster look, mapped onto the kit's canonical token contract.\n   Solid cel-step shading; components in this family never blur or glow. */\n[data-theme=\"ink\"] {\n  --bg: #28303e;\n  --surface: #333c4d;\n  --surface-2: #3d4759;\n  --line: rgba(213, 205, 185, 0.16);\n\n  --ink: #f1ede2;\n  --ink-dim: #c3bdae;\n  --ink-faint: #918c7f;\n\n  --accent: #d47b47;\n  --accent-2: #d9b361;\n\n  --good: #7dbd9e;\n  --warn: #d9b361;\n  --bad: #cf5f52;\n\n  --font-display: var(--font-barlow-condensed), \"Arial Narrow\", system-ui, sans-serif;\n  --font-ui: var(--font-barlow), system-ui, sans-serif;\n  --font-mono: var(--font-jetbrains), ui-monospace, monospace;\n\n  --radius: 4px;\n  --radius-sm: 2px;\n  --ring: 0 0 0 1px rgba(213, 205, 185, 0.14);\n}"},{"slug":"theme/neutral","title":"Neutral","summary":"The Forge's clean baseline — the canonical token contract's default values. A calm starting point for new work.","origin":"forge","tokens":{"--bg":"#0c0e13","--surface":"#14171e","--surface-2":"#1b1f28","--line":"#2a2f3a","--ink":"#f1f3f7","--ink-dim":"#aab0bd","--ink-faint":"#6f7682","--accent":"#6ea8fe","--accent-2":"#b66efe","--good":"#36d399","--warn":"#f5b544","--bad":"#ff5d6c","--font-display":"var(--font-montserrat), system-ui, sans-serif","--font-ui":"var(--font-barlow), system-ui, sans-serif","--font-mono":"var(--font-jetbrains), ui-monospace, monospace","--radius":"12px","--radius-sm":"7px","--ring":"0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 30px rgba(0, 0, 0, 0.35)"},"css":"/* Neutral — The Forge's clean baseline theme (the canonical contract's defaults). */\n[data-theme=\"neutral\"] {\n  --bg: #0c0e13;\n  --surface: #14171e;\n  --surface-2: #1b1f28;\n  --line: #2a2f3a;\n\n  --ink: #f1f3f7;\n  --ink-dim: #aab0bd;\n  --ink-faint: #6f7682;\n\n  --accent: #6ea8fe;\n  --accent-2: #b66efe;\n\n  --good: #36d399;\n  --warn: #f5b544;\n  --bad: #ff5d6c;\n\n  --font-display: var(--font-montserrat), system-ui, sans-serif;\n  --font-ui: var(--font-barlow), system-ui, sans-serif;\n  --font-mono: var(--font-jetbrains), ui-monospace, monospace;\n\n  --radius: 12px;\n  --radius-sm: 7px;\n  --ring: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 30px rgba(0, 0, 0, 0.35);\n}\n"},{"slug":"theme/tifo","title":"TIFO","summary":"WC26's editorial matchday look: near-black canvas, a single strike-orange accent, condensed display type (Barlow Condensed) and sharp 3-4px corners. High-contrast and broadcast-graphic.","origin":"wc26","tokens":{"--bg":"#08080a","--surface":"#131317","--surface-2":"#1a1a20","--line":"#26262d","--ink":"#f4f4f2","--ink-dim":"#b6b6bc","--ink-faint":"#8a8a92","--accent":"#ff5a3c","--accent-2":"#e0b23a","--good":"#37c26b","--warn":"#e0b23a","--bad":"#ff5a3c","--font-display":"var(--font-barlow-condensed), \"Arial Narrow\", sans-serif","--font-ui":"var(--font-barlow), system-ui, sans-serif","--font-mono":"var(--font-jetbrains), ui-monospace, monospace","--radius":"4px","--radius-sm":"3px","--ring":"0 10px 34px -10px rgba(0, 0, 0, 0.65), 0 0 0 1px #26262d"},"css":"/* TIFO — WC26's editorial matchday look: near-black canvas, single strike-orange accent,\n   condensed display type, sharp corners. Harvested from\n   projects/wc26/app/packages/ui/src/tokens.css and mapped onto the canonical contract. */\n[data-theme=\"tifo\"] {\n  --bg: #08080a;\n  --surface: #131317;\n  --surface-2: #1a1a20;\n  --line: #26262d;\n\n  --ink: #f4f4f2;\n  --ink-dim: #b6b6bc;\n  --ink-faint: #8a8a92;\n\n  --accent: #ff5a3c;\n  --accent-2: #e0b23a;\n\n  --good: #37c26b;\n  --warn: #e0b23a;\n  --bad: #ff5a3c;\n\n  --font-display: var(--font-barlow-condensed), \"Arial Narrow\", sans-serif;\n  --font-ui: var(--font-barlow), system-ui, sans-serif;\n  --font-mono: var(--font-jetbrains), ui-monospace, monospace;\n\n  --radius: 4px;\n  --radius-sm: 3px;\n  --ring: 0 10px 34px -10px rgba(0, 0, 0, 0.65), 0 0 0 1px #26262d;\n}\n"}]}