@krizaka/ui 3.33.1 → 3.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/registry/map.json CHANGED
@@ -497,6 +497,20 @@
497
497
  "description": "How long the drawing lasts, in milliseconds. Default 1400.",
498
498
  "required": false
499
499
  },
500
+ {
501
+ "name": "easing",
502
+ "type": "\"linear\" | \"ease-in-out\" | \"ease-out\"",
503
+ "default": null,
504
+ "description": "The timing function of the drawing and of the trips: `ease-out`, `linear`, `ease-in-out`. Default: out for the drawing, in-out for a trip.",
505
+ "required": false
506
+ },
507
+ {
508
+ "name": "paused",
509
+ "type": "boolean",
510
+ "default": "false",
511
+ "description": "Holds the drawing and the trips where they are (a controlled pause, on top of the off-screen one).",
512
+ "required": false
513
+ },
500
514
  {
501
515
  "name": "repeatAfter",
502
516
  "type": "number",
@@ -1032,7 +1046,7 @@
1032
1046
  },
1033
1047
  {
1034
1048
  "path": "map/map.tsx",
1035
- "content": "// Client. A map of the Krizaka family on MapLibre GL JS (open source) over Protomaps tiles on our CDN — no API key, no\n// metered service. MapLibre and PMTiles are optional peers, loaded when a map is shown (a dynamic import: a page without\n// a map never pays for them). The style comes from the tokens (`mapStyle`), in the page's scheme, and follows it.\n//\n// What every map does: the canvas is decorative for assistive technologies — the map is a region named by `label`, its\n// places a list read in order (the text alternative, `MapPlaces`), each marker a button when it does something; the\n// attribution of OpenStreetMap (ODbL) and Protomaps is always shown; under reduced motion the camera jumps, never flies;\n// without WebGL (or while the engine loads) `fallback` stands in its place.\n/// <reference types=\"geojson\" />\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\n\nimport { usePrefersReducedMotion } from \"../media\";\nimport { tv } from \"../tv\";\nimport { MAP_ATTRIBUTION, setMapAssetsBase } from \"./assets\";\nimport { circlePolygon, type LngLat, pointAlong, sliceLine } from \"./geo\";\nimport { type MapBrand, mapPalette, type MapRegion, mapStyle } from \"./style\";\n\ntype MapLibre = typeof import(\"maplibre-gl\");\ntype MapInstance = import(\"maplibre-gl\").Map;\n\nexport const map = tv({\n slots: {\n root: \"relative isolate overflow-hidden rounded-2xl border border-border-default bg-surface-2 shadow-raised\",\n canvas: \"absolute inset-0 [&_.maplibregl-canvas]:outline-hidden\",\n fallback: \"absolute inset-0\",\n controls: \"absolute right-3 top-3 z-10 flex flex-col overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-floating\",\n control:\n \"inline-flex h-10 w-10 items-center justify-center text-lg font-semibold text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:focus-ring-inset disabled:opacity-40 [&+&]:border-t [&+&]:border-border-subtle\",\n attribution:\n \"absolute bottom-1.5 right-1.5 z-10 rounded-md bg-surface-1/85 px-1.5 py-0.5 text-[10px] text-fg-secondary backdrop-blur-sm [&_a]:underline-offset-2 hover:[&_a]:underline\",\n places: \"sr-only\",\n marker:\n \"group relative flex -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-2 border-on-accent bg-accent shadow-floating outline-hidden \" +\n \"transition-transform hover:scale-110 focus-visible:focus-ring motion-reduce:transition-none motion-reduce:hover:scale-100 data-[selected]:scale-125\",\n markerLabel:\n \"pointer-events-none absolute bottom-full mb-2 whitespace-nowrap rounded-lg border border-border-default bg-surface-1 px-2 py-1 text-xs font-semibold text-fg shadow-floating\",\n },\n});\n\ntype Registry = { add: (id: string, text: string) => void; remove: (id: string) => void };\ntype Ctx = { map: MapInstance | null; lib: MapLibre | null; ready: boolean; reduce: boolean; places: Registry };\nconst MapContext = React.createContext<Ctx | null>(null);\n\n/** The map a child is drawn on (`Marker`, `RadiusCircle`, `Cluster`…): the MapLibre instance once it is ready. */\nexport function useMap(): Ctx {\n const ctx = React.useContext(MapContext);\n if (!ctx) throw new Error(\"useMap: inside a <Map> only\");\n return ctx;\n}\n\n/** The scheme of the page (`html.light`), followed as it changes. */\nfunction usePageScheme(): \"dark\" | \"light\" {\n const read = () => (typeof document !== \"undefined\" && document.documentElement.classList.contains(\"light\") ? \"light\" : \"dark\");\n const [scheme, setScheme] = React.useState<\"dark\" | \"light\">(read);\n React.useEffect(() => {\n const observer = new MutationObserver(() => setScheme(read()));\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n return () => observer.disconnect();\n }, []);\n return scheme;\n}\n\nlet protocolAdded = false;\nlet workerUrl: string | null = null;\n\n/**\n * Where MapLibre's worker comes from. A bundler (Next.js, Vite) renames MapLibre's module and leaves its worker behind:\n * by default the worker of the very version the app installed is imported from jsDelivr (free, open source, no key)\n * through a module worker of the page's own origin. An app with a strict CSP serves it itself —\n * `configureMaps({ workerUrl: \"/maplibre-gl-worker.mjs\" })` (with `maplibre-gl-shared.mjs` beside it), once, before a map.\n * `assetsBase` reads the tiles and glyphs through a same-origin proxy of the CDN (`setMapAssetsBase`).\n */\nexport function configureMaps(options: { workerUrl?: string; assetsBase?: string }) {\n if (options.workerUrl) workerUrl = options.workerUrl;\n if (options.assetsBase) setMapAssetsBase(options.assetsBase);\n}\n\n/** MapLibre, its worker and the PMTiles protocol, loaded once for the page. */\nasync function loadEngine(): Promise<MapLibre> {\n const [lib, pmtiles] = await Promise.all([import(\"maplibre-gl\"), import(\"pmtiles\")]);\n const ml = ((lib as unknown as { default?: MapLibre }).default ?? lib) as MapLibre;\n if (!protocolAdded) {\n protocolAdded = true;\n const remote = `https://cdn.jsdelivr.net/npm/maplibre-gl@${ml.getVersion()}/dist/maplibre-gl-worker.mjs`;\n ml.setWorkerUrl(workerUrl ?? URL.createObjectURL(new Blob([`import ${JSON.stringify(remote)};`], { type: \"text/javascript\" })));\n ml.addProtocol(\"pmtiles\", new pmtiles.Protocol().tile);\n }\n return ml;\n}\n\nconst hasWebGL = () => {\n try {\n const canvas = document.createElement(\"canvas\");\n return Boolean(canvas.getContext(\"webgl2\") ?? canvas.getContext(\"webgl\"));\n } catch {\n return false;\n }\n};\n\nexport type MapLabels = {\n /** The zoom-in button (\"Zoom in\"). */\n zoomIn: string;\n /** The zoom-out button (\"Zoom out\"). */\n zoomOut: string;\n /** Before the list of places read in place of the map (\"Places on the map\"). */\n places: string;\n};\n\nexport const mapLabels: MapLabels = { zoomIn: \"Zoom in\", zoomOut: \"Zoom out\", places: \"Places on the map\" };\n\nexport type MapProps = {\n /** The map's accessible name (\"Pick-up points near you\"), passed translated. */\n label: string;\n /** Where the map looks at first: longitude, latitude. */\n center: LngLat;\n /** The zoom at first (0 the world, 12 a city, 16 a street). Default 12. */\n zoom?: number;\n /** The camera follows `center` and `zoom` when they change (flies, or jumps under reduced motion). */\n controlled?: boolean;\n /** The scheme. Default: the page's (`html.light`), followed. */\n scheme?: \"dark\" | \"light\";\n /** The product whose accent the map carries. Default `krizaka`. */\n brand?: MapBrand;\n /** The language of the labels (`fr`, `en`). Default `en`. */\n lang?: string;\n /** The detailed region (PMTiles) drawn from zoom 6.5. Default: our Montréal–Québec extract (to zoom 14). */\n region?: MapRegion | null;\n /** The world's low zooms (PMTiles). Default: our world extract (to zoom 6). */\n world?: MapRegion | null;\n /** The zoom buttons (default) — the keyboard also zooms with + and − on the focused map. */\n controls?: boolean;\n /** Moves with the hand (default); `false` makes a still picture of a place. */\n interactive?: boolean;\n /** Shown in place of the map while its engine loads, and for good without WebGL: a `StaticMap`, a picture. */\n fallback?: React.ReactNode;\n /** Called when the camera rests: its centre and zoom. */\n onMoveEnd?: (view: { center: LngLat; zoom: number }) => void;\n /** Called with the position pressed on the map (not on a marker). */\n onPress?: (position: LngLat) => void;\n /** Markers, circles, clusters, the user's position — and anything drawn over the map. */\n children?: React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<MapLabels>;\n /** Classes merged on the root (its height: `h-80`, `aspect-video`). */\n className?: string;\n};\n\n/** A map: MapLibre over our Protomaps tiles, styled from the tokens, with its places as a list for screen readers. */\nexport function Map({\n label,\n center,\n zoom = 12,\n controlled,\n scheme: schemeProp,\n brand,\n lang,\n region,\n world,\n controls = true,\n interactive = true,\n fallback,\n onMoveEnd,\n onPress,\n children,\n labels: given,\n className,\n}: MapProps) {\n const labels = { ...mapLabels, ...given };\n const s = map();\n const pageScheme = usePageScheme();\n const scheme = schemeProp ?? pageScheme;\n const reduce = usePrefersReducedMotion();\n const host = React.useRef<HTMLDivElement>(null);\n const [instance, setInstance] = React.useState<MapInstance | null>(null);\n const [lib, setLib] = React.useState<MapLibre | null>(null);\n const [ready, setReady] = React.useState(false);\n const [failed, setFailed] = React.useState(false);\n const [places, setPlaces] = React.useState<Record<string, string>>({});\n const registry = React.useMemo<Registry>(\n () => ({\n add: (id, text) => setPlaces((p) => (p[id] === text ? p : { ...p, [id]: text })),\n remove: (id) =>\n setPlaces((p) => {\n const { [id]: _gone, ...rest } = p;\n return rest;\n }),\n }),\n [],\n );\n const latest = React.useRef({ onMoveEnd, onPress });\n React.useEffect(() => {\n latest.current = { onMoveEnd, onPress };\n });\n const styleOf = React.useCallback((sc: \"dark\" | \"light\") => mapStyle({ scheme: sc, brand, lang, ...(region !== undefined ? { region } : {}), ...(world !== undefined ? { world } : {}) }), [brand, lang, region, world]);\n\n // The style the map wears now: the engine loads asynchronously, so the scheme it is created with is read when it is ready\n // (the page's theme may have flipped since the first render), and the effect below applies whatever differs.\n const wanted = React.useRef({ scheme, styleOf });\n React.useEffect(() => {\n wanted.current = { scheme, styleOf };\n });\n const applied = React.useRef<{ scheme: \"dark\" | \"light\"; styleOf: typeof styleOf } | null>(null);\n\n // The engine and the map, once (the style follows the scheme below).\n React.useEffect(() => {\n if (!host.current) return;\n let alive = true;\n let created: MapInstance | null = null;\n (hasWebGL() ? loadEngine() : Promise.reject(new Error(\"no WebGL\")))\n .then((ml) => {\n if (!alive || !host.current) return;\n applied.current = wanted.current;\n created = new ml.Map({\n container: host.current,\n style: wanted.current.styleOf(wanted.current.scheme) as never,\n center: [...center] as [number, number],\n zoom,\n interactive,\n attributionControl: false,\n fadeDuration: reduce ? 0 : 300,\n cooperativeGestures: false,\n });\n created.getCanvas().setAttribute(\"aria-hidden\", \"true\");\n created.getCanvas().setAttribute(\"tabindex\", \"-1\");\n created.on(\"load\", () => alive && setReady(true));\n // The basemap cannot be read (offline, a blocked request): the fallback comes back rather than an empty map.\n created.on(\"error\", (e) => {\n const source = (e as { sourceId?: string }).sourceId;\n if (alive && (source === \"world\" || source === \"region\")) setFailed(true);\n });\n created.on(\"moveend\", () => {\n const c = created!.getCenter();\n latest.current.onMoveEnd?.({ center: [c.lng, c.lat], zoom: created!.getZoom() });\n });\n created.on(\"click\", (e) => {\n if ((e.originalEvent.target as Element | null)?.closest?.(\"[data-kz-marker]\")) return;\n latest.current.onPress?.([e.lngLat.lng, e.lngLat.lat]);\n });\n setLib(ml);\n setInstance(created);\n })\n .catch(() => alive && setFailed(true));\n return () => {\n alive = false;\n created?.remove();\n };\n // Created once: the props that change afterwards are followed by the effects below.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // The scheme (and the brand, the language) change the style, not the map.\n React.useEffect(() => {\n if (!instance) return;\n if (applied.current?.scheme === scheme && applied.current.styleOf === styleOf) return;\n applied.current = { scheme, styleOf };\n instance.setStyle(styleOf(scheme) as never);\n }, [instance, scheme, styleOf]);\n\n // A controlled camera: it flies (or jumps) to the new view.\n React.useEffect(() => {\n if (!instance || !controlled) return;\n const view = { center: [...center] as [number, number], zoom };\n if (reduce) instance.jumpTo(view);\n else instance.flyTo({ ...view, speed: 1.4 });\n }, [instance, controlled, center[0], center[1], zoom, reduce]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const zoomBy = (by: number) => instance && (reduce ? instance.setZoom(instance.getZoom() + by) : instance.zoomTo(instance.getZoom() + by, { duration: 250 }));\n const ctx = React.useMemo<Ctx>(() => ({ map: instance, lib, ready, reduce, places: registry }), [instance, lib, ready, reduce, registry]);\n const listed = Object.entries(places);\n return (\n <MapContext.Provider value={ctx}>\n <section\n aria-label={label}\n className={s.root({ className })}\n data-ready={ready ? \"\" : undefined}\n tabIndex={interactive ? 0 : undefined}\n onKeyDown={(e) => {\n if (e.target !== e.currentTarget || !instance) return;\n if (e.key === \"+\" || e.key === \"=\") zoomBy(1);\n else if (e.key === \"-\") zoomBy(-1);\n else if (e.key.startsWith(\"Arrow\")) {\n e.preventDefault();\n const step = 80;\n const by: Record<string, [number, number]> = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] };\n instance.panBy(by[e.key]!, { duration: reduce ? 0 : 200 });\n }\n }}\n >\n {!ready || failed ? <div className={s.fallback()}>{fallback}</div> : null}\n <div ref={host} className={s.canvas()} style={{ position: \"absolute\", inset: 0, visibility: failed ? \"hidden\" : undefined }} />\n {controls && interactive && !failed ? (\n <div className={s.controls()}>\n <button type=\"button\" className={s.control()} aria-label={labels.zoomIn} disabled={!instance} onClick={() => zoomBy(1)}>\n +\n </button>\n <button type=\"button\" className={s.control()} aria-label={labels.zoomOut} disabled={!instance} onClick={() => zoomBy(-1)}>\n −\n </button>\n </div>\n ) : null}\n <p className={s.attribution()}>\n <a href={MAP_ATTRIBUTION.protomapsUrl} target=\"_blank\" rel=\"noreferrer\">\n {MAP_ATTRIBUTION.protomaps}\n </a>\n {\" · \"}\n <a href={MAP_ATTRIBUTION.osmUrl} target=\"_blank\" rel=\"noreferrer\">\n {MAP_ATTRIBUTION.osm}\n </a>\n </p>\n {listed.length ? (\n <div className={s.places()}>\n <p>{labels.places}</p>\n <ul>\n {listed.map(([id, text]) => (\n <li key={id}>{text}</li>\n ))}\n </ul>\n </div>\n ) : null}\n {children}\n </section>\n </MapContext.Provider>\n );\n}\n\nexport type MarkerProps = {\n /** Where it stands. */\n position: LngLat;\n /** Its words: the place's name, read in the list of places and as the button's name (\"Café Olimpico, 350 m\"). */\n label: string;\n /** Pressing it (details in a panel, a sheet): the marker becomes a button. */\n onPress?: () => void;\n /** The chosen one: larger, its label shown above it. */\n selected?: boolean;\n /** Its size in pixels. Default 18. */\n size?: number;\n /** What it draws instead of the accent dot (an avatar, an icon). */\n children?: React.ReactNode;\n /** Drops onto its place once, after this many milliseconds (stagger several: `index * 90`). Still under reduced motion. */\n drop?: number;\n};\n\nconst noSubscription = () => () => undefined;\n\n/** A place on the map: an accent dot (or your content) that is a button when it does something. */\nexport function Marker({ position, label, onPress, selected, size = 18, children, drop }: MarkerProps) {\n const { map: m, lib, places } = useMap();\n const s = map();\n const id = React.useId();\n // The marker's host element exists only in a browser: the server and the hydration pass render nothing for it, then the\n // client swaps in the element — `useSyncExternalStore`'s server snapshot, so hydration never sees a difference.\n const host = React.useRef<HTMLElement | null>(null);\n const el = React.useSyncExternalStore(\n noSubscription,\n () => (host.current ??= document.createElement(\"div\")),\n () => null,\n );\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib || !el) return;\n el.setAttribute(\"data-kz-marker\", \"\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, el, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n if (!el) return null;\n const Tag = onPress ? \"button\" : \"span\";\n return createPortal(\n <Tag\n type={onPress ? \"button\" : undefined}\n aria-label={onPress ? label : undefined}\n aria-hidden={onPress ? undefined : true}\n aria-pressed={onPress && selected !== undefined ? selected : undefined}\n data-selected={selected ? \"\" : undefined}\n data-drop={drop === undefined ? undefined : \"\"}\n className={s.marker()}\n style={{ ...(children ? null : { width: size, height: size }), ...(drop === undefined ? null : ({ \"--kz-map-drop-delay\": `${drop}ms` } as React.CSSProperties)) }}\n onClick={onPress}\n >\n {children}\n {selected ? <span className={s.markerLabel()}>{label}</span> : null}\n </Tag>,\n el,\n );\n}\n\nexport type UserPositionProps = {\n /** Where the person is. */\n position: LngLat;\n /** Its words (\"You are here\"), read in the list of places. */\n label: string;\n /** The accuracy in metres: a soft disc around the dot. */\n accuracy?: number;\n};\n\n/** The person's position: a dot that pulses (still under reduced motion), its accuracy as a soft disc. */\nexport function UserPosition({ position, label, accuracy }: UserPositionProps) {\n const { map: m, lib, places } = useMap();\n const id = React.useId();\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-user\";\n el.setAttribute(\"aria-hidden\", \"true\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n return accuracy ? <RadiusCircle center={position} radius={accuracy} tone=\"soft\" /> : null;\n}\n\nexport type PulseProps = {\n /** Where it beats. */\n position: LngLat;\n /** Its words (\"High demand in Mile End\"), read in the list of places. */\n label: string;\n /** The rings' reach in pixels. Default 56. */\n size?: number;\n /** `accent` (a zone that matters) or `soft`. Default `accent`. */\n tone?: \"accent\" | \"soft\";\n};\n\n/** A heat pulse: rings that swell and fade around a place (a hot spot, a demand peak). Still rings under reduced motion. */\nexport function Pulse({ position, label, size = 56, tone = \"accent\" }: PulseProps) {\n const { map: m, lib, places } = useMap();\n const id = React.useId();\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-pulse\";\n el.setAttribute(\"data-tone\", tone);\n el.setAttribute(\"aria-hidden\", \"true\");\n el.style.setProperty(\"--kz-map-pulse-size\", `${size}px`);\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, tone, size, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n return null;\n}\n\nexport type RouteProps = {\n /** The path, as positions in order (a polyline: a delivery, a walk, a flight). */\n path: readonly LngLat[];\n /** Its words (\"Delivery from the studio to Mile End, 2.4 km\"), read in the list of places. */\n label: string;\n /** The line draws itself once, from its start, when the map is on screen; `false` shows it whole. Default `true`. */\n draw?: boolean;\n /** How long the drawing lasts, in milliseconds. Default 1400. */\n duration?: number;\n /** A dot that travels the route once drawn (`true`, or your own content: an avatar, an icon). */\n courier?: boolean | React.ReactNode;\n /** How long a trip takes, in milliseconds. Default 3200. */\n tripDuration?: number;\n /** Start the trips again after a pause (milliseconds), only while the map is on screen. Without it the courier makes one trip. */\n repeatAfter?: number;\n /** Changing it replays the drawing and the trip. */\n replayKey?: string | number;\n /** `accent` or `soft`. Default `accent`. */\n tone?: \"accent\" | \"soft\";\n};\n\ntype RouteSource = { setData?: (d: GeoJSON.GeoJSON) => void };\n\n/** A route on the map: a line that draws itself, and a courier that travels it. Under reduced motion the line is whole and the courier rests at the end. */\nexport function Route({ path, label, draw = true, duration = 1400, courier, tripDuration = 3200, repeatAfter, replayKey, tone = \"accent\" }: RouteProps) {\n const { map: m, lib, ready, reduce, places } = useMap();\n const id = `kz-route-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const animated = draw && !reduce;\n // The drawing is done for this `replayKey` (a new key draws again); without animation the line is whole at once.\n const token = replayKey ?? 0;\n const [doneFor, setDoneFor] = React.useState<string | number | null>(null);\n const drawn = !animated || doneFor === token;\n const whole = React.useMemo(() => sliceLine(path, 1), [path]);\n const start = React.useMemo(() => sliceLine(path, 0), [path]);\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n useGeoLayer(id, drawn ? whole : start, (c) => [\n { id: `${id}-casing`, type: \"line\", source: id, layout: { \"line-cap\": \"round\", \"line-join\": \"round\" }, paint: { \"line-color\": c.accent, \"line-width\": 9, \"line-opacity\": tone === \"soft\" ? 0.08 : 0.16, \"line-blur\": 3 } },\n { id: `${id}-line`, type: \"line\", source: id, layout: { \"line-cap\": \"round\", \"line-join\": \"round\" }, paint: { \"line-color\": c.accent, \"line-width\": tone === \"soft\" ? 2 : 3.5, \"line-opacity\": tone === \"soft\" ? 0.6 : 1 } },\n ]);\n // Is the map on screen? The drawing waits for it, and a repeating trip does not start while it is not.\n const [visible, setVisible] = React.useState(() => typeof IntersectionObserver === \"undefined\");\n React.useEffect(() => {\n const el = m?.getContainer?.();\n if (!el || typeof IntersectionObserver === \"undefined\") return;\n const io = new IntersectionObserver(([entry]) => setVisible(!!entry?.isIntersecting), { threshold: 0.25 });\n io.observe(el);\n return () => io.disconnect();\n }, [m]);\n // The drawing: a finite animation, one frame per paint for `duration` ms — no loop, nothing runs afterwards.\n React.useEffect(() => {\n if (!m || !ready || !animated || drawn || !visible) return;\n const source = m.getSource(id) as RouteSource | undefined;\n let frame = 0;\n const began = performance.now();\n const step = (now: number) => {\n const t = Math.min(1, (now - began) / Math.max(1, duration));\n source?.setData?.(sliceLine(path, 1 - (1 - t) ** 3));\n if (t < 1) frame = requestAnimationFrame(step);\n else setDoneFor(token);\n };\n frame = requestAnimationFrame(step);\n return () => cancelAnimationFrame(frame);\n }, [m, ready, animated, drawn, visible, id, path, duration, token]);\n // The courier: one trip once the line is drawn (finite frames), again after `repeatAfter` while on screen.\n const [trip, setTrip] = React.useState(0);\n React.useEffect(() => {\n if (!courier || !m || !lib || !ready || !drawn) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-courier\";\n el.setAttribute(\"aria-hidden\", \"true\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...(path[reduce ? path.length - 1 : 0] ?? [0, 0])] as [number, number]).addTo(m);\n let frame = 0;\n let timer: ReturnType<typeof setTimeout> | undefined;\n if (!reduce && visible) {\n const began = performance.now();\n const step = (now: number) => {\n const t = Math.min(1, (now - began) / Math.max(1, tripDuration));\n const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;\n marker.setLngLat([...pointAlong(path, eased).position] as [number, number]);\n if (t < 1) frame = requestAnimationFrame(step);\n else if (repeatAfter !== undefined) timer = setTimeout(() => setTrip((n) => n + 1), repeatAfter);\n };\n frame = requestAnimationFrame(step);\n } else if (!reduce) marker.setLngLat([...(path[path.length - 1] ?? [0, 0])] as [number, number]);\n return () => {\n cancelAnimationFrame(frame);\n clearTimeout(timer);\n marker.remove();\n };\n }, [courier, m, lib, ready, drawn, visible, reduce, path, tripDuration, repeatAfter, trip, token]);\n return null;\n}\n\n/** Adds a GeoJSON source and its layers while the map has a style (again after a change of scheme). */\nfunction useGeoLayer(id: string, data: GeoJSON.GeoJSON, layers: (palette: ReturnType<typeof mapPalette>) => object[], source: object = {}) {\n const { map: m, ready } = useMap();\n const dataRef = React.useRef(data);\n React.useEffect(() => {\n dataRef.current = data;\n const src = m?.getSource(id) as { setData?: (d: GeoJSON.GeoJSON) => void } | undefined;\n src?.setData?.(data);\n }, [m, id, data]);\n React.useEffect(() => {\n if (!m || !ready) return;\n const add = () => {\n if (m.getSource(id)) return;\n const scheme = document.documentElement.classList.contains(\"light\") ? \"light\" : \"dark\";\n m.addSource(id, { type: \"geojson\", data: dataRef.current, ...source } as never);\n for (const layer of layers(mapPalette(scheme))) m.addLayer(layer as never);\n };\n add();\n m.on(\"styledata\", add);\n return () => {\n m.off(\"styledata\", add);\n if (!m.getStyle()) return;\n for (const layer of m.getStyle().layers.filter((l) => \"source\" in l && l.source === id)) m.removeLayer(layer.id);\n if (m.getSource(id)) m.removeSource(id);\n };\n }, [m, ready, id]); // eslint-disable-line react-hooks/exhaustive-deps\n}\n\nexport type RadiusCircleProps = {\n /** Its centre. */\n center: LngLat;\n /** Its radius in metres. */\n radius: number;\n /** `accent` (a zone that matters: a delivery area, a search radius) or `soft` (an accuracy). */\n tone?: \"accent\" | \"soft\";\n};\n\n/** A circle on the ground: a delivery zone, a search radius — filled with the accent, its edge drawn. */\nexport function RadiusCircle({ center, radius, tone = \"accent\" }: RadiusCircleProps) {\n const id = `kz-circle-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const data = React.useMemo(() => circlePolygon(center, radius), [center[0], center[1], radius]); // eslint-disable-line react-hooks/exhaustive-deps\n useGeoLayer(id, data, (c) => [\n { id: `${id}-fill`, type: \"fill\", source: id, paint: { \"fill-color\": c.accent, \"fill-opacity\": tone === \"soft\" ? 0.1 : 0.14 } },\n { id: `${id}-line`, type: \"line\", source: id, paint: { \"line-color\": c.accent, \"line-width\": tone === \"soft\" ? 1 : 2, \"line-opacity\": tone === \"soft\" ? 0.4 : 0.9, ...(tone === \"accent\" ? { \"line-dasharray\": [2, 1.5] } : {}) } },\n ]);\n return null;\n}\n\nexport type ClusterPoint = { id: string; position: LngLat; label: string };\n\nexport type ClusterProps = {\n /** The points: many of them (creators, pick-up points, events). */\n points: readonly ClusterPoint[];\n /** Pressing a single point. A group zooms in to open itself. */\n onPointPress?: (point: ClusterPoint) => void;\n /** The radius, in pixels, within which points group. Default 48. */\n radius?: number;\n};\n\n/** Many points grouped while they are close: a disc with the count, opened by zooming in; each point is listed for screen readers. */\nexport function Cluster({ points, onPointPress, radius = 48 }: ClusterProps) {\n const { map: m, ready, reduce, places } = useMap();\n const id = `kz-cluster-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const data = React.useMemo<GeoJSON.FeatureCollection>(\n () => ({ type: \"FeatureCollection\", features: points.map((p) => ({ type: \"Feature\", properties: { id: p.id, label: p.label }, geometry: { type: \"Point\", coordinates: [...p.position] } })) }),\n [points],\n );\n React.useEffect(() => {\n for (const p of points) places.add(`${id}-${p.id}`, p.label);\n return () => points.forEach((p) => places.remove(`${id}-${p.id}`));\n }, [points, places, id]);\n useGeoLayer(\n id,\n data,\n (c) => [\n { id: `${id}-groups`, type: \"circle\", source: id, filter: [\"has\", \"point_count\"], paint: { \"circle-color\": c.accent, \"circle-opacity\": 0.92, \"circle-radius\": [\"step\", [\"get\", \"point_count\"], 16, 10, 20, 50, 26], \"circle-stroke-width\": 3, \"circle-stroke-color\": c.halo } },\n { id: `${id}-count`, type: \"symbol\", source: id, filter: [\"has\", \"point_count\"], layout: { \"text-field\": [\"get\", \"point_count_abbreviated\"], \"text-font\": [\"Noto Sans Medium\"], \"text-size\": 13 }, paint: { \"text-color\": c.onAccent } },\n { id: `${id}-points`, type: \"circle\", source: id, filter: [\"!\", [\"has\", \"point_count\"]], paint: { \"circle-color\": c.accent, \"circle-radius\": 7, \"circle-stroke-width\": 2.5, \"circle-stroke-color\": c.halo } },\n ],\n { cluster: true, clusterRadius: radius, clusterMaxZoom: 15 },\n );\n React.useEffect(() => {\n if (!m || !ready) return;\n const openGroup = async (e: { features?: { properties: Record<string, unknown>; geometry: GeoJSON.Geometry }[] }) => {\n const f = e.features?.[0];\n if (!f) return;\n const src = m.getSource(id) as unknown as { getClusterExpansionZoom: (id: number) => Promise<number> };\n const z = await src.getClusterExpansionZoom(f.properties.cluster_id as number);\n const center = (f.geometry as GeoJSON.Point).coordinates as [number, number];\n if (reduce) m.jumpTo({ center, zoom: z });\n else m.easeTo({ center, zoom: z });\n };\n const pressPoint = (e: { features?: { properties: Record<string, unknown> }[] }) => {\n const pid = e.features?.[0]?.properties.id;\n const point = points.find((p) => p.id === pid);\n if (point) onPointPress?.(point);\n };\n m.on(\"click\", `${id}-groups`, openGroup as never);\n m.on(\"click\", `${id}-points`, pressPoint as never);\n return () => {\n m.off(\"click\", `${id}-groups`, openGroup as never);\n m.off(\"click\", `${id}-points`, pressPoint as never);\n };\n }, [m, ready, id, points, onPointPress, reduce]);\n return null;\n}\n"
1049
+ "content": "// Client. A map of the Krizaka family on MapLibre GL JS (open source) over Protomaps tiles on our CDN — no API key, no\n// metered service. MapLibre and PMTiles are optional peers, loaded when a map is shown (a dynamic import: a page without\n// a map never pays for them). The style comes from the tokens (`mapStyle`), in the page's scheme, and follows it.\n//\n// What every map does: the canvas is decorative for assistive technologies — the map is a region named by `label`, its\n// places a list read in order (the text alternative, `MapPlaces`), each marker a button when it does something; the\n// attribution of OpenStreetMap (ODbL) and Protomaps is always shown; under reduced motion the camera jumps, never flies;\n// without WebGL (or while the engine loads) `fallback` stands in its place.\n/// <reference types=\"geojson\" />\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\n\nimport { usePrefersReducedMotion } from \"../media\";\nimport { tv } from \"../tv\";\nimport { MAP_ATTRIBUTION, setMapAssetsBase } from \"./assets\";\nimport { circlePolygon, type LngLat, pointAlong, sliceLine } from \"./geo\";\nimport { type MapBrand, mapPalette, type MapRegion, mapStyle } from \"./style\";\n\ntype MapLibre = typeof import(\"maplibre-gl\");\ntype MapInstance = import(\"maplibre-gl\").Map;\n\nexport const map = tv({\n slots: {\n root: \"relative isolate overflow-hidden rounded-2xl border border-border-default bg-surface-2 shadow-raised\",\n canvas: \"absolute inset-0 [&_.maplibregl-canvas]:outline-hidden\",\n fallback: \"absolute inset-0\",\n controls: \"absolute right-3 top-3 z-10 flex flex-col overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-floating\",\n control:\n \"inline-flex h-10 w-10 items-center justify-center text-lg font-semibold text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:focus-ring-inset disabled:opacity-40 [&+&]:border-t [&+&]:border-border-subtle\",\n attribution:\n \"absolute bottom-1.5 right-1.5 z-10 rounded-md bg-surface-1/85 px-1.5 py-0.5 text-[10px] text-fg-secondary backdrop-blur-sm [&_a]:underline-offset-2 hover:[&_a]:underline\",\n places: \"sr-only\",\n marker:\n \"group relative flex -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-2 border-on-accent bg-accent shadow-floating outline-hidden \" +\n \"transition-transform hover:scale-110 focus-visible:focus-ring motion-reduce:transition-none motion-reduce:hover:scale-100 data-[selected]:scale-125\",\n markerLabel:\n \"pointer-events-none absolute bottom-full mb-2 whitespace-nowrap rounded-lg border border-border-default bg-surface-1 px-2 py-1 text-xs font-semibold text-fg shadow-floating\",\n },\n});\n\ntype Registry = { add: (id: string, text: string) => void; remove: (id: string) => void };\ntype Ctx = { map: MapInstance | null; lib: MapLibre | null; ready: boolean; reduce: boolean; places: Registry };\nconst MapContext = React.createContext<Ctx | null>(null);\n\n/** The map a child is drawn on (`Marker`, `RadiusCircle`, `Cluster`…): the MapLibre instance once it is ready. */\nexport function useMap(): Ctx {\n const ctx = React.useContext(MapContext);\n if (!ctx) throw new Error(\"useMap: inside a <Map> only\");\n return ctx;\n}\n\n/** The scheme of the page (`html.light`), followed as it changes. */\nfunction usePageScheme(): \"dark\" | \"light\" {\n const read = () => (typeof document !== \"undefined\" && document.documentElement.classList.contains(\"light\") ? \"light\" : \"dark\");\n const [scheme, setScheme] = React.useState<\"dark\" | \"light\">(read);\n React.useEffect(() => {\n const observer = new MutationObserver(() => setScheme(read()));\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n return () => observer.disconnect();\n }, []);\n return scheme;\n}\n\nlet protocolAdded = false;\nlet workerUrl: string | null = null;\n\n/**\n * Where MapLibre's worker comes from. A bundler (Next.js, Vite) renames MapLibre's module and leaves its worker behind:\n * by default the worker of the very version the app installed is imported from jsDelivr (free, open source, no key)\n * through a module worker of the page's own origin. An app with a strict CSP serves it itself —\n * `configureMaps({ workerUrl: \"/maplibre-gl-worker.mjs\" })` (with `maplibre-gl-shared.mjs` beside it), once, before a map.\n * `assetsBase` reads the tiles and glyphs through a same-origin proxy of the CDN (`setMapAssetsBase`).\n */\nexport function configureMaps(options: { workerUrl?: string; assetsBase?: string }) {\n if (options.workerUrl) workerUrl = options.workerUrl;\n if (options.assetsBase) setMapAssetsBase(options.assetsBase);\n}\n\n/** MapLibre, its worker and the PMTiles protocol, loaded once for the page. */\nasync function loadEngine(): Promise<MapLibre> {\n const [lib, pmtiles] = await Promise.all([import(\"maplibre-gl\"), import(\"pmtiles\")]);\n const ml = ((lib as unknown as { default?: MapLibre }).default ?? lib) as MapLibre;\n if (!protocolAdded) {\n protocolAdded = true;\n const remote = `https://cdn.jsdelivr.net/npm/maplibre-gl@${ml.getVersion()}/dist/maplibre-gl-worker.mjs`;\n ml.setWorkerUrl(workerUrl ?? URL.createObjectURL(new Blob([`import ${JSON.stringify(remote)};`], { type: \"text/javascript\" })));\n ml.addProtocol(\"pmtiles\", new pmtiles.Protocol().tile);\n }\n return ml;\n}\n\nconst hasWebGL = () => {\n try {\n const canvas = document.createElement(\"canvas\");\n return Boolean(canvas.getContext(\"webgl2\") ?? canvas.getContext(\"webgl\"));\n } catch {\n return false;\n }\n};\n\nexport type MapLabels = {\n /** The zoom-in button (\"Zoom in\"). */\n zoomIn: string;\n /** The zoom-out button (\"Zoom out\"). */\n zoomOut: string;\n /** Before the list of places read in place of the map (\"Places on the map\"). */\n places: string;\n};\n\nexport const mapLabels: MapLabels = { zoomIn: \"Zoom in\", zoomOut: \"Zoom out\", places: \"Places on the map\" };\n\nexport type MapProps = {\n /** The map's accessible name (\"Pick-up points near you\"), passed translated. */\n label: string;\n /** Where the map looks at first: longitude, latitude. */\n center: LngLat;\n /** The zoom at first (0 the world, 12 a city, 16 a street). Default 12. */\n zoom?: number;\n /** The camera follows `center` and `zoom` when they change (flies, or jumps under reduced motion). */\n controlled?: boolean;\n /** The scheme. Default: the page's (`html.light`), followed. */\n scheme?: \"dark\" | \"light\";\n /** The product whose accent the map carries. Default `krizaka`. */\n brand?: MapBrand;\n /** The language of the labels (`fr`, `en`). Default `en`. */\n lang?: string;\n /** The detailed region (PMTiles) drawn from zoom 6.5. Default: our Montréal–Québec extract (to zoom 14). */\n region?: MapRegion | null;\n /** The world's low zooms (PMTiles). Default: our world extract (to zoom 6). */\n world?: MapRegion | null;\n /** The zoom buttons (default) — the keyboard also zooms with + and − on the focused map. */\n controls?: boolean;\n /** Moves with the hand (default); `false` makes a still picture of a place. */\n interactive?: boolean;\n /** Shown in place of the map while its engine loads, and for good without WebGL: a `StaticMap`, a picture. */\n fallback?: React.ReactNode;\n /** Called when the camera rests: its centre and zoom. */\n onMoveEnd?: (view: { center: LngLat; zoom: number }) => void;\n /** Called with the position pressed on the map (not on a marker). */\n onPress?: (position: LngLat) => void;\n /** Markers, circles, clusters, the user's position — and anything drawn over the map. */\n children?: React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<MapLabels>;\n /** Classes merged on the root (its height: `h-80`, `aspect-video`). */\n className?: string;\n};\n\n/** A map: MapLibre over our Protomaps tiles, styled from the tokens, with its places as a list for screen readers. */\nexport function Map({\n label,\n center,\n zoom = 12,\n controlled,\n scheme: schemeProp,\n brand,\n lang,\n region,\n world,\n controls = true,\n interactive = true,\n fallback,\n onMoveEnd,\n onPress,\n children,\n labels: given,\n className,\n}: MapProps) {\n const labels = { ...mapLabels, ...given };\n const s = map();\n const pageScheme = usePageScheme();\n const scheme = schemeProp ?? pageScheme;\n const reduce = usePrefersReducedMotion();\n const host = React.useRef<HTMLDivElement>(null);\n const [instance, setInstance] = React.useState<MapInstance | null>(null);\n const [lib, setLib] = React.useState<MapLibre | null>(null);\n const [ready, setReady] = React.useState(false);\n const [failed, setFailed] = React.useState(false);\n const [places, setPlaces] = React.useState<Record<string, string>>({});\n const registry = React.useMemo<Registry>(\n () => ({\n add: (id, text) => setPlaces((p) => (p[id] === text ? p : { ...p, [id]: text })),\n remove: (id) =>\n setPlaces((p) => {\n const { [id]: _gone, ...rest } = p;\n return rest;\n }),\n }),\n [],\n );\n const latest = React.useRef({ onMoveEnd, onPress });\n React.useEffect(() => {\n latest.current = { onMoveEnd, onPress };\n });\n const styleOf = React.useCallback((sc: \"dark\" | \"light\") => mapStyle({ scheme: sc, brand, lang, ...(region !== undefined ? { region } : {}), ...(world !== undefined ? { world } : {}) }), [brand, lang, region, world]);\n\n // The style the map wears now: the engine loads asynchronously, so the scheme it is created with is read when it is ready\n // (the page's theme may have flipped since the first render), and the effect below applies whatever differs.\n const wanted = React.useRef({ scheme, styleOf });\n React.useEffect(() => {\n wanted.current = { scheme, styleOf };\n });\n const applied = React.useRef<{ scheme: \"dark\" | \"light\"; styleOf: typeof styleOf } | null>(null);\n\n // The engine and the map, once (the style follows the scheme below).\n React.useEffect(() => {\n if (!host.current) return;\n let alive = true;\n let created: MapInstance | null = null;\n (hasWebGL() ? loadEngine() : Promise.reject(new Error(\"no WebGL\")))\n .then((ml) => {\n if (!alive || !host.current) return;\n applied.current = wanted.current;\n created = new ml.Map({\n container: host.current,\n style: wanted.current.styleOf(wanted.current.scheme) as never,\n center: [...center] as [number, number],\n zoom,\n interactive,\n attributionControl: false,\n fadeDuration: reduce ? 0 : 300,\n cooperativeGestures: false,\n });\n created.getCanvas().setAttribute(\"aria-hidden\", \"true\");\n created.getCanvas().setAttribute(\"tabindex\", \"-1\");\n created.on(\"load\", () => alive && setReady(true));\n // The basemap cannot be read (offline, a blocked request): the fallback comes back rather than an empty map.\n created.on(\"error\", (e) => {\n const source = (e as { sourceId?: string }).sourceId;\n if (alive && (source === \"world\" || source === \"region\")) setFailed(true);\n });\n created.on(\"moveend\", () => {\n const c = created!.getCenter();\n latest.current.onMoveEnd?.({ center: [c.lng, c.lat], zoom: created!.getZoom() });\n });\n created.on(\"click\", (e) => {\n if ((e.originalEvent.target as Element | null)?.closest?.(\"[data-kz-marker]\")) return;\n latest.current.onPress?.([e.lngLat.lng, e.lngLat.lat]);\n });\n setLib(ml);\n setInstance(created);\n })\n .catch(() => alive && setFailed(true));\n return () => {\n alive = false;\n created?.remove();\n };\n // Created once: the props that change afterwards are followed by the effects below.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // The scheme (and the brand, the language) change the style, not the map.\n React.useEffect(() => {\n if (!instance) return;\n if (applied.current?.scheme === scheme && applied.current.styleOf === styleOf) return;\n applied.current = { scheme, styleOf };\n instance.setStyle(styleOf(scheme) as never);\n }, [instance, scheme, styleOf]);\n\n // A controlled camera: it flies (or jumps) to the new view.\n React.useEffect(() => {\n if (!instance || !controlled) return;\n const view = { center: [...center] as [number, number], zoom };\n if (reduce) instance.jumpTo(view);\n else instance.flyTo({ ...view, speed: 1.4 });\n }, [instance, controlled, center[0], center[1], zoom, reduce]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const zoomBy = (by: number) => instance && (reduce ? instance.setZoom(instance.getZoom() + by) : instance.zoomTo(instance.getZoom() + by, { duration: 250 }));\n const ctx = React.useMemo<Ctx>(() => ({ map: instance, lib, ready, reduce, places: registry }), [instance, lib, ready, reduce, registry]);\n const listed = Object.entries(places);\n return (\n <MapContext.Provider value={ctx}>\n <section\n aria-label={label}\n className={s.root({ className })}\n data-ready={ready ? \"\" : undefined}\n tabIndex={interactive ? 0 : undefined}\n onKeyDown={(e) => {\n if (e.target !== e.currentTarget || !instance) return;\n if (e.key === \"+\" || e.key === \"=\") zoomBy(1);\n else if (e.key === \"-\") zoomBy(-1);\n else if (e.key.startsWith(\"Arrow\")) {\n e.preventDefault();\n const step = 80;\n const by: Record<string, [number, number]> = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] };\n instance.panBy(by[e.key]!, { duration: reduce ? 0 : 200 });\n }\n }}\n >\n {!ready || failed ? <div className={s.fallback()}>{fallback}</div> : null}\n <div ref={host} className={s.canvas()} style={{ position: \"absolute\", inset: 0, visibility: failed ? \"hidden\" : undefined }} />\n {controls && interactive && !failed ? (\n <div className={s.controls()}>\n <button type=\"button\" className={s.control()} aria-label={labels.zoomIn} disabled={!instance} onClick={() => zoomBy(1)}>\n +\n </button>\n <button type=\"button\" className={s.control()} aria-label={labels.zoomOut} disabled={!instance} onClick={() => zoomBy(-1)}>\n −\n </button>\n </div>\n ) : null}\n <p className={s.attribution()}>\n <a href={MAP_ATTRIBUTION.protomapsUrl} target=\"_blank\" rel=\"noreferrer\">\n {MAP_ATTRIBUTION.protomaps}\n </a>\n {\" · \"}\n <a href={MAP_ATTRIBUTION.osmUrl} target=\"_blank\" rel=\"noreferrer\">\n {MAP_ATTRIBUTION.osm}\n </a>\n </p>\n {listed.length ? (\n <div className={s.places()}>\n <p>{labels.places}</p>\n <ul>\n {listed.map(([id, text]) => (\n <li key={id}>{text}</li>\n ))}\n </ul>\n </div>\n ) : null}\n {children}\n </section>\n </MapContext.Provider>\n );\n}\n\nexport type MarkerProps = {\n /** Where it stands. */\n position: LngLat;\n /** Its words: the place's name, read in the list of places and as the button's name (\"Café Olimpico, 350 m\"). */\n label: string;\n /** Pressing it (details in a panel, a sheet): the marker becomes a button. */\n onPress?: () => void;\n /** The chosen one: larger, its label shown above it. */\n selected?: boolean;\n /** Its size in pixels. Default 18. */\n size?: number;\n /** What it draws instead of the accent dot (an avatar, an icon). */\n children?: React.ReactNode;\n /** Drops onto its place once, after this many milliseconds (stagger several: `index * 90`). Still under reduced motion. */\n drop?: number;\n};\n\nconst noSubscription = () => () => undefined;\n\n/** A place on the map: an accent dot (or your content) that is a button when it does something. */\nexport function Marker({ position, label, onPress, selected, size = 18, children, drop }: MarkerProps) {\n const { map: m, lib, places } = useMap();\n const s = map();\n const id = React.useId();\n // The marker's host element exists only in a browser: the server and the hydration pass render nothing for it, then the\n // client swaps in the element — `useSyncExternalStore`'s server snapshot, so hydration never sees a difference.\n const host = React.useRef<HTMLElement | null>(null);\n const el = React.useSyncExternalStore(\n noSubscription,\n () => (host.current ??= document.createElement(\"div\")),\n () => null,\n );\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib || !el) return;\n el.setAttribute(\"data-kz-marker\", \"\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, el, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n if (!el) return null;\n const Tag = onPress ? \"button\" : \"span\";\n return createPortal(\n <Tag\n type={onPress ? \"button\" : undefined}\n aria-label={onPress ? label : undefined}\n aria-hidden={onPress ? undefined : true}\n aria-pressed={onPress && selected !== undefined ? selected : undefined}\n data-selected={selected ? \"\" : undefined}\n data-drop={drop === undefined ? undefined : \"\"}\n className={s.marker()}\n style={{ ...(children ? null : { width: size, height: size }), ...(drop === undefined ? null : ({ \"--kz-map-drop-delay\": `${drop}ms` } as React.CSSProperties)) }}\n onClick={onPress}\n >\n {children}\n {selected ? <span className={s.markerLabel()}>{label}</span> : null}\n </Tag>,\n el,\n );\n}\n\nexport type UserPositionProps = {\n /** Where the person is. */\n position: LngLat;\n /** Its words (\"You are here\"), read in the list of places. */\n label: string;\n /** The accuracy in metres: a soft disc around the dot. */\n accuracy?: number;\n};\n\n/** The person's position: a dot that pulses (still under reduced motion), its accuracy as a soft disc. */\nexport function UserPosition({ position, label, accuracy }: UserPositionProps) {\n const { map: m, lib, places } = useMap();\n const id = React.useId();\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-user\";\n el.setAttribute(\"aria-hidden\", \"true\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n return accuracy ? <RadiusCircle center={position} radius={accuracy} tone=\"soft\" /> : null;\n}\n\nexport type PulseProps = {\n /** Where it beats. */\n position: LngLat;\n /** Its words (\"High demand in Mile End\"), read in the list of places. */\n label: string;\n /** The rings' reach in pixels. Default 56. */\n size?: number;\n /** `accent` (a zone that matters) or `soft`. Default `accent`. */\n tone?: \"accent\" | \"soft\";\n};\n\n/** A heat pulse: rings that swell and fade around a place (a hot spot, a demand peak). Still rings under reduced motion. */\nexport function Pulse({ position, label, size = 56, tone = \"accent\" }: PulseProps) {\n const { map: m, lib, places } = useMap();\n const id = React.useId();\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-pulse\";\n el.setAttribute(\"data-tone\", tone);\n el.setAttribute(\"aria-hidden\", \"true\");\n el.style.setProperty(\"--kz-map-pulse-size\", `${size}px`);\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, tone, size, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n return null;\n}\n\nexport type RouteProps = {\n /** The path, as positions in order (a polyline: a delivery, a walk, a flight). */\n path: readonly LngLat[];\n /** Its words (\"Delivery from the studio to Mile End, 2.4 km\"), read in the list of places. */\n label: string;\n /** The line draws itself once, from its start, when the map is on screen; `false` shows it whole. Default `true`. */\n draw?: boolean;\n /** How long the drawing lasts, in milliseconds. Default 1400. */\n duration?: number;\n /** A dot that travels the route once drawn (`true`, or your own content: an avatar, an icon). */\n courier?: boolean | React.ReactNode;\n /** How long a trip takes, in milliseconds. Default 3200. */\n tripDuration?: number;\n /** Start the trips again after a pause (milliseconds), only while the map is on screen. Without it the courier makes one trip. */\n repeatAfter?: number;\n /** Changing it replays the drawing and the trip. */\n replayKey?: string | number;\n /** `accent` or `soft`. Default `accent`. */\n tone?: \"accent\" | \"soft\";\n /** The timing function of the drawing and of the trips: `ease-out`, `linear`, `ease-in-out`. Default: out for the drawing, in-out for a trip. */\n easing?: \"ease-out\" | \"linear\" | \"ease-in-out\";\n /** Holds the drawing and the trips where they are (a controlled pause, on top of the off-screen one). */\n paused?: boolean;\n};\n\nconst ROUTE_CURVES = {\n linear: (t: number) => t,\n \"ease-out\": (t: number) => 1 - (1 - t) ** 3,\n \"ease-in-out\": (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2),\n} as const;\n\ntype RouteSource = { setData?: (d: GeoJSON.GeoJSON) => void };\n\n/** A route on the map: a line that draws itself, and a courier that travels it. Under reduced motion the line is whole and the courier rests at the end. */\nexport function Route({ path, label, draw = true, duration = 1400, courier, tripDuration = 3200, repeatAfter, replayKey, tone = \"accent\", easing, paused = false }: RouteProps) {\n const { map: m, lib, ready, reduce, places } = useMap();\n const id = `kz-route-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const animated = draw && !reduce;\n // The drawing is done for this `replayKey` (a new key draws again); without animation the line is whole at once.\n const token = replayKey ?? 0;\n const [doneFor, setDoneFor] = React.useState<string | number | null>(null);\n const drawn = !animated || doneFor === token;\n const whole = React.useMemo(() => sliceLine(path, 1), [path]);\n const start = React.useMemo(() => sliceLine(path, 0), [path]);\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n useGeoLayer(id, drawn ? whole : start, (c) => [\n { id: `${id}-casing`, type: \"line\", source: id, layout: { \"line-cap\": \"round\", \"line-join\": \"round\" }, paint: { \"line-color\": c.accent, \"line-width\": 9, \"line-opacity\": tone === \"soft\" ? 0.08 : 0.16, \"line-blur\": 3 } },\n { id: `${id}-line`, type: \"line\", source: id, layout: { \"line-cap\": \"round\", \"line-join\": \"round\" }, paint: { \"line-color\": c.accent, \"line-width\": tone === \"soft\" ? 2 : 3.5, \"line-opacity\": tone === \"soft\" ? 0.6 : 1 } },\n ]);\n // Is the map on screen? The drawing waits for it, and a repeating trip does not start while it is not.\n const [seen, setSeen] = React.useState(() => typeof IntersectionObserver === \"undefined\");\n const visible = seen && !paused;\n React.useEffect(() => {\n const el = m?.getContainer?.();\n if (!el || typeof IntersectionObserver === \"undefined\") return;\n const io = new IntersectionObserver(([entry]) => setSeen(!!entry?.isIntersecting), { threshold: 0.25 });\n io.observe(el);\n return () => io.disconnect();\n }, [m]);\n // The drawing: a finite animation, one frame per paint for `duration` ms — no loop, nothing runs afterwards.\n React.useEffect(() => {\n if (!m || !ready || !animated || drawn || !visible) return;\n const source = m.getSource(id) as RouteSource | undefined;\n let frame = 0;\n const began = performance.now();\n const step = (now: number) => {\n const t = Math.min(1, (now - began) / Math.max(1, duration));\n source?.setData?.(sliceLine(path, ROUTE_CURVES[easing ?? \"ease-out\"](t)));\n if (t < 1) frame = requestAnimationFrame(step);\n else setDoneFor(token);\n };\n frame = requestAnimationFrame(step);\n return () => cancelAnimationFrame(frame);\n }, [m, ready, animated, drawn, visible, id, path, duration, token, easing]);\n // The courier: one trip once the line is drawn (finite frames), again after `repeatAfter` while on screen.\n const [trip, setTrip] = React.useState(0);\n React.useEffect(() => {\n if (!courier || !m || !lib || !ready || !drawn) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-courier\";\n el.setAttribute(\"aria-hidden\", \"true\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...(path[reduce ? path.length - 1 : 0] ?? [0, 0])] as [number, number]).addTo(m);\n let frame = 0;\n let timer: ReturnType<typeof setTimeout> | undefined;\n if (!reduce && visible) {\n const began = performance.now();\n const step = (now: number) => {\n const t = Math.min(1, (now - began) / Math.max(1, tripDuration));\n const eased = ROUTE_CURVES[easing ?? \"ease-in-out\"](t);\n marker.setLngLat([...pointAlong(path, eased).position] as [number, number]);\n if (t < 1) frame = requestAnimationFrame(step);\n else if (repeatAfter !== undefined) timer = setTimeout(() => setTrip((n) => n + 1), repeatAfter);\n };\n frame = requestAnimationFrame(step);\n } else if (!reduce) marker.setLngLat([...(path[path.length - 1] ?? [0, 0])] as [number, number]);\n return () => {\n cancelAnimationFrame(frame);\n clearTimeout(timer);\n marker.remove();\n };\n }, [courier, m, lib, ready, drawn, visible, reduce, path, tripDuration, repeatAfter, trip, token, easing]);\n return null;\n}\n\n/** Adds a GeoJSON source and its layers while the map has a style (again after a change of scheme). */\nfunction useGeoLayer(id: string, data: GeoJSON.GeoJSON, layers: (palette: ReturnType<typeof mapPalette>) => object[], source: object = {}) {\n const { map: m, ready } = useMap();\n const dataRef = React.useRef(data);\n React.useEffect(() => {\n dataRef.current = data;\n const src = m?.getSource(id) as { setData?: (d: GeoJSON.GeoJSON) => void } | undefined;\n src?.setData?.(data);\n }, [m, id, data]);\n React.useEffect(() => {\n if (!m || !ready) return;\n const add = () => {\n if (m.getSource(id)) return;\n const scheme = document.documentElement.classList.contains(\"light\") ? \"light\" : \"dark\";\n m.addSource(id, { type: \"geojson\", data: dataRef.current, ...source } as never);\n for (const layer of layers(mapPalette(scheme))) m.addLayer(layer as never);\n };\n add();\n m.on(\"styledata\", add);\n return () => {\n m.off(\"styledata\", add);\n if (!m.getStyle()) return;\n for (const layer of m.getStyle().layers.filter((l) => \"source\" in l && l.source === id)) m.removeLayer(layer.id);\n if (m.getSource(id)) m.removeSource(id);\n };\n }, [m, ready, id]); // eslint-disable-line react-hooks/exhaustive-deps\n}\n\nexport type RadiusCircleProps = {\n /** Its centre. */\n center: LngLat;\n /** Its radius in metres. */\n radius: number;\n /** `accent` (a zone that matters: a delivery area, a search radius) or `soft` (an accuracy). */\n tone?: \"accent\" | \"soft\";\n};\n\n/** A circle on the ground: a delivery zone, a search radius — filled with the accent, its edge drawn. */\nexport function RadiusCircle({ center, radius, tone = \"accent\" }: RadiusCircleProps) {\n const id = `kz-circle-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const data = React.useMemo(() => circlePolygon(center, radius), [center[0], center[1], radius]); // eslint-disable-line react-hooks/exhaustive-deps\n useGeoLayer(id, data, (c) => [\n { id: `${id}-fill`, type: \"fill\", source: id, paint: { \"fill-color\": c.accent, \"fill-opacity\": tone === \"soft\" ? 0.1 : 0.14 } },\n { id: `${id}-line`, type: \"line\", source: id, paint: { \"line-color\": c.accent, \"line-width\": tone === \"soft\" ? 1 : 2, \"line-opacity\": tone === \"soft\" ? 0.4 : 0.9, ...(tone === \"accent\" ? { \"line-dasharray\": [2, 1.5] } : {}) } },\n ]);\n return null;\n}\n\nexport type ClusterPoint = { id: string; position: LngLat; label: string };\n\nexport type ClusterProps = {\n /** The points: many of them (creators, pick-up points, events). */\n points: readonly ClusterPoint[];\n /** Pressing a single point. A group zooms in to open itself. */\n onPointPress?: (point: ClusterPoint) => void;\n /** The radius, in pixels, within which points group. Default 48. */\n radius?: number;\n};\n\n/** Many points grouped while they are close: a disc with the count, opened by zooming in; each point is listed for screen readers. */\nexport function Cluster({ points, onPointPress, radius = 48 }: ClusterProps) {\n const { map: m, ready, reduce, places } = useMap();\n const id = `kz-cluster-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const data = React.useMemo<GeoJSON.FeatureCollection>(\n () => ({ type: \"FeatureCollection\", features: points.map((p) => ({ type: \"Feature\", properties: { id: p.id, label: p.label }, geometry: { type: \"Point\", coordinates: [...p.position] } })) }),\n [points],\n );\n React.useEffect(() => {\n for (const p of points) places.add(`${id}-${p.id}`, p.label);\n return () => points.forEach((p) => places.remove(`${id}-${p.id}`));\n }, [points, places, id]);\n useGeoLayer(\n id,\n data,\n (c) => [\n { id: `${id}-groups`, type: \"circle\", source: id, filter: [\"has\", \"point_count\"], paint: { \"circle-color\": c.accent, \"circle-opacity\": 0.92, \"circle-radius\": [\"step\", [\"get\", \"point_count\"], 16, 10, 20, 50, 26], \"circle-stroke-width\": 3, \"circle-stroke-color\": c.halo } },\n { id: `${id}-count`, type: \"symbol\", source: id, filter: [\"has\", \"point_count\"], layout: { \"text-field\": [\"get\", \"point_count_abbreviated\"], \"text-font\": [\"Noto Sans Medium\"], \"text-size\": 13 }, paint: { \"text-color\": c.onAccent } },\n { id: `${id}-points`, type: \"circle\", source: id, filter: [\"!\", [\"has\", \"point_count\"]], paint: { \"circle-color\": c.accent, \"circle-radius\": 7, \"circle-stroke-width\": 2.5, \"circle-stroke-color\": c.halo } },\n ],\n { cluster: true, clusterRadius: radius, clusterMaxZoom: 15 },\n );\n React.useEffect(() => {\n if (!m || !ready) return;\n const openGroup = async (e: { features?: { properties: Record<string, unknown>; geometry: GeoJSON.Geometry }[] }) => {\n const f = e.features?.[0];\n if (!f) return;\n const src = m.getSource(id) as unknown as { getClusterExpansionZoom: (id: number) => Promise<number> };\n const z = await src.getClusterExpansionZoom(f.properties.cluster_id as number);\n const center = (f.geometry as GeoJSON.Point).coordinates as [number, number];\n if (reduce) m.jumpTo({ center, zoom: z });\n else m.easeTo({ center, zoom: z });\n };\n const pressPoint = (e: { features?: { properties: Record<string, unknown> }[] }) => {\n const pid = e.features?.[0]?.properties.id;\n const point = points.find((p) => p.id === pid);\n if (point) onPointPress?.(point);\n };\n m.on(\"click\", `${id}-groups`, openGroup as never);\n m.on(\"click\", `${id}-points`, pressPoint as never);\n return () => {\n m.off(\"click\", `${id}-groups`, openGroup as never);\n m.off(\"click\", `${id}-points`, pressPoint as never);\n };\n }, [m, ready, id, points, onPointPress, reduce]);\n return null;\n}\n"
1036
1050
  },
1037
1051
  {
1038
1052
  "path": "map/meta.ts",
@@ -1060,7 +1074,7 @@
1060
1074
  }
1061
1075
  ],
1062
1076
  "dependencies": [
1063
- "@krizaka/tokens@^3.33.1",
1077
+ "@krizaka/tokens@^3.34.0",
1064
1078
  "@protomaps/basemaps@5.7.2",
1065
1079
  "tailwind-variants@^3.3.1"
1066
1080
  ],
@@ -1495,6 +1509,20 @@
1495
1509
  "description": "How long the drawing lasts, in milliseconds. Default 1400.",
1496
1510
  "required": false
1497
1511
  },
1512
+ {
1513
+ "name": "easing",
1514
+ "type": "\"linear\" | \"ease-in-out\" | \"ease-out\"",
1515
+ "default": null,
1516
+ "description": "The timing function of the drawing and of the trips: `ease-out`, `linear`, `ease-in-out`. Default: out for the drawing, in-out for a trip.",
1517
+ "required": false
1518
+ },
1519
+ {
1520
+ "name": "paused",
1521
+ "type": "boolean",
1522
+ "default": "false",
1523
+ "description": "Holds the drawing and the trips where they are (a controlled pause, on top of the off-screen one).",
1524
+ "required": false
1525
+ },
1498
1526
  {
1499
1527
  "name": "repeatAfter",
1500
1528
  "type": "number",
@@ -618,7 +618,7 @@
618
618
  }
619
619
  ],
620
620
  "dependencies": [
621
- "@krizaka/tokens@^3.33.1",
621
+ "@krizaka/tokens@^3.34.0",
622
622
  "radix-ui@^1.7.0",
623
623
  "tailwind-variants@^3.3.1"
624
624
  ],
@@ -87,8 +87,15 @@
87
87
  {
88
88
  "name": "duration",
89
89
  "type": "number",
90
- "default": "1100",
91
- "description": "Milliseconds of the climb.",
90
+ "default": null,
91
+ "description": "Milliseconds of the climb (`view`) or of each digit's roll (`change`). Unset: 1100 ms to climb, 900 ms to roll.",
92
+ "required": false
93
+ },
94
+ {
95
+ "name": "easing",
96
+ "type": "(string & {}) | \"linear\" | \"ease-in-out\" | \"ease-out\"",
97
+ "default": null,
98
+ "description": "The timing function of the climb and of the roll: `ease-out` (the climb's own curve), `linear`, `ease-in-out`, or any\nCSS `<easing-function>`. Unset: the platform's curve (`--kz-ease`).",
92
99
  "required": false
93
100
  },
94
101
  {
@@ -254,7 +261,7 @@
254
261
  },
255
262
  {
256
263
  "path": "number-ticker/number-ticker.tsx",
257
- "content": "// Client (an observer, a short count). A figure that climbs — without the faults of the usual counters: the server\n// renders the final value (crawlers, screen readers, no-JS and reduced-motion readers all read it), an invisible copy\n// holds its width from the first paint (no layout shift), a figure already on screen at load does not drop to 0 in\n// front of the reader, the count is a bounded loop (~1 s) that stops for good, and assistive technology reads only the\n// final value. On `trigger=\"change\"` each digit rolls to its new value (a live total: tips, bids) and the change can be\n// announced.\nimport * as React from \"react\";\n\nimport { cx } from \"../cx\";\n\nexport interface NumberTickerProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n /** The value to show. */\n value: number;\n /** The locale of the digits and separators (`\"fr\"`: 1 204,5) — the app's locale. */\n locale?: string;\n /** How to write it (`{ style: \"currency\", currency: \"EUR\" }`, `{ maximumFractionDigits: 1 }`…): Intl.NumberFormat options. */\n format?: Intl.NumberFormatOptions;\n /**\n * `view`: climbs from 0 once, the first time it scrolls into view. `change`: each digit rolls to the new value\n * whenever `value` changes (a live total).\n * @default \"view\"\n */\n trigger?: \"view\" | \"change\";\n /**\n * Milliseconds of the climb.\n * @default 1100\n */\n duration?: number;\n /** Announce each new value politely (`trigger=\"change\"`): a live total the reader should hear. */\n announce?: boolean;\n /**\n * Where the figure sits in the box its final width reserves.\n * @default \"end\"\n */\n align?: \"start\" | \"end\";\n}\n\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 4);\n\n/** A figure that climbs into view, or rolls digit by digit to each new value. */\nexport function NumberTicker({ value, locale, format, trigger = \"view\", duration = 1100, announce, align = \"end\", className, ...props }: NumberTickerProps) {\n const formatter = React.useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);\n const text = formatter.format(value);\n const live = React.useRef<HTMLSpanElement>(null);\n // The previous value, kept in state (React's pattern for “adjusting state when a prop changes”): the trend of the last change.\n const [previous, setPrevious] = React.useState(value);\n const [trend, setTrend] = React.useState<\"up\" | \"down\" | undefined>(undefined);\n if (value !== previous) {\n setPrevious(value);\n setTrend(value > previous ? \"up\" : \"down\");\n }\n\n React.useEffect(() => {\n const el = live.current;\n if (trigger !== \"view\" || !el || window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n let first = true;\n let frame = 0;\n const io = new IntersectionObserver(\n ([entry]) => {\n const wasFirst = first;\n first = false;\n if (!entry?.isIntersecting) return;\n io.disconnect();\n if (wasFirst) return; // already on screen at load: it stays, nothing drops to 0 in front of the reader\n const start = performance.now();\n const tick = (now: number) => {\n const p = Math.min(1, (now - start) / duration);\n el.textContent = formatter.format(value * easeOut(p));\n if (p < 1) frame = requestAnimationFrame(tick);\n };\n el.textContent = formatter.format(0);\n frame = requestAnimationFrame(tick);\n },\n { threshold: 0.6 },\n );\n io.observe(el);\n return () => {\n io.disconnect();\n cancelAnimationFrame(frame);\n el.textContent = formatter.format(value);\n };\n }, [trigger, value, duration, formatter]);\n\n return (\n <span data-align={align} data-trend={trigger === \"change\" ? trend : undefined} className={cx(\"kz-count\", className)} {...props}>\n <span aria-hidden className=\"kz-count-ghost\">\n {text}\n </span>\n {trigger === \"change\" ? (\n <span aria-hidden className=\"kz-count-roll\">\n {/* Keyed from the right: when a digit is added (999 → 1,000), the units stay the units. */}\n {[...text].map((char, i, all) =>\n /\\d/.test(char) ? (\n <span key={all.length - i} className=\"kz-count-digit\" style={{ \"--kz-digit\": char } as React.CSSProperties}>\n <span>\n {\"0123456789\".split(\"\").map((d) => (\n <span key={d}>{d}</span>\n ))}\n </span>\n </span>\n ) : (\n <span key={all.length - i}>{char}</span>\n ),\n )}\n </span>\n ) : (\n <span aria-hidden ref={live}>\n {text}\n </span>\n )}\n <span className=\"sr-only\" aria-live={announce ? \"polite\" : undefined}>\n {text}\n </span>\n </span>\n );\n}\n"
264
+ "content": "// Client (an observer, a short count). A figure that climbs — without the faults of the usual counters: the server\n// renders the final value (crawlers, screen readers, no-JS and reduced-motion readers all read it), an invisible copy\n// holds its width from the first paint (no layout shift), a figure already on screen at load does not drop to 0 in\n// front of the reader, the count is a bounded loop (~1 s) that stops for good, and assistive technology reads only the\n// final value. On `trigger=\"change\"` each digit rolls to its new value (a live total: tips, bids) and the change can be\n// announced.\nimport * as React from \"react\";\n\nimport { cx } from \"../cx\";\n\nexport interface NumberTickerProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n /** The value to show. */\n value: number;\n /** The locale of the digits and separators (`\"fr\"`: 1 204,5) — the app's locale. */\n locale?: string;\n /** How to write it (`{ style: \"currency\", currency: \"EUR\" }`, `{ maximumFractionDigits: 1 }`…): Intl.NumberFormat options. */\n format?: Intl.NumberFormatOptions;\n /**\n * `view`: climbs from 0 once, the first time it scrolls into view. `change`: each digit rolls to the new value\n * whenever `value` changes (a live total).\n * @default \"view\"\n */\n trigger?: \"view\" | \"change\";\n /**\n * Milliseconds of the climb (`view`) or of each digit's roll (`change`). Unset: 1100 ms to climb, 900 ms to roll.\n */\n duration?: number;\n /**\n * The timing function of the climb and of the roll: `ease-out` (the climb's own curve), `linear`, `ease-in-out`, or any\n * CSS `<easing-function>`. Unset: the platform's curve (`--kz-ease`).\n */\n easing?: \"ease-out\" | \"linear\" | \"ease-in-out\" | (string & {});\n /** Announce each new value politely (`trigger=\"change\"`): a live total the reader should hear. */\n announce?: boolean;\n /**\n * Where the figure sits in the box its final width reserves.\n * @default \"end\"\n */\n align?: \"start\" | \"end\";\n}\n\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 4);\nconst curves: Record<string, (t: number) => number> = { linear: (t) => t, \"ease-in-out\": (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2) };\n\n/** A figure that climbs into view, or rolls digit by digit to each new value. */\nexport function NumberTicker({ value, locale, format, trigger = \"view\", duration, announce, align = \"end\", easing, className, style, ...props }: NumberTickerProps) {\n const climb = duration ?? 1100;\n const curve = (easing && curves[easing]) || easeOut;\n const formatter = React.useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);\n const text = formatter.format(value);\n const live = React.useRef<HTMLSpanElement>(null);\n // The previous value, kept in state (React's pattern for “adjusting state when a prop changes”): the trend of the last change.\n const [previous, setPrevious] = React.useState(value);\n const [trend, setTrend] = React.useState<\"up\" | \"down\" | undefined>(undefined);\n if (value !== previous) {\n setPrevious(value);\n setTrend(value > previous ? \"up\" : \"down\");\n }\n\n React.useEffect(() => {\n const el = live.current;\n if (trigger !== \"view\" || !el || window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n let first = true;\n let frame = 0;\n const io = new IntersectionObserver(\n ([entry]) => {\n const wasFirst = first;\n first = false;\n if (!entry?.isIntersecting) return;\n io.disconnect();\n if (wasFirst) return; // already on screen at load: it stays, nothing drops to 0 in front of the reader\n const start = performance.now();\n const tick = (now: number) => {\n const p = Math.min(1, (now - start) / climb);\n el.textContent = formatter.format(value * curve(p));\n if (p < 1) frame = requestAnimationFrame(tick);\n };\n el.textContent = formatter.format(0);\n frame = requestAnimationFrame(tick);\n },\n { threshold: 0.6 },\n );\n io.observe(el);\n return () => {\n io.disconnect();\n cancelAnimationFrame(frame);\n el.textContent = formatter.format(value);\n };\n }, [trigger, value, climb, curve, formatter]);\n\n return (\n <span\n data-align={align}\n data-trend={trigger === \"change\" ? trend : undefined}\n className={cx(\"kz-count\", className)}\n style={{ ...(duration !== undefined ? { \"--kz-count-duration\": `${duration}ms` } : null), ...(easing ? { \"--kz-count-ease\": easing } : null), ...style } as React.CSSProperties}\n {...props}\n >\n <span aria-hidden className=\"kz-count-ghost\">\n {text}\n </span>\n {trigger === \"change\" ? (\n <span aria-hidden className=\"kz-count-roll\">\n {/* Keyed from the right: when a digit is added (999 → 1,000), the units stay the units. */}\n {[...text].map((char, i, all) =>\n /\\d/.test(char) ? (\n <span key={all.length - i} className=\"kz-count-digit\" style={{ \"--kz-digit\": char } as React.CSSProperties}>\n <span>\n {\"0123456789\".split(\"\").map((d) => (\n <span key={d}>{d}</span>\n ))}\n </span>\n </span>\n ) : (\n <span key={all.length - i}>{char}</span>\n ),\n )}\n </span>\n ) : (\n <span aria-hidden ref={live}>\n {text}\n </span>\n )}\n <span className=\"sr-only\" aria-live={announce ? \"polite\" : undefined}>\n {text}\n </span>\n </span>\n );\n}\n"
258
265
  }
259
266
  ],
260
267
  "dependencies": [],
@@ -292,8 +299,15 @@
292
299
  {
293
300
  "name": "duration",
294
301
  "type": "number",
295
- "default": "1100",
296
- "description": "Milliseconds of the climb.",
302
+ "default": null,
303
+ "description": "Milliseconds of the climb (`view`) or of each digit's roll (`change`). Unset: 1100 ms to climb, 900 ms to roll.",
304
+ "required": false
305
+ },
306
+ {
307
+ "name": "easing",
308
+ "type": "(string & {}) | \"linear\" | \"ease-in-out\" | \"ease-out\"",
309
+ "default": null,
310
+ "description": "The timing function of the climb and of the roll: `ease-out` (the climb's own curve), `linear`, `ease-in-out`, or any\nCSS `<easing-function>`. Unset: the platform's curve (`--kz-ease`).",
297
311
  "required": false
298
312
  },
299
313
  {
@@ -342,7 +342,7 @@
342
342
  }
343
343
  ],
344
344
  "dependencies": [
345
- "@krizaka/tokens@^3.33.1",
345
+ "@krizaka/tokens@^3.34.0",
346
346
  "radix-ui@^1.7.0",
347
347
  "tailwind-variants@^3.3.1"
348
348
  ],
@@ -329,7 +329,7 @@
329
329
  }
330
330
  ],
331
331
  "dependencies": [
332
- "@krizaka/tokens@^3.33.1",
332
+ "@krizaka/tokens@^3.34.0",
333
333
  "radix-ui@^1.7.0",
334
334
  "tailwind-variants@^3.3.1"
335
335
  ],
@@ -148,6 +148,13 @@
148
148
  "description": "The screen's height, in lines; longer sessions scroll.",
149
149
  "required": false
150
150
  },
151
+ {
152
+ "name": "speed",
153
+ "type": "number",
154
+ "default": "1",
155
+ "description": "A multiplier on every delay (the motion vocabulary's `speed`): 2 plays twice as fast, 0.5 half as fast.",
156
+ "required": false
157
+ },
151
158
  {
152
159
  "name": "stepDelay",
153
160
  "type": "number",
@@ -211,7 +218,7 @@
211
218
  },
212
219
  {
213
220
  "path": "terminal-replay/terminal-replay.tsx",
214
- "content": "// Client (a timer, an observer). A terminal session played back: each command is typed at the prompt, its output\n// arrives line by line, the screen follows the last line like a real terminal. What a reader sees without the motion\n// is the session itself: the server renders it whole (no JavaScript, reduced motion and assistive technology all get\n// the full transcript), the screen has a fixed height (no layout shift while it plays), the replay starts when it\n// scrolls into view, waits while it is off screen, and pauses or replays on demand (WCAG 2.2.2). A bounded chain of\n// timeouts, never a permanent animation frame.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\nimport { CheckGlyph, PauseGlyph, PlayGlyph, RetryGlyph } from \"../glyphs\";\n\n/** The colour of an output line — the tokens' text roles, all AA on the screen: secondary, accent, success, warning, danger. */\nexport type TerminalTone = \"muted\" | \"accent\" | \"success\" | \"warning\" | \"danger\";\n\nexport interface TerminalLine {\n /** The line as the program printed it (no colour codes). */\n text: string;\n /** Its colour, when the program coloured it. */\n tone?: TerminalTone;\n}\n\nexport interface TerminalStep {\n /** What is typed at the prompt (`npx orazaka install`). */\n command: string;\n /** What it printed, line by line. */\n output?: readonly (string | TerminalLine)[];\n}\n\nexport type TerminalReplayProps = Omit<React.ComponentProps<\"figure\">, \"children\" | \"title\"> & {\n /** The session, in order. */\n steps: readonly TerminalStep[];\n /** The accessible name of the screen (“Terminal session: install, start, dev”) — passed translated. */\n label: string;\n /** The window's title, shown in its bar (`~/projects — zsh`). */\n title?: React.ReactNode;\n /**\n * What the prompt shows before each command.\n * @default \"$\"\n */\n prompt?: string;\n /** The play button's words (the replay is waiting or paused). */\n playLabel: string;\n /** The pause button's words (the replay is running). */\n pauseLabel: string;\n /** The replay button's words (the session is shown whole). */\n replayLabel: string;\n /** The copy button's words: it copies the commands, one per line. Without it, no copy button. */\n copyLabel?: string;\n /** What the copy button says once the commands are copied (announced politely). */\n copiedLabel?: string;\n /**\n * Milliseconds per typed character.\n * @default 34\n */\n typingSpeed?: number;\n /**\n * Milliseconds between two output lines.\n * @default 70\n */\n lineDelay?: number;\n /**\n * Milliseconds between a command's last line and the next command.\n * @default 900\n */\n stepDelay?: number;\n /**\n * The screen's height, in lines; longer sessions scroll.\n * @default 18\n */\n rows?: number;\n /**\n * Plays the first time it scrolls into view. `false`: the session rests whole until the reader presses play.\n * @default true\n */\n autoPlay?: boolean;\n};\n\ntype Phase = \"final\" | \"idle\" | \"playing\" | \"paused\" | \"done\";\ninterface Position {\n step: number;\n chars: number;\n lines: number;\n}\n\nconst TONE: Record<TerminalTone, string> = {\n muted: \"text-fg-secondary\",\n accent: \"text-fg-accent\",\n success: \"text-fg-success\",\n warning: \"text-fg-warning\",\n danger: \"text-fg-danger\",\n};\n\nconst line = (entry: string | TerminalLine): TerminalLine => (typeof entry === \"string\" ? { text: entry } : entry);\n\n/** The next position of the replay and how long to wait for it; null at the end of the session. */\nfunction advance(steps: readonly TerminalStep[], pos: Position, timing: { typing: number; line: number; step: number }): [Position, number] | null {\n const current = steps[pos.step];\n if (!current) return null;\n const output = current.output ?? [];\n if (pos.chars < current.command.length) {\n // A space is a beat longer: the rhythm of someone typing, not of a printer.\n const pause = current.command[pos.chars] === \" \" ? timing.typing * 2 : timing.typing;\n return [{ ...pos, chars: pos.chars + 1 }, pause];\n }\n if (pos.lines < output.length) return [{ ...pos, lines: pos.lines + 1 }, pos.lines === 0 ? timing.line * 4 : timing.line];\n if (pos.step < steps.length - 1) return [{ step: pos.step + 1, chars: 0, lines: 0 }, timing.step];\n return null;\n}\n\n/** A terminal session played back: commands typed at the prompt, their output line by line — or the whole session at rest. */\nexport function TerminalReplay({\n steps,\n label,\n title,\n prompt = \"$\",\n playLabel,\n pauseLabel,\n replayLabel,\n copyLabel,\n copiedLabel,\n typingSpeed = 34,\n lineDelay = 70,\n stepDelay = 900,\n rows = 18,\n autoPlay = true,\n className,\n style,\n ...props\n}: TerminalReplayProps) {\n const root = React.useRef<HTMLElement>(null);\n const screenRef = React.useRef<HTMLDivElement>(null);\n // The server renders the session whole; the client takes it over unless the reader asked for less motion.\n const [phase, setPhase] = React.useState<Phase>(\"final\");\n const [pos, setPos] = React.useState<Position>({ step: 0, chars: 0, lines: 0 });\n const [visible, setVisible] = React.useState(false);\n const [copied, setCopied] = React.useState(false);\n\n React.useEffect(() => {\n const el = root.current;\n if (!el || !autoPlay || window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n setPhase(\"idle\");\n // Starts the first time it is seen; off screen, it waits where it is.\n const io = new IntersectionObserver(\n ([entry]) => {\n const seen = Boolean(entry?.isIntersecting);\n setVisible(seen);\n if (seen) setPhase((current) => (current === \"idle\" ? \"playing\" : current));\n },\n { threshold: 0.35 },\n );\n io.observe(el);\n return () => io.disconnect();\n }, [autoPlay]);\n\n const ended = phase === \"playing\" && !advance(steps, pos, { typing: typingSpeed, line: lineDelay, step: stepDelay });\n // The end of the session is where the replay stands, not an event: derived, never set from an effect.\n const state: Phase = ended ? \"done\" : phase;\n\n React.useEffect(() => {\n if (phase !== \"playing\" || !visible) return;\n const next = advance(steps, pos, { typing: typingSpeed, line: lineDelay, step: stepDelay });\n if (!next) return;\n const timer = setTimeout(() => setPos(next[0]), next[1]);\n return () => clearTimeout(timer);\n }, [phase, visible, pos, steps, typingSpeed, lineDelay, stepDelay]);\n\n // The screen follows the last line while it plays, like a terminal.\n React.useEffect(() => {\n const el = screenRef.current;\n if (el && state === \"playing\") el.scrollTop = el.scrollHeight;\n }, [pos, state]);\n\n React.useEffect(() => {\n if (!copied) return;\n const timer = setTimeout(() => setCopied(false), 1800);\n return () => clearTimeout(timer);\n }, [copied]);\n\n const whole = state === \"final\" || state === \"done\";\n const shown = whole ? steps.length - 1 : pos.step;\n const toggle = () => {\n if (state === \"playing\") setPhase(\"paused\");\n else if (state === \"paused\" || state === \"idle\") setPhase(\"playing\");\n else {\n // Played on demand — also under reduced motion: the reader asked for it. Off screen it would wait: it is on screen.\n setVisible(true);\n setPos({ step: 0, chars: 0, lines: 0 });\n setPhase(\"playing\");\n }\n };\n const control =\n state === \"playing\"\n ? { text: pauseLabel, Glyph: PauseGlyph }\n : state === \"done\"\n ? { text: replayLabel, Glyph: RetryGlyph }\n : { text: playLabel, Glyph: PlayGlyph };\n const commands = steps.map((s) => s.command).join(\"\\n\");\n const transcript = steps.map((s) => [`${prompt} ${s.command}`, ...(s.output ?? []).map((o) => line(o).text)].join(\"\\n\")).join(\"\\n\\n\");\n\n return (\n <figure\n ref={root}\n data-state={state}\n data-autoplay={autoPlay ? \"\" : undefined}\n className={cn(\"kz-replay overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-md\", className)}\n style={{ \"--kz-replay-rows\": rows, ...style } as React.CSSProperties}\n {...props}\n >\n <figcaption className=\"flex items-center gap-3 border-b border-border-subtle bg-surface-2 px-3 py-2 text-xs text-fg-secondary\">\n <span aria-hidden className=\"flex shrink-0 gap-1.5\">\n <span className=\"h-2.5 w-2.5 rounded-full bg-border-strong\" />\n <span className=\"h-2.5 w-2.5 rounded-full bg-border-strong\" />\n <span className=\"h-2.5 w-2.5 rounded-full bg-border-strong\" />\n </span>\n <span className=\"min-w-0 flex-1 truncate font-mono\">{title}</span>\n <button\n type=\"button\"\n onClick={toggle}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg focus-visible:outline-2 focus-visible:outline-ring\"\n >\n <control.Glyph className=\"h-3.5 w-3.5\" />\n {control.text}\n </button>\n {copyLabel && (\n <button\n type=\"button\"\n onClick={() => navigator.clipboard?.writeText(commands).then(() => setCopied(true))}\n data-copied={copied ? \"\" : undefined}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg focus-visible:outline-2 focus-visible:outline-ring\"\n >\n {copied ? <CheckGlyph className=\"h-3.5 w-3.5\" /> : <CopyGlyph />}\n <span aria-live=\"polite\">{copied ? (copiedLabel ?? copyLabel) : copyLabel}</span>\n </button>\n )}\n </figcaption>\n <div\n ref={screenRef}\n role=\"region\"\n aria-label={label}\n tabIndex={0}\n className=\"kz-replay-screen relative overflow-auto bg-surface-0 px-4 py-3 font-mono text-[0.8125rem] leading-relaxed text-fg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n >\n <div aria-hidden className=\"kz-replay-live\">\n {steps.slice(0, state === \"idle\" ? 0 : shown + 1).map((step, i) => {\n const current = !whole && i === shown;\n const typed = current ? step.command.slice(0, pos.chars) : step.command;\n const output = (step.output ?? []).slice(0, current ? pos.lines : undefined);\n const typing = current && pos.chars < step.command.length;\n return (\n <div key={i} className={i > 0 ? \"mt-3\" : undefined}>\n <div className=\"whitespace-pre\">\n <span className=\"text-fg-accent select-none\">{prompt} </span>\n <span className=\"font-medium\">{typed}</span>\n {typing && <span className=\"kz-replay-caret\" />}\n </div>\n {output.map((entry, j) => {\n const { text, tone } = line(entry);\n return (\n <div key={j} className={cn(\"min-h-[1lh] whitespace-pre\", tone ? TONE[tone] : \"text-fg\")}>\n {text}\n </div>\n );\n })}\n </div>\n );\n })}\n {(state === \"idle\" || whole) && (\n <div className={cn(\"whitespace-pre\", state !== \"idle\" && \"mt-3\")}>\n <span className=\"text-fg-accent select-none\">{prompt} </span>\n <span className=\"kz-replay-caret\" />\n </div>\n )}\n </div>\n <pre className=\"sr-only\">{transcript}</pre>\n </div>\n </figure>\n );\n}\n\n/** Two sheets, one over the other: copy. Drawn here, like the other glyphs of the primitives (decorative). */\nfunction CopyGlyph() {\n return (\n <svg viewBox=\"0 0 24 24\" className=\"h-3.5 w-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden focusable=\"false\">\n <rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\" />\n <path d=\"M5 15V6a2 2 0 0 1 2-2h8\" />\n </svg>\n );\n}\n"
221
+ "content": "// Client (a timer, an observer). A terminal session played back: each command is typed at the prompt, its output\n// arrives line by line, the screen follows the last line like a real terminal. What a reader sees without the motion\n// is the session itself: the server renders it whole (no JavaScript, reduced motion and assistive technology all get\n// the full transcript), the screen has a fixed height (no layout shift while it plays), the replay starts when it\n// scrolls into view, waits while it is off screen, and pauses or replays on demand (WCAG 2.2.2). A bounded chain of\n// timeouts, never a permanent animation frame.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\nimport { CheckGlyph, PauseGlyph, PlayGlyph, RetryGlyph } from \"../glyphs\";\n\n/** The colour of an output line — the tokens' text roles, all AA on the screen: secondary, accent, success, warning, danger. */\nexport type TerminalTone = \"muted\" | \"accent\" | \"success\" | \"warning\" | \"danger\";\n\nexport interface TerminalLine {\n /** The line as the program printed it (no colour codes). */\n text: string;\n /** Its colour, when the program coloured it. */\n tone?: TerminalTone;\n}\n\nexport interface TerminalStep {\n /** What is typed at the prompt (`npx orazaka install`). */\n command: string;\n /** What it printed, line by line. */\n output?: readonly (string | TerminalLine)[];\n}\n\nexport type TerminalReplayProps = Omit<React.ComponentProps<\"figure\">, \"children\" | \"title\"> & {\n /** The session, in order. */\n steps: readonly TerminalStep[];\n /** The accessible name of the screen (“Terminal session: install, start, dev”) — passed translated. */\n label: string;\n /** The window's title, shown in its bar (`~/projects — zsh`). */\n title?: React.ReactNode;\n /**\n * What the prompt shows before each command.\n * @default \"$\"\n */\n prompt?: string;\n /** The play button's words (the replay is waiting or paused). */\n playLabel: string;\n /** The pause button's words (the replay is running). */\n pauseLabel: string;\n /** The replay button's words (the session is shown whole). */\n replayLabel: string;\n /** The copy button's words: it copies the commands, one per line. Without it, no copy button. */\n copyLabel?: string;\n /** What the copy button says once the commands are copied (announced politely). */\n copiedLabel?: string;\n /**\n * Milliseconds per typed character.\n * @default 34\n */\n typingSpeed?: number;\n /**\n * Milliseconds between two output lines.\n * @default 70\n */\n lineDelay?: number;\n /**\n * Milliseconds between a command's last line and the next command.\n * @default 900\n */\n stepDelay?: number;\n /**\n * A multiplier on every delay (the motion vocabulary's `speed`): 2 plays twice as fast, 0.5 half as fast.\n * @default 1\n */\n speed?: number;\n /**\n * The screen's height, in lines; longer sessions scroll.\n * @default 18\n */\n rows?: number;\n /**\n * Plays the first time it scrolls into view. `false`: the session rests whole until the reader presses play.\n * @default true\n */\n autoPlay?: boolean;\n};\n\ntype Phase = \"final\" | \"idle\" | \"playing\" | \"paused\" | \"done\";\ninterface Position {\n step: number;\n chars: number;\n lines: number;\n}\n\nconst TONE: Record<TerminalTone, string> = {\n muted: \"text-fg-secondary\",\n accent: \"text-fg-accent\",\n success: \"text-fg-success\",\n warning: \"text-fg-warning\",\n danger: \"text-fg-danger\",\n};\n\nconst line = (entry: string | TerminalLine): TerminalLine => (typeof entry === \"string\" ? { text: entry } : entry);\n\n/** The next position of the replay and how long to wait for it; null at the end of the session. */\nfunction advance(steps: readonly TerminalStep[], pos: Position, timing: { typing: number; line: number; step: number }): [Position, number] | null {\n const current = steps[pos.step];\n if (!current) return null;\n const output = current.output ?? [];\n if (pos.chars < current.command.length) {\n // A space is a beat longer: the rhythm of someone typing, not of a printer.\n const pause = current.command[pos.chars] === \" \" ? timing.typing * 2 : timing.typing;\n return [{ ...pos, chars: pos.chars + 1 }, pause];\n }\n if (pos.lines < output.length) return [{ ...pos, lines: pos.lines + 1 }, pos.lines === 0 ? timing.line * 4 : timing.line];\n if (pos.step < steps.length - 1) return [{ step: pos.step + 1, chars: 0, lines: 0 }, timing.step];\n return null;\n}\n\n/** A terminal session played back: commands typed at the prompt, their output line by line — or the whole session at rest. */\nexport function TerminalReplay({\n steps,\n label,\n title,\n prompt = \"$\",\n playLabel,\n pauseLabel,\n replayLabel,\n copyLabel,\n copiedLabel,\n typingSpeed = 34,\n lineDelay = 70,\n stepDelay = 900,\n speed = 1,\n rows = 18,\n autoPlay = true,\n className,\n style,\n ...props\n}: TerminalReplayProps) {\n const root = React.useRef<HTMLElement>(null);\n const screenRef = React.useRef<HTMLDivElement>(null);\n // The server renders the session whole; the client takes it over unless the reader asked for less motion.\n const [phase, setPhase] = React.useState<Phase>(\"final\");\n const [pos, setPos] = React.useState<Position>({ step: 0, chars: 0, lines: 0 });\n const [visible, setVisible] = React.useState(false);\n const [copied, setCopied] = React.useState(false);\n\n React.useEffect(() => {\n const el = root.current;\n if (!el || !autoPlay || window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n setPhase(\"idle\");\n // Starts the first time it is seen; off screen, it waits where it is.\n const io = new IntersectionObserver(\n ([entry]) => {\n const seen = Boolean(entry?.isIntersecting);\n setVisible(seen);\n if (seen) setPhase((current) => (current === \"idle\" ? \"playing\" : current));\n },\n { threshold: 0.35 },\n );\n io.observe(el);\n return () => io.disconnect();\n }, [autoPlay]);\n\n const rate = Math.max(0.1, speed);\n const timing = React.useMemo(() => ({ typing: typingSpeed / rate, line: lineDelay / rate, step: stepDelay / rate }), [typingSpeed, lineDelay, stepDelay, rate]);\n const ended = phase === \"playing\" && !advance(steps, pos, timing);\n // The end of the session is where the replay stands, not an event: derived, never set from an effect.\n const state: Phase = ended ? \"done\" : phase;\n\n React.useEffect(() => {\n if (phase !== \"playing\" || !visible) return;\n const next = advance(steps, pos, timing);\n if (!next) return;\n const timer = setTimeout(() => setPos(next[0]), next[1]);\n return () => clearTimeout(timer);\n }, [phase, visible, pos, steps, timing]);\n\n // The screen follows the last line while it plays, like a terminal.\n React.useEffect(() => {\n const el = screenRef.current;\n if (el && state === \"playing\") el.scrollTop = el.scrollHeight;\n }, [pos, state]);\n\n React.useEffect(() => {\n if (!copied) return;\n const timer = setTimeout(() => setCopied(false), 1800);\n return () => clearTimeout(timer);\n }, [copied]);\n\n const whole = state === \"final\" || state === \"done\";\n const shown = whole ? steps.length - 1 : pos.step;\n const toggle = () => {\n if (state === \"playing\") setPhase(\"paused\");\n else if (state === \"paused\" || state === \"idle\") setPhase(\"playing\");\n else {\n // Played on demand — also under reduced motion: the reader asked for it. Off screen it would wait: it is on screen.\n setVisible(true);\n setPos({ step: 0, chars: 0, lines: 0 });\n setPhase(\"playing\");\n }\n };\n const control =\n state === \"playing\"\n ? { text: pauseLabel, Glyph: PauseGlyph }\n : state === \"done\"\n ? { text: replayLabel, Glyph: RetryGlyph }\n : { text: playLabel, Glyph: PlayGlyph };\n const commands = steps.map((s) => s.command).join(\"\\n\");\n const transcript = steps.map((s) => [`${prompt} ${s.command}`, ...(s.output ?? []).map((o) => line(o).text)].join(\"\\n\")).join(\"\\n\\n\");\n\n return (\n <figure\n ref={root}\n data-state={state}\n data-autoplay={autoPlay ? \"\" : undefined}\n className={cn(\"kz-replay overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-md\", className)}\n style={{ \"--kz-replay-rows\": rows, ...style } as React.CSSProperties}\n {...props}\n >\n <figcaption className=\"flex items-center gap-3 border-b border-border-subtle bg-surface-2 px-3 py-2 text-xs text-fg-secondary\">\n <span aria-hidden className=\"flex shrink-0 gap-1.5\">\n <span className=\"h-2.5 w-2.5 rounded-full bg-border-strong\" />\n <span className=\"h-2.5 w-2.5 rounded-full bg-border-strong\" />\n <span className=\"h-2.5 w-2.5 rounded-full bg-border-strong\" />\n </span>\n <span className=\"min-w-0 flex-1 truncate font-mono\">{title}</span>\n <button\n type=\"button\"\n onClick={toggle}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg focus-visible:outline-2 focus-visible:outline-ring\"\n >\n <control.Glyph className=\"h-3.5 w-3.5\" />\n {control.text}\n </button>\n {copyLabel && (\n <button\n type=\"button\"\n onClick={() => navigator.clipboard?.writeText(commands).then(() => setCopied(true))}\n data-copied={copied ? \"\" : undefined}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg focus-visible:outline-2 focus-visible:outline-ring\"\n >\n {copied ? <CheckGlyph className=\"h-3.5 w-3.5\" /> : <CopyGlyph />}\n <span aria-live=\"polite\">{copied ? (copiedLabel ?? copyLabel) : copyLabel}</span>\n </button>\n )}\n </figcaption>\n <div\n ref={screenRef}\n role=\"region\"\n aria-label={label}\n tabIndex={0}\n className=\"kz-replay-screen relative overflow-auto bg-surface-0 px-4 py-3 font-mono text-[0.8125rem] leading-relaxed text-fg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n >\n <div aria-hidden className=\"kz-replay-live\">\n {steps.slice(0, state === \"idle\" ? 0 : shown + 1).map((step, i) => {\n const current = !whole && i === shown;\n const typed = current ? step.command.slice(0, pos.chars) : step.command;\n const output = (step.output ?? []).slice(0, current ? pos.lines : undefined);\n const typing = current && pos.chars < step.command.length;\n return (\n <div key={i} className={i > 0 ? \"mt-3\" : undefined}>\n <div className=\"whitespace-pre\">\n <span className=\"text-fg-accent select-none\">{prompt} </span>\n <span className=\"font-medium\">{typed}</span>\n {typing && <span className=\"kz-replay-caret\" />}\n </div>\n {output.map((entry, j) => {\n const { text, tone } = line(entry);\n return (\n <div key={j} className={cn(\"min-h-[1lh] whitespace-pre\", tone ? TONE[tone] : \"text-fg\")}>\n {text}\n </div>\n );\n })}\n </div>\n );\n })}\n {(state === \"idle\" || whole) && (\n <div className={cn(\"whitespace-pre\", state !== \"idle\" && \"mt-3\")}>\n <span className=\"text-fg-accent select-none\">{prompt} </span>\n <span className=\"kz-replay-caret\" />\n </div>\n )}\n </div>\n <pre className=\"sr-only\">{transcript}</pre>\n </div>\n </figure>\n );\n}\n\n/** Two sheets, one over the other: copy. Drawn here, like the other glyphs of the primitives (decorative). */\nfunction CopyGlyph() {\n return (\n <svg viewBox=\"0 0 24 24\" className=\"h-3.5 w-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden focusable=\"false\">\n <rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\" />\n <path d=\"M5 15V6a2 2 0 0 1 2-2h8\" />\n </svg>\n );\n}\n"
215
222
  }
216
223
  ],
217
224
  "dependencies": [],
@@ -304,6 +311,13 @@
304
311
  "description": "The screen's height, in lines; longer sessions scroll.",
305
312
  "required": false
306
313
  },
314
+ {
315
+ "name": "speed",
316
+ "type": "number",
317
+ "default": "1",
318
+ "description": "A multiplier on every delay (the motion vocabulary's `speed`): 2 plays twice as fast, 0.5 half as fast.",
319
+ "required": false
320
+ },
307
321
  {
308
322
  "name": "stepDelay",
309
323
  "type": "number",