@krizaka/ui 3.38.0 → 3.39.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.
Files changed (57) hide show
  1. package/dist/blocks.css +6 -6
  2. package/dist/chip-link.js +1 -1
  3. package/dist/chip-row.js +1 -1
  4. package/dist/{chunk-IANSFIVC.js → chunk-7JFHTPI3.js} +13 -3
  5. package/dist/chunk-7JFHTPI3.js.map +1 -0
  6. package/dist/{chunk-RE5WJ4QP.js → chunk-ADRUEPYC.js} +4 -4
  7. package/dist/chunk-ADRUEPYC.js.map +1 -0
  8. package/dist/{chunk-BTTDIVVJ.js → chunk-F2UZ2WWL.js} +2 -2
  9. package/dist/chunk-F2UZ2WWL.js.map +1 -0
  10. package/dist/image-cropper.js +1 -1
  11. package/dist/kanban.js +1 -1
  12. package/dist/kanban.js.map +1 -1
  13. package/dist/map.js +2 -2
  14. package/dist/map.js.map +1 -1
  15. package/dist/motion.css +1 -1
  16. package/dist/native/vertical-video.js +1 -1
  17. package/dist/native/vertical-video.js.map +1 -1
  18. package/dist/native.d.ts +1 -1
  19. package/dist/native.js +11 -11
  20. package/dist/native.js.map +1 -1
  21. package/dist/nav-bar.d.ts +1 -1
  22. package/dist/nav-bar.js +1 -1
  23. package/dist/photo-grid.js +1 -1
  24. package/dist/photo-grid.js.map +1 -1
  25. package/dist/site-footer.d.ts +5 -17
  26. package/dist/site-footer.js +33 -29
  27. package/dist/site-footer.js.map +1 -1
  28. package/dist/{sites-B4sYlfPQ.d.ts → sites-inztN_Bv.d.ts} +8 -0
  29. package/dist/sites.d.ts +11 -1
  30. package/dist/sites.js +12 -2
  31. package/dist/sites.js.map +1 -1
  32. package/dist/slider.js +3 -3
  33. package/dist/slider.js.map +1 -1
  34. package/dist/step-showcase.js +1 -1
  35. package/dist/step-showcase.js.map +1 -1
  36. package/dist/story.css +3 -3
  37. package/dist/video-player.js +1 -1
  38. package/dist/video-trimmer.js +1 -1
  39. package/dist/video-trimmer.js.map +1 -1
  40. package/package.json +4 -4
  41. package/registry/chip-link.json +1 -1
  42. package/registry/examples/site-footer/company.js +0 -4
  43. package/registry/examples/site-footer/company.tsx +3 -7
  44. package/registry/index.json +7 -7
  45. package/registry/kanban.json +1 -1
  46. package/registry/map.json +2 -2
  47. package/registry/nav-bar.json +1 -1
  48. package/registry/photo-grid.json +1 -1
  49. package/registry/rail-nav.json +1 -1
  50. package/registry/site-footer.json +10 -10
  51. package/registry/sites.json +2 -2
  52. package/registry/slider.json +1 -1
  53. package/registry/step-showcase.json +1 -1
  54. package/registry/video-trimmer.json +1 -1
  55. package/dist/chunk-BTTDIVVJ.js.map +0 -1
  56. package/dist/chunk-IANSFIVC.js.map +0 -1
  57. package/dist/chunk-RE5WJ4QP.js.map +0 -1
@@ -162,7 +162,7 @@ function StepShowcase({ label, steps, device, deviceTitle, autoplay = true, labe
162
162
  " ",
163
163
  /* @__PURE__ */ jsx2("span", { className: "text-fg-secondary", children: current?.description })
164
164
  ] }),
165
- auto && /* @__PURE__ */ jsxs2("button", { type: "button", onClick: () => setPaused((p) => !p), "aria-pressed": paused, className: "mx-auto inline-flex h-8 items-center gap-2 rounded-full border border-border-subtle px-3 text-xs font-medium text-fg-secondary hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring md:mx-0 md:w-fit", children: [
165
+ auto && /* @__PURE__ */ jsxs2("button", { type: "button", onClick: () => setPaused((p) => !p), "aria-pressed": paused, className: "mx-auto inline-flex h-8 items-center gap-2 rounded-sm border border-border-subtle px-3 text-xs font-medium text-fg-secondary hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring md:mx-0 md:w-fit", children: [
166
166
  /* @__PURE__ */ jsx2("svg", { viewBox: "0 0 24 24", className: "h-3.5 w-3.5", fill: "currentColor", "aria-hidden": true, children: paused ? /* @__PURE__ */ jsx2("path", { d: "M8 5v14l11-7z" }) : /* @__PURE__ */ jsx2("path", { d: "M7 5h4v14H7zM13 5h4v14h-4z" }) }),
167
167
  paused ? labels.play : labels.pause
168
168
  ] })
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/step-showcase/device-frame.tsx","../src/step-showcase/step-showcase.tsx"],"sourcesContent":["// Server-safe. The frame a product's recording plays in: a phone, a browser window or a macOS window — drawn with the\n// tokens (no image), so it follows dark and light. The content fills the screen area (`object-cover` media).\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\n\nexport type DeviceKind = \"phone\" | \"browser\" | \"desktop\";\n\nexport interface DeviceFrameProps extends React.ComponentProps<\"div\"> {\n /** The device: a phone, a browser window or a macOS window. */\n kind: DeviceKind;\n /** The browser's address or the window's title (“dev.orazaka.com”, “Orazaka”). */\n title?: string;\n}\n\nconst screen: Record<DeviceKind, string> = {\n phone: \"aspect-[9/19.5]\",\n browser: \"aspect-[16/10]\",\n desktop: \"aspect-[16/10]\",\n};\n\n/** A phone, a browser window or a macOS window around a screen of content. */\nexport function DeviceFrame({ kind, title, className, children, ...props }: DeviceFrameProps) {\n if (kind === \"phone\") {\n return (\n <div data-device=\"phone\" className={cn(\"relative mx-auto w-full max-w-[18rem] rounded-[2.4rem] border border-border-strong bg-surface-3 p-2 shadow-2xl\", className)} {...props}>\n <div className={cn(\"relative overflow-hidden rounded-[1.9rem] bg-surface-0\", screen.phone)}>\n {children}\n <span aria-hidden className=\"absolute left-1/2 top-2 h-5 w-20 -translate-x-1/2 rounded-full bg-surface-3\" />\n </div>\n </div>\n );\n }\n return (\n <div data-device={kind} className={cn(\"relative w-full overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-2xl\", className)} {...props}>\n <div className=\"flex h-8 items-center gap-1.5 border-b border-border-subtle bg-surface-2 px-3\">\n <span aria-hidden className=\"h-2.5 w-2.5 rounded-full bg-danger/80\" />\n <span aria-hidden className=\"h-2.5 w-2.5 rounded-full bg-warning/80\" />\n <span aria-hidden className=\"h-2.5 w-2.5 rounded-full bg-success/80\" />\n {title &&\n (kind === \"browser\" ? (\n <span className=\"mx-auto max-w-[60%] truncate rounded-md bg-surface-1 px-3 py-0.5 font-mono text-[11px] text-fg-secondary\">{title}</span>\n ) : (\n <span className=\"mx-auto truncate text-xs font-semibold text-fg-secondary\">{title}</span>\n ))}\n </div>\n <div className={cn(\"relative overflow-hidden bg-surface-0\", screen[kind])}>{children}</div>\n </div>\n );\n}\n","// Client (timers through the video, observers, swipe). A guided sequence in one device frame: the steps listed beside\n// it (a vertical tab list: number · title · one line, a bar filling under the current one), the frame playing the\n// step's recording muted, then moving on to the next; the recordings crossfade. It plays only while in view, holds on\n// hover and focus, keeps a visible play / pause button, and never shows a video's native controls. Recordings load\n// lazily (posters first, the current step's video once the block has been seen). Reduced motion: posters, manual steps.\n// Below `md` the list becomes dots under the frame, the frame swipes, the current step's words show under it.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\nimport { DeviceFrame, type DeviceKind } from \"./device-frame\";\n\nconst FILL_CSS = \".kz-step-fill{transform-origin:left;animation:kz-step-fill var(--kz-step-ms,6000ms) linear forwards}@keyframes kz-step-fill{from{transform:scaleX(0)}to{transform:scaleX(1)}}\";\nconst REDUCED = \"(prefers-reduced-motion: reduce)\";\nconst noop = () => () => undefined;\nconst readReduced = () => Boolean(typeof window !== \"undefined\" && window.matchMedia?.(REDUCED).matches);\nconst subscribeReduced = (notify: () => void) => {\n const q = window.matchMedia?.(REDUCED);\n q?.addEventListener?.(\"change\", notify);\n return () => q?.removeEventListener?.(\"change\", notify);\n};\n\nexport interface ShowcaseStep {\n /** A stable key. */\n id: string;\n /** The step's name (“Make an image”), passed translated. */\n title: string;\n /** One line under it. */\n description: string;\n /** The still shown first, and the only picture under reduced motion. */\n poster: string;\n /** What the poster shows, for assistive technology. */\n alt: string;\n /** The recording: WebM and / or MP4. Without one, the poster stays `duration` ms. */\n video?: { webm?: string; mp4?: string };\n /** Milliseconds a step without a video stays. @default 6000 */\n duration?: number;\n}\n\nexport interface StepShowcaseLabels {\n /** The button's name while paused. */\n play: string;\n /** The button's name while it plays. */\n pause: string;\n /** A step's position in words: `(2, 4) => \"Step 2 of 4\"`. */\n step: (n: number, total: number) => string;\n}\n\nexport const stepShowcaseLabels: StepShowcaseLabels = {\n play: \"Play the steps\",\n pause: \"Pause the steps\",\n step: (n, total) => `Step ${n} of ${total}`,\n};\n\nexport interface StepShowcaseProps extends Omit<React.ComponentProps<\"section\">, \"children\"> {\n /** The sequence's accessible name (“Orazaka in your pocket”), passed translated. */\n label: string;\n /** The steps, in the order of the journey. */\n steps: readonly ShowcaseStep[];\n /** The device the recordings were made on. */\n device: DeviceKind;\n /** The browser's address or the window's title. */\n deviceTitle?: string;\n /** Moves on by itself (never under reduced motion). @default true */\n autoplay?: boolean;\n /** The controls' words, translated. */\n labels?: StepShowcaseLabels;\n /** The step shown first. @default 0 */\n defaultIndex?: number;\n /** Called when the step changes — `user` is true when the visitor asked for it. */\n onIndexChange?: (index: number, user: boolean) => void;\n}\n\n/** One device frame, its steps beside it, playing each recording then the next — a product's guided tour. */\nexport function StepShowcase({ label, steps, device, deviceTitle, autoplay = true, labels = stepShowcaseLabels, defaultIndex = 0, onIndexChange, className, ...props }: StepShowcaseProps) {\n const total = steps.length;\n const id = React.useId();\n const root = React.useRef<HTMLElement>(null);\n const video = React.useRef<HTMLVideoElement>(null);\n const bar = React.useRef<HTMLSpanElement>(null);\n const swipe = React.useRef<number | null>(null);\n const [index, setIndex] = React.useState(defaultIndex);\n const [paused, setPaused] = React.useState(false);\n const [hold, setHold] = React.useState({ hover: false, focus: false });\n const [inView, setInView] = React.useState(false);\n const [seen, setSeen] = React.useState(false);\n const reduced = React.useSyncExternalStore(subscribeReduced, readReduced, () => false);\n const mounted = React.useSyncExternalStore(noop, () => true, () => false);\n const current = steps[Math.min(index, total - 1)];\n const auto = autoplay && !reduced && mounted;\n const running = auto && !paused && !hold.hover && !hold.focus && inView;\n\n React.useEffect(() => {\n const host = root.current;\n if (!host || typeof IntersectionObserver === \"undefined\") return;\n const io = new IntersectionObserver((e) => {\n const visible = e.some((x) => x.isIntersecting);\n setInView(visible);\n if (visible) setSeen(true);\n }, { threshold: 0.35 });\n io.observe(host);\n return () => io.disconnect();\n }, []);\n\n const go = React.useCallback(\n (to: number, user: boolean) => {\n const next = (to + total) % total;\n setIndex(next);\n if (bar.current) bar.current.style.transform = \"scaleX(0)\";\n onIndexChange?.(next, user);\n },\n [total, onIndexChange],\n );\n\n // The recording runs only while the sequence does; its time is the bar.\n React.useEffect(() => {\n const v = video.current;\n if (!v) return;\n if (running) v.play()?.catch?.(() => undefined);\n else v.pause();\n }, [running, index, seen]);\n\n const playable = Boolean(current?.video) && seen && !reduced;\n const onTime = (e: React.SyntheticEvent<HTMLVideoElement>) => {\n const v = e.currentTarget;\n if (bar.current && v.duration) bar.current.style.transform = `scaleX(${v.currentTime / v.duration})`;\n };\n\n return (\n <section\n ref={root}\n aria-roledescription=\"carousel\"\n aria-label={label}\n className={cn(\"grid items-center gap-6 md:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] md:gap-10\", className)}\n onPointerEnter={() => setHold((h) => ({ ...h, hover: true }))}\n onPointerLeave={() => setHold((h) => ({ ...h, hover: false }))}\n onFocus={() => setHold((h) => ({ ...h, focus: true }))}\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHold((h) => ({ ...h, focus: false }));\n }}\n {...props}\n >\n <style href=\"kz-step-showcase\" precedence=\"medium\">{FILL_CSS}</style>\n <div className=\"order-2 grid gap-3 md:order-1\">\n <div role=\"tablist\" aria-orientation=\"vertical\" aria-label={label} className=\"flex justify-center gap-2 md:grid md:gap-1\">\n {steps.map((s, i) => {\n const active = i === index;\n return (\n <button\n key={s.id}\n type=\"button\"\n role=\"tab\"\n id={`${id}-tab-${i}`}\n aria-selected={active}\n aria-controls={`${id}-panel`}\n tabIndex={active ? 0 : -1}\n onClick={() => go(i, true)}\n onKeyDown={(e) => {\n if (e.key === \"ArrowDown\" || e.key === \"ArrowRight\") go(i + 1, true);\n else if (e.key === \"ArrowUp\" || e.key === \"ArrowLeft\") go(i - 1, true);\n else return;\n e.preventDefault();\n }}\n className={cn(\n \"group relative rounded-xl text-left outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n \"max-md:h-2.5 max-md:w-2.5 max-md:rounded-full max-md:bg-border-strong max-md:aria-selected:w-6 max-md:aria-selected:bg-accent\",\n \"md:overflow-hidden md:p-4 md:hover:bg-surface-2 md:aria-selected:bg-surface-2\",\n )}\n >\n <span className=\"max-md:sr-only\">\n <span className=\"flex items-baseline gap-3\">\n <span className={cn(\"font-mono text-xs\", active ? \"text-fg-accent\" : \"text-fg-secondary\")}>{String(i + 1).padStart(2, \"0\")}</span>\n <span className={cn(\"text-sm font-semibold\", active ? \"text-fg\" : \"text-fg-secondary\")}>{s.title}</span>\n </span>\n <span className=\"mt-1 block pl-8 text-xs leading-relaxed text-fg-secondary\">{s.description}</span>\n </span>\n {active && auto && (\n <span aria-hidden className=\"absolute inset-x-4 bottom-0 hidden h-0.5 overflow-hidden rounded-full bg-border-subtle md:block\">\n <span\n key={`${s.id}-${playable}`}\n ref={bar}\n data-fill\n className={cn(\"block h-full bg-accent\", !playable && \"kz-step-fill\")}\n style={{ transform: playable ? \"scaleX(0)\" : undefined, animationPlayState: running ? \"running\" : \"paused\", [\"--kz-step-ms\" as string]: `${s.duration ?? 6000}ms` }}\n onAnimationEnd={() => go(index + 1, false)}\n />\n </span>\n )}\n </button>\n );\n })}\n </div>\n <p aria-hidden className=\"text-center text-sm md:hidden\">\n <span className=\"font-semibold text-fg\">{current?.title}</span> <span className=\"text-fg-secondary\">{current?.description}</span>\n </p>\n {auto && (\n <button type=\"button\" onClick={() => setPaused((p) => !p)} aria-pressed={paused} className=\"mx-auto inline-flex h-8 items-center gap-2 rounded-full border border-border-subtle px-3 text-xs font-medium text-fg-secondary hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring md:mx-0 md:w-fit\">\n <svg viewBox=\"0 0 24 24\" className=\"h-3.5 w-3.5\" fill=\"currentColor\" aria-hidden>\n {paused ? <path d=\"M8 5v14l11-7z\" /> : <path d=\"M7 5h4v14H7zM13 5h4v14h-4z\" />}\n </svg>\n {paused ? labels.play : labels.pause}\n </button>\n )}\n </div>\n\n <div\n id={`${id}-panel`}\n role=\"tabpanel\"\n aria-roledescription=\"slide\"\n aria-labelledby={`${id}-tab-${index}`}\n className=\"order-1 touch-pan-y md:order-2\"\n onPointerDown={(e) => {\n swipe.current = e.clientX;\n }}\n onPointerUp={(e) => {\n if (swipe.current === null) return;\n const dx = e.clientX - swipe.current;\n swipe.current = null;\n if (Math.abs(dx) > 40) go(index + (dx < 0 ? 1 : -1), true);\n }}\n >\n <DeviceFrame kind={device} title={deviceTitle}>\n {steps.map((s, i) => {\n const active = i === index;\n return (\n <div key={s.id} data-step={s.id} aria-hidden={!active} className={cn(\"absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none\", active ? \"opacity-100\" : \"opacity-0\")}>\n <img src={s.poster} alt={active ? s.alt : \"\"} loading={i === 0 ? \"eager\" : \"lazy\"} decoding=\"async\" className=\"h-full w-full object-cover\" />\n {active && playable && s.video && (\n <video\n ref={video}\n key={s.id}\n muted\n playsInline\n preload=\"metadata\"\n poster={s.poster}\n aria-hidden\n tabIndex={-1}\n onTimeUpdate={onTime}\n onEnded={() => go(index + 1, false)}\n className=\"absolute inset-0 h-full w-full object-cover\"\n >\n {s.video.webm && <source src={s.video.webm} type=\"video/webm\" />}\n {s.video.mp4 && <source src={s.video.mp4} type=\"video/mp4\" />}\n </video>\n )}\n </div>\n );\n })}\n </DeviceFrame>\n <span className=\"sr-only\" aria-live=\"off\">\n {labels.step(index + 1, total)}\n </span>\n </div>\n </section>\n );\n}\n"],"mappings":"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","names":["jsx","jsxs"]}
1
+ {"version":3,"sources":["../src/step-showcase/device-frame.tsx","../src/step-showcase/step-showcase.tsx"],"sourcesContent":["// Server-safe. The frame a product's recording plays in: a phone, a browser window or a macOS window — drawn with the\n// tokens (no image), so it follows dark and light. The content fills the screen area (`object-cover` media).\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\n\nexport type DeviceKind = \"phone\" | \"browser\" | \"desktop\";\n\nexport interface DeviceFrameProps extends React.ComponentProps<\"div\"> {\n /** The device: a phone, a browser window or a macOS window. */\n kind: DeviceKind;\n /** The browser's address or the window's title (“dev.orazaka.com”, “Orazaka”). */\n title?: string;\n}\n\nconst screen: Record<DeviceKind, string> = {\n phone: \"aspect-[9/19.5]\",\n browser: \"aspect-[16/10]\",\n desktop: \"aspect-[16/10]\",\n};\n\n/** A phone, a browser window or a macOS window around a screen of content. */\nexport function DeviceFrame({ kind, title, className, children, ...props }: DeviceFrameProps) {\n if (kind === \"phone\") {\n return (\n <div data-device=\"phone\" className={cn(\"relative mx-auto w-full max-w-[18rem] rounded-[2.4rem] border border-border-strong bg-surface-3 p-2 shadow-2xl\", className)} {...props}>\n <div className={cn(\"relative overflow-hidden rounded-[1.9rem] bg-surface-0\", screen.phone)}>\n {children}\n <span aria-hidden className=\"absolute left-1/2 top-2 h-5 w-20 -translate-x-1/2 rounded-full bg-surface-3\" />\n </div>\n </div>\n );\n }\n return (\n <div data-device={kind} className={cn(\"relative w-full overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-2xl\", className)} {...props}>\n <div className=\"flex h-8 items-center gap-1.5 border-b border-border-subtle bg-surface-2 px-3\">\n <span aria-hidden className=\"h-2.5 w-2.5 rounded-full bg-danger/80\" />\n <span aria-hidden className=\"h-2.5 w-2.5 rounded-full bg-warning/80\" />\n <span aria-hidden className=\"h-2.5 w-2.5 rounded-full bg-success/80\" />\n {title &&\n (kind === \"browser\" ? (\n <span className=\"mx-auto max-w-[60%] truncate rounded-md bg-surface-1 px-3 py-0.5 font-mono text-[11px] text-fg-secondary\">{title}</span>\n ) : (\n <span className=\"mx-auto truncate text-xs font-semibold text-fg-secondary\">{title}</span>\n ))}\n </div>\n <div className={cn(\"relative overflow-hidden bg-surface-0\", screen[kind])}>{children}</div>\n </div>\n );\n}\n","// Client (timers through the video, observers, swipe). A guided sequence in one device frame: the steps listed beside\n// it (a vertical tab list: number · title · one line, a bar filling under the current one), the frame playing the\n// step's recording muted, then moving on to the next; the recordings crossfade. It plays only while in view, holds on\n// hover and focus, keeps a visible play / pause button, and never shows a video's native controls. Recordings load\n// lazily (posters first, the current step's video once the block has been seen). Reduced motion: posters, manual steps.\n// Below `md` the list becomes dots under the frame, the frame swipes, the current step's words show under it.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\nimport { DeviceFrame, type DeviceKind } from \"./device-frame\";\n\nconst FILL_CSS = \".kz-step-fill{transform-origin:left;animation:kz-step-fill var(--kz-step-ms,6000ms) linear forwards}@keyframes kz-step-fill{from{transform:scaleX(0)}to{transform:scaleX(1)}}\";\nconst REDUCED = \"(prefers-reduced-motion: reduce)\";\nconst noop = () => () => undefined;\nconst readReduced = () => Boolean(typeof window !== \"undefined\" && window.matchMedia?.(REDUCED).matches);\nconst subscribeReduced = (notify: () => void) => {\n const q = window.matchMedia?.(REDUCED);\n q?.addEventListener?.(\"change\", notify);\n return () => q?.removeEventListener?.(\"change\", notify);\n};\n\nexport interface ShowcaseStep {\n /** A stable key. */\n id: string;\n /** The step's name (“Make an image”), passed translated. */\n title: string;\n /** One line under it. */\n description: string;\n /** The still shown first, and the only picture under reduced motion. */\n poster: string;\n /** What the poster shows, for assistive technology. */\n alt: string;\n /** The recording: WebM and / or MP4. Without one, the poster stays `duration` ms. */\n video?: { webm?: string; mp4?: string };\n /** Milliseconds a step without a video stays. @default 6000 */\n duration?: number;\n}\n\nexport interface StepShowcaseLabels {\n /** The button's name while paused. */\n play: string;\n /** The button's name while it plays. */\n pause: string;\n /** A step's position in words: `(2, 4) => \"Step 2 of 4\"`. */\n step: (n: number, total: number) => string;\n}\n\nexport const stepShowcaseLabels: StepShowcaseLabels = {\n play: \"Play the steps\",\n pause: \"Pause the steps\",\n step: (n, total) => `Step ${n} of ${total}`,\n};\n\nexport interface StepShowcaseProps extends Omit<React.ComponentProps<\"section\">, \"children\"> {\n /** The sequence's accessible name (“Orazaka in your pocket”), passed translated. */\n label: string;\n /** The steps, in the order of the journey. */\n steps: readonly ShowcaseStep[];\n /** The device the recordings were made on. */\n device: DeviceKind;\n /** The browser's address or the window's title. */\n deviceTitle?: string;\n /** Moves on by itself (never under reduced motion). @default true */\n autoplay?: boolean;\n /** The controls' words, translated. */\n labels?: StepShowcaseLabels;\n /** The step shown first. @default 0 */\n defaultIndex?: number;\n /** Called when the step changes — `user` is true when the visitor asked for it. */\n onIndexChange?: (index: number, user: boolean) => void;\n}\n\n/** One device frame, its steps beside it, playing each recording then the next — a product's guided tour. */\nexport function StepShowcase({ label, steps, device, deviceTitle, autoplay = true, labels = stepShowcaseLabels, defaultIndex = 0, onIndexChange, className, ...props }: StepShowcaseProps) {\n const total = steps.length;\n const id = React.useId();\n const root = React.useRef<HTMLElement>(null);\n const video = React.useRef<HTMLVideoElement>(null);\n const bar = React.useRef<HTMLSpanElement>(null);\n const swipe = React.useRef<number | null>(null);\n const [index, setIndex] = React.useState(defaultIndex);\n const [paused, setPaused] = React.useState(false);\n const [hold, setHold] = React.useState({ hover: false, focus: false });\n const [inView, setInView] = React.useState(false);\n const [seen, setSeen] = React.useState(false);\n const reduced = React.useSyncExternalStore(subscribeReduced, readReduced, () => false);\n const mounted = React.useSyncExternalStore(noop, () => true, () => false);\n const current = steps[Math.min(index, total - 1)];\n const auto = autoplay && !reduced && mounted;\n const running = auto && !paused && !hold.hover && !hold.focus && inView;\n\n React.useEffect(() => {\n const host = root.current;\n if (!host || typeof IntersectionObserver === \"undefined\") return;\n const io = new IntersectionObserver((e) => {\n const visible = e.some((x) => x.isIntersecting);\n setInView(visible);\n if (visible) setSeen(true);\n }, { threshold: 0.35 });\n io.observe(host);\n return () => io.disconnect();\n }, []);\n\n const go = React.useCallback(\n (to: number, user: boolean) => {\n const next = (to + total) % total;\n setIndex(next);\n if (bar.current) bar.current.style.transform = \"scaleX(0)\";\n onIndexChange?.(next, user);\n },\n [total, onIndexChange],\n );\n\n // The recording runs only while the sequence does; its time is the bar.\n React.useEffect(() => {\n const v = video.current;\n if (!v) return;\n if (running) v.play()?.catch?.(() => undefined);\n else v.pause();\n }, [running, index, seen]);\n\n const playable = Boolean(current?.video) && seen && !reduced;\n const onTime = (e: React.SyntheticEvent<HTMLVideoElement>) => {\n const v = e.currentTarget;\n if (bar.current && v.duration) bar.current.style.transform = `scaleX(${v.currentTime / v.duration})`;\n };\n\n return (\n <section\n ref={root}\n aria-roledescription=\"carousel\"\n aria-label={label}\n className={cn(\"grid items-center gap-6 md:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] md:gap-10\", className)}\n onPointerEnter={() => setHold((h) => ({ ...h, hover: true }))}\n onPointerLeave={() => setHold((h) => ({ ...h, hover: false }))}\n onFocus={() => setHold((h) => ({ ...h, focus: true }))}\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHold((h) => ({ ...h, focus: false }));\n }}\n {...props}\n >\n <style href=\"kz-step-showcase\" precedence=\"medium\">{FILL_CSS}</style>\n <div className=\"order-2 grid gap-3 md:order-1\">\n <div role=\"tablist\" aria-orientation=\"vertical\" aria-label={label} className=\"flex justify-center gap-2 md:grid md:gap-1\">\n {steps.map((s, i) => {\n const active = i === index;\n return (\n <button\n key={s.id}\n type=\"button\"\n role=\"tab\"\n id={`${id}-tab-${i}`}\n aria-selected={active}\n aria-controls={`${id}-panel`}\n tabIndex={active ? 0 : -1}\n onClick={() => go(i, true)}\n onKeyDown={(e) => {\n if (e.key === \"ArrowDown\" || e.key === \"ArrowRight\") go(i + 1, true);\n else if (e.key === \"ArrowUp\" || e.key === \"ArrowLeft\") go(i - 1, true);\n else return;\n e.preventDefault();\n }}\n className={cn(\n \"group relative rounded-xl text-left outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n \"max-md:h-2.5 max-md:w-2.5 max-md:rounded-full max-md:bg-border-strong max-md:aria-selected:w-6 max-md:aria-selected:bg-accent\",\n \"md:overflow-hidden md:p-4 md:hover:bg-surface-2 md:aria-selected:bg-surface-2\",\n )}\n >\n <span className=\"max-md:sr-only\">\n <span className=\"flex items-baseline gap-3\">\n <span className={cn(\"font-mono text-xs\", active ? \"text-fg-accent\" : \"text-fg-secondary\")}>{String(i + 1).padStart(2, \"0\")}</span>\n <span className={cn(\"text-sm font-semibold\", active ? \"text-fg\" : \"text-fg-secondary\")}>{s.title}</span>\n </span>\n <span className=\"mt-1 block pl-8 text-xs leading-relaxed text-fg-secondary\">{s.description}</span>\n </span>\n {active && auto && (\n <span aria-hidden className=\"absolute inset-x-4 bottom-0 hidden h-0.5 overflow-hidden rounded-full bg-border-subtle md:block\">\n <span\n key={`${s.id}-${playable}`}\n ref={bar}\n data-fill\n className={cn(\"block h-full bg-accent\", !playable && \"kz-step-fill\")}\n style={{ transform: playable ? \"scaleX(0)\" : undefined, animationPlayState: running ? \"running\" : \"paused\", [\"--kz-step-ms\" as string]: `${s.duration ?? 6000}ms` }}\n onAnimationEnd={() => go(index + 1, false)}\n />\n </span>\n )}\n </button>\n );\n })}\n </div>\n <p aria-hidden className=\"text-center text-sm md:hidden\">\n <span className=\"font-semibold text-fg\">{current?.title}</span> <span className=\"text-fg-secondary\">{current?.description}</span>\n </p>\n {auto && (\n <button type=\"button\" onClick={() => setPaused((p) => !p)} aria-pressed={paused} className=\"mx-auto inline-flex h-8 items-center gap-2 rounded-sm border border-border-subtle px-3 text-xs font-medium text-fg-secondary hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring md:mx-0 md:w-fit\">\n <svg viewBox=\"0 0 24 24\" className=\"h-3.5 w-3.5\" fill=\"currentColor\" aria-hidden>\n {paused ? <path d=\"M8 5v14l11-7z\" /> : <path d=\"M7 5h4v14H7zM13 5h4v14h-4z\" />}\n </svg>\n {paused ? labels.play : labels.pause}\n </button>\n )}\n </div>\n\n <div\n id={`${id}-panel`}\n role=\"tabpanel\"\n aria-roledescription=\"slide\"\n aria-labelledby={`${id}-tab-${index}`}\n className=\"order-1 touch-pan-y md:order-2\"\n onPointerDown={(e) => {\n swipe.current = e.clientX;\n }}\n onPointerUp={(e) => {\n if (swipe.current === null) return;\n const dx = e.clientX - swipe.current;\n swipe.current = null;\n if (Math.abs(dx) > 40) go(index + (dx < 0 ? 1 : -1), true);\n }}\n >\n <DeviceFrame kind={device} title={deviceTitle}>\n {steps.map((s, i) => {\n const active = i === index;\n return (\n <div key={s.id} data-step={s.id} aria-hidden={!active} className={cn(\"absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none\", active ? \"opacity-100\" : \"opacity-0\")}>\n <img src={s.poster} alt={active ? s.alt : \"\"} loading={i === 0 ? \"eager\" : \"lazy\"} decoding=\"async\" className=\"h-full w-full object-cover\" />\n {active && playable && s.video && (\n <video\n ref={video}\n key={s.id}\n muted\n playsInline\n preload=\"metadata\"\n poster={s.poster}\n aria-hidden\n tabIndex={-1}\n onTimeUpdate={onTime}\n onEnded={() => go(index + 1, false)}\n className=\"absolute inset-0 h-full w-full object-cover\"\n >\n {s.video.webm && <source src={s.video.webm} type=\"video/webm\" />}\n {s.video.mp4 && <source src={s.video.mp4} type=\"video/mp4\" />}\n </video>\n )}\n </div>\n );\n })}\n </DeviceFrame>\n <span className=\"sr-only\" aria-live=\"off\">\n {labels.step(index + 1, total)}\n </span>\n </div>\n </section>\n );\n}\n"],"mappings":"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","names":["jsx","jsxs"]}
package/dist/story.css CHANGED
@@ -31,7 +31,7 @@
31
31
  .kzs-stat { display: grid; gap: 6px; width: 100%; text-align: left; }
32
32
  .kzs-stat-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12.5px; }
33
33
  .kzs-stat-label { color: var(--kz-text-secondary); }
34
- .kzs-stat-value { color: var(--kz-text-primary); font-weight: 600; font-variant-numeric: tabular-nums; }
34
+ .kzs-stat-value { color: var(--kz-text-primary); font-weight: 600; font-variant-numeric: var(--kz-figures, lining-nums tabular-nums); }
35
35
  .kzs-bar { position: relative; display: block; width: 100%; height: 6px; border-radius: 999px; overflow: hidden; background: var(--kz-surface-3); }
36
36
  .kzs-bar-fill { position: absolute; inset: 0; border-radius: inherit; transform-origin: left center; transform: scaleX(var(--kzs-ratio, 1)); background: var(--kz-accent); }
37
37
 
@@ -208,7 +208,7 @@ html.light :is(.kzs-profile.kzs-arcade, .kzs-arcade .kzs-profile) { background:
208
208
  .kzs-gauge { display: grid; gap: 8px; width: 100%; max-width: 560px; }
209
209
  .kzs-gauge-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: 12.5px; }
210
210
  .kzs-gauge-label { color: var(--kz-text-secondary); }
211
- .kzs-gauge-value { color: var(--kz-text-primary); font-weight: 600; font-variant-numeric: tabular-nums; }
211
+ .kzs-gauge-value { color: var(--kz-text-primary); font-weight: 600; font-variant-numeric: var(--kz-figures, lining-nums tabular-nums); }
212
212
  .kzs-gauge-cells { display: grid; grid-template-columns: repeat(var(--kzs-segments, 8), minmax(0, 1fr)); gap: 4px; }
213
213
  .kzs-gauge-cell { height: 8px; border-radius: 999px; background: var(--kz-surface-3); }
214
214
  .kzs-gauge-cell[data-lit] { background: var(--kz-accent); }
@@ -261,7 +261,7 @@ html.light :is(.kzs-promise.kzs-arcade, .kzs-arcade .kzs-promise) { background:
261
261
  .kzs-prompt-head { display: inline-flex; align-items: center; gap: 14px; }
262
262
  .kzs-prompt-title { font-family: var(--kz-font-display); font-size: clamp(1.2rem, 3vw, 1.6rem); font-weight: 700; letter-spacing: -.015em; }
263
263
  .kzs-prompt-count { position: relative; display: inline-grid; place-items: center; min-width: 1.8em; height: 1.8em; padding: 0 6px; border-radius: 10px;
264
- font-family: var(--kz-font-display); font-size: clamp(1.2rem, 3vw, 1.5rem); font-weight: 700; font-variant-numeric: tabular-nums; color: var(--kz-accent-text); background: var(--kz-accent-soft); }
264
+ font-family: var(--kz-font-display); font-size: clamp(1.2rem, 3vw, 1.5rem); font-weight: 700; font-variant-numeric: var(--kz-figures, lining-nums tabular-nums); color: var(--kz-accent-text); background: var(--kz-accent-soft); }
265
265
  .kzs-prompt-count::after { position: absolute; inset: 0; display: grid; place-items: center; color: var(--kz-accent-text); }
266
266
  .kzs-prompt-deck { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; }
267
267
  .kzs-prompt-coin { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 12.5px; color: var(--kz-text-secondary); }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import {
3
3
  Slider
4
- } from "./chunk-RE5WJ4QP.js";
4
+ } from "./chunk-ADRUEPYC.js";
5
5
  import "./chunk-HJFRHYCI.js";
6
6
  import "./chunk-NSLXEDIS.js";
7
7
  import {
@@ -12,7 +12,7 @@ var videoTrimmer = tv({
12
12
  slots: {
13
13
  root: "w-full select-none",
14
14
  header: "mb-2 flex items-center justify-between font-mono text-[11px] tabular-nums text-fg-secondary",
15
- kept: "rounded-full bg-accent-soft px-2.5 py-0.5 font-sans text-xs font-semibold text-fg",
15
+ kept: "rounded-sm bg-accent-soft px-2.5 py-0.5 font-sans text-xs font-semibold text-fg",
16
16
  track: "relative h-16 touch-none rounded-xl bg-surface-2 data-[disabled]:opacity-50",
17
17
  strip: "pointer-events-none absolute inset-0 flex overflow-hidden rounded-xl",
18
18
  frame: "h-full min-w-0 flex-1 object-cover",
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/video-trimmer/video-trimmer.tsx"],"sourcesContent":["// Client. The trim timeline of a video editor: the clip as a filmstrip (or any track content), the part kept framed\n// between two edge handles — each a `slider` named by a prop, its value read as a time, never past the other one —\n// the outside dimmed, and a playhead to scrub. The handles sit on their value by their outer edge (the start handle\n// to the right of its value, the end handle to its left), so the frame encloses exactly what is kept. Every part\n// takes its own classes (`classNames`) and the handles their own content (`renderHandle`).\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { clamp, formatClock } from \"../media\";\n\nexport const videoTrimmer = tv({\n slots: {\n root: \"w-full select-none\",\n header: \"mb-2 flex items-center justify-between font-mono text-[11px] tabular-nums text-fg-secondary\",\n kept: \"rounded-full bg-accent-soft px-2.5 py-0.5 font-sans text-xs font-semibold text-fg\",\n track: \"relative h-16 touch-none rounded-xl bg-surface-2 data-[disabled]:opacity-50\",\n strip: \"pointer-events-none absolute inset-0 flex overflow-hidden rounded-xl\",\n frame: \"h-full min-w-0 flex-1 object-cover\",\n shade: \"pointer-events-none absolute inset-y-0 bg-scrim-strong\",\n range: \"pointer-events-none absolute inset-y-0 border-y-[3px] border-accent\",\n handle:\n \"absolute inset-y-0 z-10 flex w-6 cursor-ew-resize items-center justify-center bg-accent text-on-accent shadow-md \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 \" +\n \"data-[edge=start]:rounded-l-xl data-[edge=end]:-translate-x-full data-[edge=end]:rounded-r-xl\",\n grip: \"flex gap-[3px] [&>span]:h-5 [&>span]:w-[2px] [&>span]:rounded-sm [&>span]:bg-on-accent/70\",\n playhead:\n \"absolute -inset-y-1.5 z-20 w-[3px] -translate-x-1/2 cursor-ew-resize rounded-full bg-fg shadow-md \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n },\n});\n\ntype Part = keyof ReturnType<typeof videoTrimmer>;\n\nexport type VideoTrimmerLabels = {\n /** The start handle (\"Start\"). */\n start: string;\n /** The end handle (\"End\"). */\n end: string;\n /** The playhead (\"Playhead\"). */\n playhead: string;\n /** The length kept, above the track: `(length) => length + \" kept\"` (the length already formatted). */\n kept: (length: string) => string;\n};\n\nexport const videoTrimmerLabels: VideoTrimmerLabels = {\n start: \"Start\",\n end: \"End\",\n playhead: \"Playhead\",\n kept: (length) => `${length} kept`,\n};\n\nexport type VideoTrimmerProps = {\n /** The length of the clip, in seconds. */\n duration: number;\n /** The part kept, `[start, end]` in seconds (controlled). */\n value?: readonly [number, number];\n /** The part kept at first, uncontrolled. Default: the whole clip. */\n defaultValue?: readonly [number, number];\n /** Called on every move of a handle. */\n onValueChange?: (value: [number, number]) => void;\n /** Called once a drag or a key press ends: render a preview, save. */\n onValueCommit?: (value: [number, number]) => void;\n /** The time played, in seconds: shows the playhead. */\n time?: number;\n /** Called when the playhead is moved (a drag on the track, the arrows on the playhead). */\n onSeek?: (time: number) => void;\n /** The shortest part that can be kept, in seconds. Default 0.5. */\n minLength?: number;\n /** One arrow press, in seconds. Default 0.1. */\n step?: number;\n /** Shift + arrow, Page Up / Page Down, in seconds. Default 1. */\n largeStep?: number;\n /** Pictures of the clip, in order: the filmstrip. */\n frames?: readonly string[];\n /** Track content instead of the filmstrip (a waveform, a coloured band). */\n children?: React.ReactNode;\n /** A time as read and shown. Default `m:ss` with tenths. */\n formatTime?: (seconds: number) => string;\n /** Shows the times and the length kept above the track. Default `true`. */\n showHeader?: boolean;\n /** Not available (a render is running). */\n disabled?: boolean;\n /** Classes per part: `root`, `header`, `kept`, `track`, `strip`, `frame`, `shade`, `range`, `handle`, `grip`, `playhead`. */\n classNames?: Partial<Record<Part, string>>;\n /** The content of a handle (default: two grip lines). */\n renderHandle?: (edge: \"start\" | \"end\") => React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<VideoTrimmerLabels>;\n};\n\nconst tenths = (seconds: number) => `${formatClock(seconds)}.${Math.floor((Math.max(0, seconds) % 1) * 10)}`;\n\ntype Grip = \"start\" | \"end\" | \"playhead\";\n\n/** Keeps a part of a clip between two handles over its filmstrip, with a playhead to scrub — the keyboard included. */\nexport function VideoTrimmer({\n duration,\n value: valueProp,\n defaultValue,\n onValueChange,\n onValueCommit,\n time,\n onSeek,\n minLength = 0.5,\n step = 0.1,\n largeStep = 1,\n frames,\n children,\n formatTime = tenths,\n showHeader = true,\n disabled,\n classNames = {},\n renderHandle,\n labels: given,\n}: VideoTrimmerProps) {\n const labels = { ...videoTrimmerLabels, ...given };\n const s = videoTrimmer();\n const track = React.useRef<HTMLDivElement>(null);\n const grip = React.useRef<Grip | null>(null);\n const [inner, setInner] = React.useState<[number, number]>(() => [defaultValue?.[0] ?? 0, defaultValue?.[1] ?? duration]);\n const [start, end] = valueProp ?? inner;\n const total = duration > 0 ? duration : 1;\n const pct = (seconds: number) => (clamp(seconds, 0, total) / total) * 100;\n\n const set = (next: [number, number], commit = false) => {\n if (!valueProp) setInner(next);\n onValueChange?.(next);\n if (commit) onValueCommit?.(next);\n };\n const move = (edge: \"start\" | \"end\", to: number, commit = false) => {\n const next: [number, number] =\n edge === \"start\" ? [clamp(to, 0, end - minLength), end] : [start, clamp(to, start + minLength, duration)];\n set([Math.round(next[0] * 1000) / 1000, Math.round(next[1] * 1000) / 1000], commit);\n };\n const seek = (to: number) => onSeek?.(clamp(to, start, Math.max(start, end - 0.05)));\n\n const at = (clientX: number) => {\n const box = track.current?.getBoundingClientRect();\n if (!box || box.width === 0) return 0;\n return clamp(((clientX - box.left) / box.width) * duration, 0, duration);\n };\n\n const keys = (which: Grip) => (event: React.KeyboardEvent) => {\n const current = which === \"start\" ? start : which === \"end\" ? end : (time ?? start);\n const big = largeStep;\n const small = event.shiftKey ? largeStep : step;\n const target: Record<string, number> = {\n ArrowLeft: current - small,\n ArrowDown: current - small,\n ArrowRight: current + small,\n ArrowUp: current + small,\n PageDown: current - big,\n PageUp: current + big,\n Home: 0,\n End: duration,\n };\n const to = target[event.key];\n if (to === undefined || disabled) return;\n event.preventDefault();\n if (which === \"playhead\") seek(to);\n else move(which, to, true);\n };\n\n const handle = (edge: \"start\" | \"end\") => {\n const own = edge === \"start\" ? start : end;\n return (\n <div\n key={edge}\n role=\"slider\"\n tabIndex={disabled ? -1 : 0}\n aria-label={edge === \"start\" ? labels.start : labels.end}\n aria-valuemin={edge === \"start\" ? 0 : Math.round((start + minLength) * 10) / 10}\n aria-valuemax={edge === \"start\" ? Math.round((end - minLength) * 10) / 10 : Math.round(duration * 10) / 10}\n aria-valuenow={Math.round(own * 10) / 10}\n aria-valuetext={formatTime(own)}\n aria-disabled={disabled || undefined}\n data-edge={edge}\n className={s.handle({ className: classNames.handle })}\n style={{ left: `${pct(own)}%` }}\n onPointerDown={(event) => {\n if (disabled) return;\n event.stopPropagation();\n track.current?.setPointerCapture?.(event.pointerId);\n grip.current = edge;\n }}\n onKeyDown={keys(edge)}\n >\n {renderHandle ? (\n renderHandle(edge)\n ) : (\n <span className={s.grip({ className: classNames.grip })}>\n <span />\n <span />\n </span>\n )}\n </div>\n );\n };\n\n return (\n <div className={s.root({ className: classNames.root })}>\n {showHeader && (\n <div className={s.header({ className: classNames.header })}>\n <span>{formatTime(start)}</span>\n <span className={s.kept({ className: classNames.kept })}>{labels.kept(formatTime(end - start))}</span>\n <span>{formatTime(end)}</span>\n </div>\n )}\n <div\n ref={track}\n className={s.track({ className: classNames.track })}\n data-disabled={disabled ? \"\" : undefined}\n onPointerDown={(event) => {\n if (disabled) return;\n track.current?.setPointerCapture?.(event.pointerId);\n const t = at(event.clientX);\n grip.current = onSeek ? \"playhead\" : Math.abs(t - start) < Math.abs(t - end) ? \"start\" : \"end\";\n if (grip.current === \"playhead\") seek(t);\n else move(grip.current, t);\n }}\n onPointerMove={(event) => {\n if (!grip.current) return;\n const t = at(event.clientX);\n if (grip.current === \"playhead\") seek(t);\n else move(grip.current, t);\n }}\n onPointerUp={() => {\n if (grip.current && grip.current !== \"playhead\") onValueCommit?.([start, end]);\n grip.current = null;\n }}\n onPointerCancel={() => {\n grip.current = null;\n }}\n >\n <div className={s.strip({ className: classNames.strip })}>\n {children ?? frames?.map((src, i) => <img key={`${i}-${src}`} src={src} alt=\"\" draggable={false} className={s.frame({ className: classNames.frame })} />)}\n </div>\n <div className={s.shade({ className: classNames.shade })} style={{ left: 0, width: `${pct(start)}%`, borderRadius: \"0.75rem 0 0 0.75rem\" }} />\n <div className={s.shade({ className: classNames.shade })} style={{ right: 0, width: `${100 - pct(end)}%`, borderRadius: \"0 0.75rem 0.75rem 0\" }} />\n <div className={s.range({ className: classNames.range })} style={{ left: `${pct(start)}%`, right: `${100 - pct(end)}%` }} />\n {time !== undefined && (\n <div\n role=\"slider\"\n tabIndex={disabled || !onSeek ? -1 : 0}\n aria-label={labels.playhead}\n aria-valuemin={Math.round(start * 10) / 10}\n aria-valuemax={Math.round(end * 10) / 10}\n aria-valuenow={Math.round(time * 10) / 10}\n aria-valuetext={formatTime(time)}\n aria-readonly={!onSeek || undefined}\n className={s.playhead({ className: classNames.playhead })}\n style={{ left: `${pct(time)}%` }}\n onKeyDown={onSeek ? keys(\"playhead\") : undefined}\n />\n )}\n {handle(\"start\")}\n {handle(\"end\")}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/video-trimmer/video-trimmer.tsx"],"sourcesContent":["// Client. The trim timeline of a video editor: the clip as a filmstrip (or any track content), the part kept framed\n// between two edge handles — each a `slider` named by a prop, its value read as a time, never past the other one —\n// the outside dimmed, and a playhead to scrub. The handles sit on their value by their outer edge (the start handle\n// to the right of its value, the end handle to its left), so the frame encloses exactly what is kept. Every part\n// takes its own classes (`classNames`) and the handles their own content (`renderHandle`).\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { clamp, formatClock } from \"../media\";\n\nexport const videoTrimmer = tv({\n slots: {\n root: \"w-full select-none\",\n header: \"mb-2 flex items-center justify-between font-mono text-[11px] tabular-nums text-fg-secondary\",\n kept: \"rounded-sm bg-accent-soft px-2.5 py-0.5 font-sans text-xs font-semibold text-fg\",\n track: \"relative h-16 touch-none rounded-xl bg-surface-2 data-[disabled]:opacity-50\",\n strip: \"pointer-events-none absolute inset-0 flex overflow-hidden rounded-xl\",\n frame: \"h-full min-w-0 flex-1 object-cover\",\n shade: \"pointer-events-none absolute inset-y-0 bg-scrim-strong\",\n range: \"pointer-events-none absolute inset-y-0 border-y-[3px] border-accent\",\n handle:\n \"absolute inset-y-0 z-10 flex w-6 cursor-ew-resize items-center justify-center bg-accent text-on-accent shadow-md \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 \" +\n \"data-[edge=start]:rounded-l-xl data-[edge=end]:-translate-x-full data-[edge=end]:rounded-r-xl\",\n grip: \"flex gap-[3px] [&>span]:h-5 [&>span]:w-[2px] [&>span]:rounded-sm [&>span]:bg-on-accent/70\",\n playhead:\n \"absolute -inset-y-1.5 z-20 w-[3px] -translate-x-1/2 cursor-ew-resize rounded-full bg-fg shadow-md \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n },\n});\n\ntype Part = keyof ReturnType<typeof videoTrimmer>;\n\nexport type VideoTrimmerLabels = {\n /** The start handle (\"Start\"). */\n start: string;\n /** The end handle (\"End\"). */\n end: string;\n /** The playhead (\"Playhead\"). */\n playhead: string;\n /** The length kept, above the track: `(length) => length + \" kept\"` (the length already formatted). */\n kept: (length: string) => string;\n};\n\nexport const videoTrimmerLabels: VideoTrimmerLabels = {\n start: \"Start\",\n end: \"End\",\n playhead: \"Playhead\",\n kept: (length) => `${length} kept`,\n};\n\nexport type VideoTrimmerProps = {\n /** The length of the clip, in seconds. */\n duration: number;\n /** The part kept, `[start, end]` in seconds (controlled). */\n value?: readonly [number, number];\n /** The part kept at first, uncontrolled. Default: the whole clip. */\n defaultValue?: readonly [number, number];\n /** Called on every move of a handle. */\n onValueChange?: (value: [number, number]) => void;\n /** Called once a drag or a key press ends: render a preview, save. */\n onValueCommit?: (value: [number, number]) => void;\n /** The time played, in seconds: shows the playhead. */\n time?: number;\n /** Called when the playhead is moved (a drag on the track, the arrows on the playhead). */\n onSeek?: (time: number) => void;\n /** The shortest part that can be kept, in seconds. Default 0.5. */\n minLength?: number;\n /** One arrow press, in seconds. Default 0.1. */\n step?: number;\n /** Shift + arrow, Page Up / Page Down, in seconds. Default 1. */\n largeStep?: number;\n /** Pictures of the clip, in order: the filmstrip. */\n frames?: readonly string[];\n /** Track content instead of the filmstrip (a waveform, a coloured band). */\n children?: React.ReactNode;\n /** A time as read and shown. Default `m:ss` with tenths. */\n formatTime?: (seconds: number) => string;\n /** Shows the times and the length kept above the track. Default `true`. */\n showHeader?: boolean;\n /** Not available (a render is running). */\n disabled?: boolean;\n /** Classes per part: `root`, `header`, `kept`, `track`, `strip`, `frame`, `shade`, `range`, `handle`, `grip`, `playhead`. */\n classNames?: Partial<Record<Part, string>>;\n /** The content of a handle (default: two grip lines). */\n renderHandle?: (edge: \"start\" | \"end\") => React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<VideoTrimmerLabels>;\n};\n\nconst tenths = (seconds: number) => `${formatClock(seconds)}.${Math.floor((Math.max(0, seconds) % 1) * 10)}`;\n\ntype Grip = \"start\" | \"end\" | \"playhead\";\n\n/** Keeps a part of a clip between two handles over its filmstrip, with a playhead to scrub — the keyboard included. */\nexport function VideoTrimmer({\n duration,\n value: valueProp,\n defaultValue,\n onValueChange,\n onValueCommit,\n time,\n onSeek,\n minLength = 0.5,\n step = 0.1,\n largeStep = 1,\n frames,\n children,\n formatTime = tenths,\n showHeader = true,\n disabled,\n classNames = {},\n renderHandle,\n labels: given,\n}: VideoTrimmerProps) {\n const labels = { ...videoTrimmerLabels, ...given };\n const s = videoTrimmer();\n const track = React.useRef<HTMLDivElement>(null);\n const grip = React.useRef<Grip | null>(null);\n const [inner, setInner] = React.useState<[number, number]>(() => [defaultValue?.[0] ?? 0, defaultValue?.[1] ?? duration]);\n const [start, end] = valueProp ?? inner;\n const total = duration > 0 ? duration : 1;\n const pct = (seconds: number) => (clamp(seconds, 0, total) / total) * 100;\n\n const set = (next: [number, number], commit = false) => {\n if (!valueProp) setInner(next);\n onValueChange?.(next);\n if (commit) onValueCommit?.(next);\n };\n const move = (edge: \"start\" | \"end\", to: number, commit = false) => {\n const next: [number, number] =\n edge === \"start\" ? [clamp(to, 0, end - minLength), end] : [start, clamp(to, start + minLength, duration)];\n set([Math.round(next[0] * 1000) / 1000, Math.round(next[1] * 1000) / 1000], commit);\n };\n const seek = (to: number) => onSeek?.(clamp(to, start, Math.max(start, end - 0.05)));\n\n const at = (clientX: number) => {\n const box = track.current?.getBoundingClientRect();\n if (!box || box.width === 0) return 0;\n return clamp(((clientX - box.left) / box.width) * duration, 0, duration);\n };\n\n const keys = (which: Grip) => (event: React.KeyboardEvent) => {\n const current = which === \"start\" ? start : which === \"end\" ? end : (time ?? start);\n const big = largeStep;\n const small = event.shiftKey ? largeStep : step;\n const target: Record<string, number> = {\n ArrowLeft: current - small,\n ArrowDown: current - small,\n ArrowRight: current + small,\n ArrowUp: current + small,\n PageDown: current - big,\n PageUp: current + big,\n Home: 0,\n End: duration,\n };\n const to = target[event.key];\n if (to === undefined || disabled) return;\n event.preventDefault();\n if (which === \"playhead\") seek(to);\n else move(which, to, true);\n };\n\n const handle = (edge: \"start\" | \"end\") => {\n const own = edge === \"start\" ? start : end;\n return (\n <div\n key={edge}\n role=\"slider\"\n tabIndex={disabled ? -1 : 0}\n aria-label={edge === \"start\" ? labels.start : labels.end}\n aria-valuemin={edge === \"start\" ? 0 : Math.round((start + minLength) * 10) / 10}\n aria-valuemax={edge === \"start\" ? Math.round((end - minLength) * 10) / 10 : Math.round(duration * 10) / 10}\n aria-valuenow={Math.round(own * 10) / 10}\n aria-valuetext={formatTime(own)}\n aria-disabled={disabled || undefined}\n data-edge={edge}\n className={s.handle({ className: classNames.handle })}\n style={{ left: `${pct(own)}%` }}\n onPointerDown={(event) => {\n if (disabled) return;\n event.stopPropagation();\n track.current?.setPointerCapture?.(event.pointerId);\n grip.current = edge;\n }}\n onKeyDown={keys(edge)}\n >\n {renderHandle ? (\n renderHandle(edge)\n ) : (\n <span className={s.grip({ className: classNames.grip })}>\n <span />\n <span />\n </span>\n )}\n </div>\n );\n };\n\n return (\n <div className={s.root({ className: classNames.root })}>\n {showHeader && (\n <div className={s.header({ className: classNames.header })}>\n <span>{formatTime(start)}</span>\n <span className={s.kept({ className: classNames.kept })}>{labels.kept(formatTime(end - start))}</span>\n <span>{formatTime(end)}</span>\n </div>\n )}\n <div\n ref={track}\n className={s.track({ className: classNames.track })}\n data-disabled={disabled ? \"\" : undefined}\n onPointerDown={(event) => {\n if (disabled) return;\n track.current?.setPointerCapture?.(event.pointerId);\n const t = at(event.clientX);\n grip.current = onSeek ? \"playhead\" : Math.abs(t - start) < Math.abs(t - end) ? \"start\" : \"end\";\n if (grip.current === \"playhead\") seek(t);\n else move(grip.current, t);\n }}\n onPointerMove={(event) => {\n if (!grip.current) return;\n const t = at(event.clientX);\n if (grip.current === \"playhead\") seek(t);\n else move(grip.current, t);\n }}\n onPointerUp={() => {\n if (grip.current && grip.current !== \"playhead\") onValueCommit?.([start, end]);\n grip.current = null;\n }}\n onPointerCancel={() => {\n grip.current = null;\n }}\n >\n <div className={s.strip({ className: classNames.strip })}>\n {children ?? frames?.map((src, i) => <img key={`${i}-${src}`} src={src} alt=\"\" draggable={false} className={s.frame({ className: classNames.frame })} />)}\n </div>\n <div className={s.shade({ className: classNames.shade })} style={{ left: 0, width: `${pct(start)}%`, borderRadius: \"0.75rem 0 0 0.75rem\" }} />\n <div className={s.shade({ className: classNames.shade })} style={{ right: 0, width: `${100 - pct(end)}%`, borderRadius: \"0 0.75rem 0.75rem 0\" }} />\n <div className={s.range({ className: classNames.range })} style={{ left: `${pct(start)}%`, right: `${100 - pct(end)}%` }} />\n {time !== undefined && (\n <div\n role=\"slider\"\n tabIndex={disabled || !onSeek ? -1 : 0}\n aria-label={labels.playhead}\n aria-valuemin={Math.round(start * 10) / 10}\n aria-valuemax={Math.round(end * 10) / 10}\n aria-valuenow={Math.round(time * 10) / 10}\n aria-valuetext={formatTime(time)}\n aria-readonly={!onSeek || undefined}\n className={s.playhead({ className: classNames.playhead })}\n style={{ left: `${pct(time)}%` }}\n onKeyDown={onSeek ? keys(\"playhead\") : undefined}\n />\n )}\n {handle(\"start\")}\n {handle(\"end\")}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/ui",
3
- "version": "3.38.0",
3
+ "version": "3.39.0",
4
4
  "description": "The Krizaka front-end platform's components: the primitives (button, card, dialog, toast, menu, field, tabs, chip, switch, slider, command palette, theme, countdown…) on the semantic --kz-* tokens, the animated marks (Krizaka, Orazaka, Orochia, Yasakia, Yororis) and the motion signature — web and /native.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -741,7 +741,7 @@
741
741
  ],
742
742
  "peerDependencies": {
743
743
  "@krizaka/query": ">=0.2.1",
744
- "@krizaka/tailwind": ">=3.38.0",
744
+ "@krizaka/tailwind": ">=3.39.0",
745
745
  "@maplibre/maplibre-react-native": ">=11",
746
746
  "@shopify/flash-list": ">=2",
747
747
  "expo-camera": ">=16",
@@ -2535,8 +2535,8 @@
2535
2535
  "sonner": "^2.0.8",
2536
2536
  "tailwind-variants": "^3.3.1",
2537
2537
  "@krizaka/intl": "^0.2.1",
2538
- "@krizaka/tokens": "^3.38.0",
2539
- "@krizaka/pacer": "^0.1.0"
2538
+ "@krizaka/pacer": "^0.1.0",
2539
+ "@krizaka/tokens": "^3.39.0"
2540
2540
  },
2541
2541
  "scripts": {
2542
2542
  "build": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});for(const f of fs.readdirSync('registry/examples',{recursive:true}))if(/\\.(js|d\\.ts)$/.test(f))fs.rmSync('registry/examples/'+f)\" && tsup && node -e \"require('node:fs').copyFileSync('src/motion.css','dist/motion.css');require('node:fs').copyFileSync('src/blocks.css','dist/blocks.css');require('node:fs').copyFileSync('src/story.css','dist/story.css')\" && node scripts/build-registry.mjs",
@@ -109,7 +109,7 @@
109
109
  "files": [
110
110
  {
111
111
  "path": "chip-link/chip-link.tsx",
112
- "content": "// Server-safe: no hook, no context. A chip that is a link — a filter that is an address (`/explore?tag=night`): the look\n// of `Chip`, the current filter marked by `selected` (`aria-current`, the accent), usable in a Server Component.\n// `chipVariants` styles anything else (a router link you render yourself).\nimport { Slot } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { tv, type VariantProps } from \"tailwind-variants\";\n\nexport const chipVariants = tv({\n base:\n \"inline-flex shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-full border font-semibold no-underline transition-colors \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n variants: {\n size: { sm: \"h-7 px-3 text-xs\", md: \"h-9 px-3.5 text-xs\" },\n selected: {\n false: \"border-border-default bg-surface-1 text-fg-secondary hover:border-border-strong hover:text-fg\",\n true: \"border-accent bg-accent-soft text-fg\",\n },\n },\n defaultVariants: { size: \"md\", selected: false },\n});\n\nexport type ChipVariantsProps = VariantProps<typeof chipVariants>;\n\nexport type ChipLinkProps = React.ComponentProps<\"a\"> & {\n /**\n * sm · md.\n * @default \"md\"\n */\n size?: ChipVariantsProps[\"size\"];\n /** The current filter: the accent, `aria-current` (`page` by default) and `data-state=\"on\"`. */\n selected?: boolean;\n /**\n * What `aria-current` says when selected: `page` (a filter that is a page) or `true`.\n * @default \"page\"\n */\n current?: \"page\" | \"true\";\n /** Renders your router's `<Link>` with the chip's look. */\n asChild?: boolean;\n};\n\n/** A filter that is a link, with the look of `Chip`. */\nexport function ChipLink({ size, selected = false, current = \"page\", asChild, className, ...props }: ChipLinkProps) {\n const Comp = asChild ? Slot.Root : \"a\";\n return (\n <Comp\n data-state={selected ? \"on\" : \"off\"}\n aria-current={selected ? current : undefined}\n className={chipVariants({ size, selected, className })}\n {...props}\n />\n );\n}\n"
112
+ "content": "// Server-safe: no hook, no context. A chip that is a link — a filter that is an address (`/explore?tag=night`): the look\n// of `Chip`, the current filter marked by `selected` (`aria-current`, the accent), usable in a Server Component.\n// `chipVariants` styles anything else (a router link you render yourself).\nimport { Slot } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { tv, type VariantProps } from \"tailwind-variants\";\n\nexport const chipVariants = tv({\n base:\n \"inline-flex shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-sm border font-semibold no-underline transition-colors \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n variants: {\n size: { sm: \"h-7 px-3 text-xs\", md: \"h-9 px-3.5 text-xs\" },\n selected: {\n false: \"border-border-default bg-surface-1 text-fg-secondary hover:border-border-strong hover:text-fg\",\n true: \"border-accent bg-accent-soft text-fg\",\n },\n },\n defaultVariants: { size: \"md\", selected: false },\n});\n\nexport type ChipVariantsProps = VariantProps<typeof chipVariants>;\n\nexport type ChipLinkProps = React.ComponentProps<\"a\"> & {\n /**\n * sm · md.\n * @default \"md\"\n */\n size?: ChipVariantsProps[\"size\"];\n /** The current filter: the accent, `aria-current` (`page` by default) and `data-state=\"on\"`. */\n selected?: boolean;\n /**\n * What `aria-current` says when selected: `page` (a filter that is a page) or `true`.\n * @default \"page\"\n */\n current?: \"page\" | \"true\";\n /** Renders your router's `<Link>` with the chip's look. */\n asChild?: boolean;\n};\n\n/** A filter that is a link, with the look of `Chip`. */\nexport function ChipLink({ size, selected = false, current = \"page\", asChild, className, ...props }: ChipLinkProps) {\n const Comp = asChild ? Slot.Root : \"a\";\n return (\n <Comp\n data-state={selected ? \"on\" : \"off\"}\n aria-current={selected ? current : undefined}\n className={chipVariants({ size, selected, className })}\n {...props}\n />\n );\n}\n"
113
113
  },
114
114
  {
115
115
  "path": "chip-link/index.ts",
@@ -13,10 +13,6 @@ function SiteFooterCompany() {
13
13
  legal: /* @__PURE__ */ jsxs(Fragment, { children: [
14
14
  /* @__PURE__ */ jsx(SiteFooter.Link, { href: "#privacy", children: "Privacy" }),
15
15
  /* @__PURE__ */ jsx(SiteFooter.Link, { href: "#terms", children: "Terms" })
16
- ] }),
17
- children: /* @__PURE__ */ jsxs(SiteFooter.Column, { title: "Company", children: [
18
- /* @__PURE__ */ jsx(SiteFooter.Link, { href: "#story", active: true, children: "Our story" }),
19
- /* @__PURE__ */ jsx(SiteFooter.Link, { href: "#contact", children: "Contact" })
20
16
  ] })
21
17
  }
22
18
  );
@@ -1,7 +1,8 @@
1
1
  import { SiteFooter } from "@krizaka/ui/site-footer";
2
2
  import { familyLabels } from "@krizaka/ui/sites";
3
3
 
4
- // www.krizaka.com: “A Krizaka company”, the company's own columns, the legal links at the end of the signature line.
4
+ // www.krizaka.com: “A Krizaka company” — the family's columns already hold the company's pages (story, blog, contact),
5
+ // so www adds no column of its own; the legal links end the signature line.
5
6
  export default function SiteFooterCompany() {
6
7
  return (
7
8
  <SiteFooter
@@ -13,11 +14,6 @@ export default function SiteFooterCompany() {
13
14
  <SiteFooter.Link href="#terms">Terms</SiteFooter.Link>
14
15
  </>
15
16
  }
16
- >
17
- <SiteFooter.Column title="Company">
18
- <SiteFooter.Link href="#story" active>Our story</SiteFooter.Link>
19
- <SiteFooter.Link href="#contact">Contact</SiteFooter.Link>
20
- </SiteFooter.Column>
21
- </SiteFooter>
17
+ />
22
18
  );
23
19
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/ui",
3
- "version": "3.38.0",
3
+ "version": "3.39.0",
4
4
  "templatePurposes": {
5
5
  "convince": {
6
6
  "title": "Convince",
@@ -3110,7 +3110,7 @@
3110
3110
  "native": 2
3111
3111
  },
3112
3112
  "dependencies": [
3113
- "@krizaka/tokens@^3.38.0",
3113
+ "@krizaka/tokens@^3.39.0",
3114
3114
  "@protomaps/basemaps@5.7.2",
3115
3115
  "tailwind-variants@^3.3.1"
3116
3116
  ],
@@ -3549,7 +3549,7 @@
3549
3549
  "native": 0
3550
3550
  },
3551
3551
  "dependencies": [
3552
- "@krizaka/tokens@^3.38.0",
3552
+ "@krizaka/tokens@^3.39.0",
3553
3553
  "radix-ui@^1.7.0",
3554
3554
  "tailwind-variants@^3.3.1"
3555
3555
  ],
@@ -4212,7 +4212,7 @@
4212
4212
  "native": 0
4213
4213
  },
4214
4214
  "dependencies": [
4215
- "@krizaka/tokens@^3.38.0",
4215
+ "@krizaka/tokens@^3.39.0",
4216
4216
  "radix-ui@^1.7.0",
4217
4217
  "tailwind-variants@^3.3.1"
4218
4218
  ],
@@ -4532,8 +4532,8 @@
4532
4532
  "name": "site-footer",
4533
4533
  "type": "primitive",
4534
4534
  "title": "Site footer",
4535
- "summary": "The footer of a Krizaka site: the site's mark and line, its own columns, the family in columns on the Krizaka sites (from `@krizaka/ui/sites`; `family={false}` for an independent product), and the signature line — “A Krizaka company / product / open-source project” — with the version and Montréal.",
4536
- "description": "The footer of a Krizaka site: the site's mark and line, its own columns, the family in columns on the Krizaka sites (from `@krizaka/ui/sites`; `family={false}` for an independent product), and the signature line — “A Krizaka company / product / open-source project” — with the version and Montréal.",
4535
+ "summary": "The footer of a Krizaka site, one calm band: its own columns beside the family's — Products · Open source (GitHub with them) · Krizaka (our story, the blog, contact) — on the Krizaka sites (`family={false}` for an independent product), then one line: “A Krizaka company / product / open-source project”, the version, Montréal and the legal links. No site block, no link twice.",
4536
+ "description": "The footer of a Krizaka site, one calm band: its own columns beside the family's — Products · Open source (GitHub with them) · Krizaka (our story, the blog, contact) — on the Krizaka sites (`family={false}` for an independent product), then one line: “A Krizaka company / product / open-source project”, the version, Montréal and the legal links. No site block, no link twice.",
4537
4537
  "status": "beta",
4538
4538
  "category": "navigation",
4539
4539
  "platforms": "web",
@@ -4555,7 +4555,7 @@
4555
4555
  "native": 0
4556
4556
  },
4557
4557
  "dependencies": [
4558
- "@krizaka/tokens@^3.38.0",
4558
+ "@krizaka/tokens@^3.39.0",
4559
4559
  "radix-ui@^1.7.0",
4560
4560
  "tailwind-variants@^3.3.1"
4561
4561
  ],
@@ -167,7 +167,7 @@
167
167
  },
168
168
  {
169
169
  "path": "kanban/kanban.tsx",
170
- "content": "// Client (the drag, the keyboard move, the announcements). Cards in columns that move by drag and drop — and by the\n// keyboard: Space picks a card up, the arrows carry it (← → between columns, ↑ ↓ within one), Space drops it, Esc puts it\n// back. Every move is announced in words. The app owns the data: `onMove` says where a card went.\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nexport const kanban = tv({\n slots: {\n root: \"flex w-full gap-3 overflow-x-auto pb-2\",\n column: \"flex w-72 shrink-0 flex-col rounded-xl border border-border-default bg-surface-1 data-[over]:border-accent data-[over]:bg-accent-soft/40\",\n header: \"flex items-center gap-2 px-3 pb-2 pt-3\",\n title: \"min-w-0 flex-1 truncate text-sm font-semibold text-fg\",\n count: \"rounded-full bg-surface-3 px-2 text-xs font-semibold tabular-nums text-fg-secondary data-[over-limit]:bg-danger data-[over-limit]:text-on-accent\",\n list: \"m-0 flex min-h-16 list-none flex-col gap-2 p-2 pt-0\",\n card:\n \"cursor-grab rounded-lg border border-border-default bg-surface-0 p-3 text-sm text-fg shadow-xs outline-hidden transition-shadow \" +\n \"focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing data-[grabbed]:ring-2 data-[grabbed]:ring-accent data-[grabbed]:shadow-lg data-[dragging]:opacity-40\",\n empty: \"rounded-lg border border-dashed border-border-default px-3 py-4 text-center text-xs text-fg-secondary\",\n },\n});\n\nexport type KanbanColumn = {\n /** A stable id. */\n id: string;\n /** Its name — passed translated (“To review”). */\n title: string;\n /** The most cards it should hold: the count turns to danger past it. */\n limit?: number;\n};\n\nexport type KanbanMove = {\n /** The card moved. */\n cardId: string;\n /** Where it came from. */\n from: string;\n /** The column it went to. */\n to: string;\n /** Its position in that column (0 = top). */\n index: number;\n};\n\nexport type KanbanLabels = {\n /** The hint read on a card: how to move it with the keyboard. */\n instructions: string;\n /** Announced when a card is picked up: `(card, column) => …`. */\n grabbed: (card: string, column: string) => string;\n /** Announced as it moves: `(card, column, position, total) => …`. */\n moved: (card: string, column: string, position: number, total: number) => string;\n /** Announced when it is dropped. */\n dropped: (card: string, column: string, position: number) => string;\n /** Announced when the move is cancelled. */\n cancelled: (card: string) => string;\n /** A column without card (“No card”). */\n empty: string;\n};\n\nexport const kanbanLabels: KanbanLabels = {\n instructions: \"Press Space to pick it up, the arrow keys to move it, Space to drop it, Escape to cancel.\",\n grabbed: (card, column) => `${card} picked up, in ${column}.`,\n moved: (card, column, position, total) => `${card}: ${column}, position ${position} of ${total}.`,\n dropped: (card, column, position) => `${card} dropped in ${column}, position ${position}.`,\n cancelled: (card) => `Move of ${card} cancelled.`,\n empty: \"No card\",\n};\n\nexport type KanbanProps<T> = {\n /** The columns, in order. */\n columns: readonly KanbanColumn[];\n /** The cards, in their order within each column. */\n cards: readonly T[];\n /** A card's id. */\n getId: (card: T) => string;\n /** A card's column. */\n getColumn: (card: T) => string;\n /** A card's name, for the announcements. */\n getTitle: (card: T) => string;\n /** What a card shows. */\n renderCard: (card: T) => React.ReactNode;\n /** Called when a card is dropped somewhere new: update the data here. */\n onMove: (move: KanbanMove) => void;\n /** The board's accessible name (“Moderation queue”). */\n \"aria-label\": string;\n /** The words, passed translated; English by default. */\n labels?: Partial<KanbanLabels>;\n /** Classes merged on the root. */\n className?: string;\n};\n\ntype Placement = { column: string; index: number };\n\n/**\n * A board of cards in columns — a moderation queue, a release plan: drag and drop with the pointer, or Space and the\n * arrow keys, every step announced. Column limits show in the counts.\n */\nexport function Kanban<T>({ columns, cards, getId, getColumn, getTitle, renderCard, onMove, \"aria-label\": label, labels: words, className }: KanbanProps<T>) {\n const labels = { ...kanbanLabels, ...words };\n const s = kanban();\n const instructionsId = React.useId();\n const [grab, setGrab] = React.useState<{ id: string; origin: Placement; at: Placement } | null>(null);\n const [dragging, setDragging] = React.useState<string | null>(null);\n const [over, setOver] = React.useState<Placement | null>(null);\n const [message, setMessage] = React.useState(\"\");\n const refs = React.useRef(new Map<string, HTMLLIElement>());\n const byColumn = (column: string, without?: string) => cards.filter((c) => getColumn(c) === column && getId(c) !== without);\n const titleOf = (id: string) => {\n const card = cards.find((c) => getId(c) === id);\n return card ? getTitle(card) : id;\n };\n const columnTitle = (id: string) => columns.find((c) => c.id === id)?.title ?? id;\n const placementOf = (id: string): Placement => {\n const card = cards.find((c) => getId(c) === id)!;\n const column = getColumn(card);\n return { column, index: byColumn(column).findIndex((c) => getId(c) === id) };\n };\n const commit = (id: string, from: Placement, to: Placement) => {\n if (from.column !== to.column || from.index !== to.index) onMove({ cardId: id, from: from.column, to: to.column, index: to.index });\n };\n // The cards of a column as shown, with the grabbed card at its keyboard position.\n const shown = (column: string) => {\n const list = byColumn(column, grab?.id);\n if (grab && grab.at.column === column) {\n const card = cards.find((c) => getId(c) === grab.id);\n if (card) list.splice(grab.at.index, 0, card);\n }\n return list;\n };\n React.useEffect(() => {\n if (grab) refs.current.get(grab.id)?.focus();\n }, [grab]);\n\n const onKeyDown = (event: React.KeyboardEvent, id: string) => {\n const title = titleOf(id);\n if (!grab) {\n if (event.key === \" \" || event.key === \"Enter\") {\n event.preventDefault();\n const origin = placementOf(id);\n setGrab({ id, origin, at: origin });\n setMessage(labels.grabbed(title, columnTitle(origin.column)));\n }\n return;\n }\n const { at, origin } = grab;\n const columnIndex = columns.findIndex((c) => c.id === at.column);\n let next: Placement | null = null;\n if (event.key === \"ArrowLeft\" && columnIndex > 0) next = { column: columns[columnIndex - 1]!.id, index: Math.min(at.index, byColumn(columns[columnIndex - 1]!.id, id).length) };\n if (event.key === \"ArrowRight\" && columnIndex < columns.length - 1) next = { column: columns[columnIndex + 1]!.id, index: Math.min(at.index, byColumn(columns[columnIndex + 1]!.id, id).length) };\n if (event.key === \"ArrowUp\" && at.index > 0) next = { ...at, index: at.index - 1 };\n if (event.key === \"ArrowDown\" && at.index < byColumn(at.column, id).length) next = { ...at, index: at.index + 1 };\n if (next) {\n event.preventDefault();\n setGrab({ ...grab, at: next });\n setMessage(labels.moved(title, columnTitle(next.column), next.index + 1, byColumn(next.column, id).length + 1));\n return;\n }\n if (event.key === \" \" || event.key === \"Enter\") {\n event.preventDefault();\n setGrab(null);\n commit(id, origin, at);\n setMessage(labels.dropped(title, columnTitle(at.column), at.index + 1));\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setGrab(null);\n setMessage(labels.cancelled(title));\n }\n };\n\n const indexAt = (column: string, y: number) => {\n const list = byColumn(column, dragging ?? undefined);\n const i = list.findIndex((c) => {\n const box = refs.current.get(getId(c))?.getBoundingClientRect();\n return box ? y < box.top + box.height / 2 : false;\n });\n return i === -1 ? list.length : i;\n };\n\n return (\n <div role=\"group\" aria-label={label} className={s.root({ className })}>\n <p id={instructionsId} className=\"sr-only\">\n {labels.instructions}\n </p>\n <p className=\"sr-only\" aria-live=\"assertive\">\n {message}\n </p>\n {columns.map((column) => {\n const list = shown(column.id);\n const overLimit = column.limit !== undefined && list.length > column.limit;\n return (\n <section\n key={column.id}\n aria-label={column.title}\n data-over={over?.column === column.id || undefined}\n className={s.column()}\n onDragOver={(e) => {\n if (!dragging) return;\n e.preventDefault();\n setOver({ column: column.id, index: indexAt(column.id, e.clientY) });\n }}\n onDragLeave={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOver(null);\n }}\n onDrop={(e) => {\n e.preventDefault();\n if (dragging) commit(dragging, placementOf(dragging), { column: column.id, index: indexAt(column.id, e.clientY) });\n setDragging(null);\n setOver(null);\n }}\n >\n <header className={s.header()}>\n <h3 className={s.title()}>{column.title}</h3>\n <span className={s.count()} data-over-limit={overLimit || undefined}>\n {column.limit !== undefined ? `${list.length}/${column.limit}` : list.length}\n </span>\n </header>\n <ul className={s.list()}>\n {list.length === 0 ? <li className={s.empty()}>{labels.empty}</li> : null}\n {list.map((card) => {\n const id = getId(card);\n return (\n <li\n key={id}\n ref={(el) => {\n if (el) refs.current.set(id, el);\n else refs.current.delete(id);\n }}\n tabIndex={0}\n draggable\n aria-describedby={instructionsId}\n aria-roledescription=\"card\"\n data-grabbed={grab?.id === id || undefined}\n data-dragging={dragging === id || undefined}\n className={s.card()}\n onKeyDown={(e) => onKeyDown(e, id)}\n onFocus={() => {\n // Tab to another card while one is carried: the carried one goes back.\n if (grab && grab.id !== id) {\n setMessage(labels.cancelled(titleOf(grab.id)));\n setGrab(null);\n }\n }}\n onDragStart={(e) => {\n e.dataTransfer.effectAllowed = \"move\";\n e.dataTransfer.setData(\"text/plain\", id);\n setDragging(id);\n }}\n onDragEnd={() => {\n setDragging(null);\n setOver(null);\n }}\n >\n {renderCard(card)}\n </li>\n );\n })}\n </ul>\n </section>\n );\n })}\n </div>\n );\n}\n"
170
+ "content": "// Client (the drag, the keyboard move, the announcements). Cards in columns that move by drag and drop — and by the\n// keyboard: Space picks a card up, the arrows carry it (← → between columns, ↑ ↓ within one), Space drops it, Esc puts it\n// back. Every move is announced in words. The app owns the data: `onMove` says where a card went.\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nexport const kanban = tv({\n slots: {\n root: \"flex w-full gap-3 overflow-x-auto pb-2\",\n column: \"flex w-72 shrink-0 flex-col rounded-xl border border-border-default bg-surface-1 data-[over]:border-accent data-[over]:bg-accent-soft/40\",\n header: \"flex items-center gap-2 px-3 pb-2 pt-3\",\n title: \"min-w-0 flex-1 truncate text-sm font-semibold text-fg\",\n count: \"rounded-sm bg-surface-3 px-2 text-xs font-semibold tabular-nums text-fg-secondary data-[over-limit]:bg-danger data-[over-limit]:text-on-accent\",\n list: \"m-0 flex min-h-16 list-none flex-col gap-2 p-2 pt-0\",\n card:\n \"cursor-grab rounded-lg border border-border-default bg-surface-0 p-3 text-sm text-fg shadow-xs outline-hidden transition-shadow \" +\n \"focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing data-[grabbed]:ring-2 data-[grabbed]:ring-accent data-[grabbed]:shadow-lg data-[dragging]:opacity-40\",\n empty: \"rounded-lg border border-dashed border-border-default px-3 py-4 text-center text-xs text-fg-secondary\",\n },\n});\n\nexport type KanbanColumn = {\n /** A stable id. */\n id: string;\n /** Its name — passed translated (“To review”). */\n title: string;\n /** The most cards it should hold: the count turns to danger past it. */\n limit?: number;\n};\n\nexport type KanbanMove = {\n /** The card moved. */\n cardId: string;\n /** Where it came from. */\n from: string;\n /** The column it went to. */\n to: string;\n /** Its position in that column (0 = top). */\n index: number;\n};\n\nexport type KanbanLabels = {\n /** The hint read on a card: how to move it with the keyboard. */\n instructions: string;\n /** Announced when a card is picked up: `(card, column) => …`. */\n grabbed: (card: string, column: string) => string;\n /** Announced as it moves: `(card, column, position, total) => …`. */\n moved: (card: string, column: string, position: number, total: number) => string;\n /** Announced when it is dropped. */\n dropped: (card: string, column: string, position: number) => string;\n /** Announced when the move is cancelled. */\n cancelled: (card: string) => string;\n /** A column without card (“No card”). */\n empty: string;\n};\n\nexport const kanbanLabels: KanbanLabels = {\n instructions: \"Press Space to pick it up, the arrow keys to move it, Space to drop it, Escape to cancel.\",\n grabbed: (card, column) => `${card} picked up, in ${column}.`,\n moved: (card, column, position, total) => `${card}: ${column}, position ${position} of ${total}.`,\n dropped: (card, column, position) => `${card} dropped in ${column}, position ${position}.`,\n cancelled: (card) => `Move of ${card} cancelled.`,\n empty: \"No card\",\n};\n\nexport type KanbanProps<T> = {\n /** The columns, in order. */\n columns: readonly KanbanColumn[];\n /** The cards, in their order within each column. */\n cards: readonly T[];\n /** A card's id. */\n getId: (card: T) => string;\n /** A card's column. */\n getColumn: (card: T) => string;\n /** A card's name, for the announcements. */\n getTitle: (card: T) => string;\n /** What a card shows. */\n renderCard: (card: T) => React.ReactNode;\n /** Called when a card is dropped somewhere new: update the data here. */\n onMove: (move: KanbanMove) => void;\n /** The board's accessible name (“Moderation queue”). */\n \"aria-label\": string;\n /** The words, passed translated; English by default. */\n labels?: Partial<KanbanLabels>;\n /** Classes merged on the root. */\n className?: string;\n};\n\ntype Placement = { column: string; index: number };\n\n/**\n * A board of cards in columns — a moderation queue, a release plan: drag and drop with the pointer, or Space and the\n * arrow keys, every step announced. Column limits show in the counts.\n */\nexport function Kanban<T>({ columns, cards, getId, getColumn, getTitle, renderCard, onMove, \"aria-label\": label, labels: words, className }: KanbanProps<T>) {\n const labels = { ...kanbanLabels, ...words };\n const s = kanban();\n const instructionsId = React.useId();\n const [grab, setGrab] = React.useState<{ id: string; origin: Placement; at: Placement } | null>(null);\n const [dragging, setDragging] = React.useState<string | null>(null);\n const [over, setOver] = React.useState<Placement | null>(null);\n const [message, setMessage] = React.useState(\"\");\n const refs = React.useRef(new Map<string, HTMLLIElement>());\n const byColumn = (column: string, without?: string) => cards.filter((c) => getColumn(c) === column && getId(c) !== without);\n const titleOf = (id: string) => {\n const card = cards.find((c) => getId(c) === id);\n return card ? getTitle(card) : id;\n };\n const columnTitle = (id: string) => columns.find((c) => c.id === id)?.title ?? id;\n const placementOf = (id: string): Placement => {\n const card = cards.find((c) => getId(c) === id)!;\n const column = getColumn(card);\n return { column, index: byColumn(column).findIndex((c) => getId(c) === id) };\n };\n const commit = (id: string, from: Placement, to: Placement) => {\n if (from.column !== to.column || from.index !== to.index) onMove({ cardId: id, from: from.column, to: to.column, index: to.index });\n };\n // The cards of a column as shown, with the grabbed card at its keyboard position.\n const shown = (column: string) => {\n const list = byColumn(column, grab?.id);\n if (grab && grab.at.column === column) {\n const card = cards.find((c) => getId(c) === grab.id);\n if (card) list.splice(grab.at.index, 0, card);\n }\n return list;\n };\n React.useEffect(() => {\n if (grab) refs.current.get(grab.id)?.focus();\n }, [grab]);\n\n const onKeyDown = (event: React.KeyboardEvent, id: string) => {\n const title = titleOf(id);\n if (!grab) {\n if (event.key === \" \" || event.key === \"Enter\") {\n event.preventDefault();\n const origin = placementOf(id);\n setGrab({ id, origin, at: origin });\n setMessage(labels.grabbed(title, columnTitle(origin.column)));\n }\n return;\n }\n const { at, origin } = grab;\n const columnIndex = columns.findIndex((c) => c.id === at.column);\n let next: Placement | null = null;\n if (event.key === \"ArrowLeft\" && columnIndex > 0) next = { column: columns[columnIndex - 1]!.id, index: Math.min(at.index, byColumn(columns[columnIndex - 1]!.id, id).length) };\n if (event.key === \"ArrowRight\" && columnIndex < columns.length - 1) next = { column: columns[columnIndex + 1]!.id, index: Math.min(at.index, byColumn(columns[columnIndex + 1]!.id, id).length) };\n if (event.key === \"ArrowUp\" && at.index > 0) next = { ...at, index: at.index - 1 };\n if (event.key === \"ArrowDown\" && at.index < byColumn(at.column, id).length) next = { ...at, index: at.index + 1 };\n if (next) {\n event.preventDefault();\n setGrab({ ...grab, at: next });\n setMessage(labels.moved(title, columnTitle(next.column), next.index + 1, byColumn(next.column, id).length + 1));\n return;\n }\n if (event.key === \" \" || event.key === \"Enter\") {\n event.preventDefault();\n setGrab(null);\n commit(id, origin, at);\n setMessage(labels.dropped(title, columnTitle(at.column), at.index + 1));\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setGrab(null);\n setMessage(labels.cancelled(title));\n }\n };\n\n const indexAt = (column: string, y: number) => {\n const list = byColumn(column, dragging ?? undefined);\n const i = list.findIndex((c) => {\n const box = refs.current.get(getId(c))?.getBoundingClientRect();\n return box ? y < box.top + box.height / 2 : false;\n });\n return i === -1 ? list.length : i;\n };\n\n return (\n <div role=\"group\" aria-label={label} className={s.root({ className })}>\n <p id={instructionsId} className=\"sr-only\">\n {labels.instructions}\n </p>\n <p className=\"sr-only\" aria-live=\"assertive\">\n {message}\n </p>\n {columns.map((column) => {\n const list = shown(column.id);\n const overLimit = column.limit !== undefined && list.length > column.limit;\n return (\n <section\n key={column.id}\n aria-label={column.title}\n data-over={over?.column === column.id || undefined}\n className={s.column()}\n onDragOver={(e) => {\n if (!dragging) return;\n e.preventDefault();\n setOver({ column: column.id, index: indexAt(column.id, e.clientY) });\n }}\n onDragLeave={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOver(null);\n }}\n onDrop={(e) => {\n e.preventDefault();\n if (dragging) commit(dragging, placementOf(dragging), { column: column.id, index: indexAt(column.id, e.clientY) });\n setDragging(null);\n setOver(null);\n }}\n >\n <header className={s.header()}>\n <h3 className={s.title()}>{column.title}</h3>\n <span className={s.count()} data-over-limit={overLimit || undefined}>\n {column.limit !== undefined ? `${list.length}/${column.limit}` : list.length}\n </span>\n </header>\n <ul className={s.list()}>\n {list.length === 0 ? <li className={s.empty()}>{labels.empty}</li> : null}\n {list.map((card) => {\n const id = getId(card);\n return (\n <li\n key={id}\n ref={(el) => {\n if (el) refs.current.set(id, el);\n else refs.current.delete(id);\n }}\n tabIndex={0}\n draggable\n aria-describedby={instructionsId}\n aria-roledescription=\"card\"\n data-grabbed={grab?.id === id || undefined}\n data-dragging={dragging === id || undefined}\n className={s.card()}\n onKeyDown={(e) => onKeyDown(e, id)}\n onFocus={() => {\n // Tab to another card while one is carried: the carried one goes back.\n if (grab && grab.id !== id) {\n setMessage(labels.cancelled(titleOf(grab.id)));\n setGrab(null);\n }\n }}\n onDragStart={(e) => {\n e.dataTransfer.effectAllowed = \"move\";\n e.dataTransfer.setData(\"text/plain\", id);\n setDragging(id);\n }}\n onDragEnd={() => {\n setDragging(null);\n setOver(null);\n }}\n >\n {renderCard(card)}\n </li>\n );\n })}\n </ul>\n </section>\n );\n })}\n </div>\n );\n}\n"
171
171
  },
172
172
  {
173
173
  "path": "kanban/meta.ts",
package/registry/map.json CHANGED
@@ -1042,7 +1042,7 @@
1042
1042
  },
1043
1043
  {
1044
1044
  "path": "map/location-picker.tsx",
1045
- "content": "// Client. A place chosen by moving the map under a fixed pin — a meeting point, a delivery address, the centre of a\n// search — with its distance from where the person is (a chip) and \"near me\": the browser asks for the position only when\n// the button is pressed, and every answer has its words (locating, refused, unavailable). The value is a position.\nimport * as React from \"react\";\n\nimport { tv } from \"../tv\";\nimport { distanceMeters, formatDistance, type LngLat } from \"./geo\";\nimport { Map, type MapProps, RadiusCircle, UserPosition } from \"./map\";\n\nexport const locationPicker = tv({\n slots: {\n root: \"flex flex-col gap-2\",\n frame: \"relative\",\n pin: \"pointer-events-none absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-full text-accent drop-shadow-md\",\n bar: \"absolute inset-x-3 bottom-8 z-10 flex flex-wrap items-center justify-between gap-2\",\n chip: \"rounded-full border border-border-default bg-surface-1 px-3 py-1.5 text-xs font-semibold tabular-nums text-fg shadow-floating\",\n near:\n \"inline-flex h-10 items-center gap-2 rounded-full border border-border-default bg-surface-1 bg-sheen px-4 text-sm font-semibold text-fg shadow-floating inset-shadow-edge \" +\n \"transition-colors hover:border-border-strong focus-visible:outline-hidden focus-visible:focus-ring disabled:opacity-60\",\n status: \"text-xs text-fg-secondary data-[tone=danger]:text-fg-danger\",\n },\n});\n\nexport type LocationPickerLabels = {\n /** The button that centres the map on the person (\"Near me\"). */\n nearMe: string;\n /** While the position is asked (\"Finding you…\"). */\n locating: string;\n /** The person refused (\"Location is off for this site. Move the map instead.\"). */\n denied: string;\n /** No position (no GPS, a timeout) (\"We could not find you. Move the map instead.\"). */\n unavailable: string;\n /** The person's place in the list of places (\"You are here\"). */\n you: string;\n /** The distance chip, from a written distance (\"1.2 km from you\"). */\n distance: (distance: string) => string;\n};\n\nexport const locationPickerLabels: LocationPickerLabels = {\n nearMe: \"Near me\",\n locating: \"Finding you…\",\n denied: \"Location is off for this site. Move the map instead.\",\n unavailable: \"We could not find you. Move the map instead.\",\n you: \"You are here\",\n distance: (d) => `${d} from you`,\n};\n\nexport type LocationPickerProps = Pick<MapProps, \"scheme\" | \"brand\" | \"lang\" | \"region\" | \"world\" | \"fallback\" | \"className\"> & {\n /** The picker's accessible name (\"Meeting point\"), passed translated. */\n label: string;\n /** The chosen position (controlled): the centre of the map. */\n value: LngLat;\n /** Called when the map rests on a new position. */\n onValueChange: (value: LngLat) => void;\n /** The BCP 47 locale of the distance. */\n locale: string;\n /** The zoom at first. Default 14. */\n zoom?: number;\n /** A radius in metres drawn around the chosen position (a delivery zone, a search radius). */\n radius?: number;\n /** Where the person is, when the app already knows it (the distance chip shows at once). */\n origin?: LngLat | null;\n /** The words, passed translated; English by default. */\n labels?: Partial<LocationPickerLabels>;\n};\n\ntype Status = \"idle\" | \"locating\" | \"denied\" | \"unavailable\";\n\n/** A position picked by moving the map under a pin; its distance from the person; \"near me\" on request. */\nexport function LocationPicker({ label, value, onValueChange, locale, zoom = 14, radius, origin: originProp = null, labels: given, className, ...map }: LocationPickerProps) {\n const labels = { ...locationPickerLabels, ...given };\n const s = locationPicker();\n const [origin, setOrigin] = React.useState<LngLat | null>(originProp);\n const [camera, setCamera] = React.useState<LngLat>(value);\n const [status, setStatus] = React.useState<Status>(\"idle\");\n const statusId = React.useId();\n const nearMe = () => {\n if (typeof navigator === \"undefined\" || !navigator.geolocation) return setStatus(\"unavailable\");\n setStatus(\"locating\");\n navigator.geolocation.getCurrentPosition(\n (p) => {\n const here: LngLat = [p.coords.longitude, p.coords.latitude];\n setOrigin(here);\n setCamera(here);\n onValueChange(here);\n setStatus(\"idle\");\n },\n (e) => setStatus(e.code === 1 ? \"denied\" : \"unavailable\"),\n { enableHighAccuracy: false, timeout: 10_000, maximumAge: 60_000 },\n );\n };\n const distance = origin ? formatDistance(distanceMeters(origin, value), locale) : null;\n const message = status === \"locating\" ? labels.locating : status === \"denied\" ? labels.denied : status === \"unavailable\" ? labels.unavailable : \"\";\n return (\n <div className={s.root({ className })} role=\"group\" aria-label={label}>\n <div className={s.frame()}>\n <Map {...map} label={label} center={camera} zoom={zoom} controlled onMoveEnd={(v) => onValueChange(v.center)} className=\"h-80\">\n {radius ? <RadiusCircle center={value} radius={radius} /> : null}\n {origin ? <UserPosition position={origin} label={labels.you} /> : null}\n </Map>\n <svg aria-hidden viewBox=\"0 0 24 32\" className={s.pin()} width=\"30\" height=\"40\">\n <path d=\"M12 0C5.4 0 0 5.2 0 11.7 0 20.4 12 32 12 32s12-11.6 12-20.3C24 5.2 18.6 0 12 0Z\" fill=\"currentColor\" />\n <circle cx=\"12\" cy=\"11.5\" r=\"4.5\" fill=\"var(--kz-on-accent)\" />\n </svg>\n <div className={s.bar()}>\n {distance ? (\n <span className={s.chip()} aria-live=\"polite\">\n {labels.distance(distance)}\n </span>\n ) : (\n <span />\n )}\n <button type=\"button\" className={s.near()} onClick={nearMe} disabled={status === \"locating\"} aria-describedby={message ? statusId : undefined}>\n <svg aria-hidden viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"3.5\" />\n <path d=\"M12 2v3M12 19v3M2 12h3M19 12h3\" />\n </svg>\n {status === \"locating\" ? labels.locating : labels.nearMe}\n </button>\n </div>\n </div>\n <p id={statusId} className={s.status()} data-tone={status === \"denied\" || status === \"unavailable\" ? \"danger\" : undefined} aria-live=\"polite\">\n {message}\n </p>\n </div>\n );\n}\n"
1045
+ "content": "// Client. A place chosen by moving the map under a fixed pin — a meeting point, a delivery address, the centre of a\n// search — with its distance from where the person is (a chip) and \"near me\": the browser asks for the position only when\n// the button is pressed, and every answer has its words (locating, refused, unavailable). The value is a position.\nimport * as React from \"react\";\n\nimport { tv } from \"../tv\";\nimport { distanceMeters, formatDistance, type LngLat } from \"./geo\";\nimport { Map, type MapProps, RadiusCircle, UserPosition } from \"./map\";\n\nexport const locationPicker = tv({\n slots: {\n root: \"flex flex-col gap-2\",\n frame: \"relative\",\n pin: \"pointer-events-none absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-full text-accent drop-shadow-md\",\n bar: \"absolute inset-x-3 bottom-8 z-10 flex flex-wrap items-center justify-between gap-2\",\n chip: \"rounded-sm border border-border-default bg-surface-1 px-3 py-1.5 text-xs font-semibold tabular-nums text-fg shadow-floating\",\n near:\n \"inline-flex h-10 items-center gap-2 rounded-sm border border-border-default bg-surface-1 bg-sheen px-4 text-sm font-semibold text-fg shadow-floating inset-shadow-edge \" +\n \"transition-colors hover:border-border-strong focus-visible:outline-hidden focus-visible:focus-ring disabled:opacity-60\",\n status: \"text-xs text-fg-secondary data-[tone=danger]:text-fg-danger\",\n },\n});\n\nexport type LocationPickerLabels = {\n /** The button that centres the map on the person (\"Near me\"). */\n nearMe: string;\n /** While the position is asked (\"Finding you…\"). */\n locating: string;\n /** The person refused (\"Location is off for this site. Move the map instead.\"). */\n denied: string;\n /** No position (no GPS, a timeout) (\"We could not find you. Move the map instead.\"). */\n unavailable: string;\n /** The person's place in the list of places (\"You are here\"). */\n you: string;\n /** The distance chip, from a written distance (\"1.2 km from you\"). */\n distance: (distance: string) => string;\n};\n\nexport const locationPickerLabels: LocationPickerLabels = {\n nearMe: \"Near me\",\n locating: \"Finding you…\",\n denied: \"Location is off for this site. Move the map instead.\",\n unavailable: \"We could not find you. Move the map instead.\",\n you: \"You are here\",\n distance: (d) => `${d} from you`,\n};\n\nexport type LocationPickerProps = Pick<MapProps, \"scheme\" | \"brand\" | \"lang\" | \"region\" | \"world\" | \"fallback\" | \"className\"> & {\n /** The picker's accessible name (\"Meeting point\"), passed translated. */\n label: string;\n /** The chosen position (controlled): the centre of the map. */\n value: LngLat;\n /** Called when the map rests on a new position. */\n onValueChange: (value: LngLat) => void;\n /** The BCP 47 locale of the distance. */\n locale: string;\n /** The zoom at first. Default 14. */\n zoom?: number;\n /** A radius in metres drawn around the chosen position (a delivery zone, a search radius). */\n radius?: number;\n /** Where the person is, when the app already knows it (the distance chip shows at once). */\n origin?: LngLat | null;\n /** The words, passed translated; English by default. */\n labels?: Partial<LocationPickerLabels>;\n};\n\ntype Status = \"idle\" | \"locating\" | \"denied\" | \"unavailable\";\n\n/** A position picked by moving the map under a pin; its distance from the person; \"near me\" on request. */\nexport function LocationPicker({ label, value, onValueChange, locale, zoom = 14, radius, origin: originProp = null, labels: given, className, ...map }: LocationPickerProps) {\n const labels = { ...locationPickerLabels, ...given };\n const s = locationPicker();\n const [origin, setOrigin] = React.useState<LngLat | null>(originProp);\n const [camera, setCamera] = React.useState<LngLat>(value);\n const [status, setStatus] = React.useState<Status>(\"idle\");\n const statusId = React.useId();\n const nearMe = () => {\n if (typeof navigator === \"undefined\" || !navigator.geolocation) return setStatus(\"unavailable\");\n setStatus(\"locating\");\n navigator.geolocation.getCurrentPosition(\n (p) => {\n const here: LngLat = [p.coords.longitude, p.coords.latitude];\n setOrigin(here);\n setCamera(here);\n onValueChange(here);\n setStatus(\"idle\");\n },\n (e) => setStatus(e.code === 1 ? \"denied\" : \"unavailable\"),\n { enableHighAccuracy: false, timeout: 10_000, maximumAge: 60_000 },\n );\n };\n const distance = origin ? formatDistance(distanceMeters(origin, value), locale) : null;\n const message = status === \"locating\" ? labels.locating : status === \"denied\" ? labels.denied : status === \"unavailable\" ? labels.unavailable : \"\";\n return (\n <div className={s.root({ className })} role=\"group\" aria-label={label}>\n <div className={s.frame()}>\n <Map {...map} label={label} center={camera} zoom={zoom} controlled onMoveEnd={(v) => onValueChange(v.center)} className=\"h-80\">\n {radius ? <RadiusCircle center={value} radius={radius} /> : null}\n {origin ? <UserPosition position={origin} label={labels.you} /> : null}\n </Map>\n <svg aria-hidden viewBox=\"0 0 24 32\" className={s.pin()} width=\"30\" height=\"40\">\n <path d=\"M12 0C5.4 0 0 5.2 0 11.7 0 20.4 12 32 12 32s12-11.6 12-20.3C24 5.2 18.6 0 12 0Z\" fill=\"currentColor\" />\n <circle cx=\"12\" cy=\"11.5\" r=\"4.5\" fill=\"var(--kz-on-accent)\" />\n </svg>\n <div className={s.bar()}>\n {distance ? (\n <span className={s.chip()} aria-live=\"polite\">\n {labels.distance(distance)}\n </span>\n ) : (\n <span />\n )}\n <button type=\"button\" className={s.near()} onClick={nearMe} disabled={status === \"locating\"} aria-describedby={message ? statusId : undefined}>\n <svg aria-hidden viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\">\n <circle cx=\"12\" cy=\"12\" r=\"3.5\" />\n <path d=\"M12 2v3M12 19v3M2 12h3M19 12h3\" />\n </svg>\n {status === \"locating\" ? labels.locating : labels.nearMe}\n </button>\n </div>\n </div>\n <p id={statusId} className={s.status()} data-tone={status === \"denied\" || status === \"unavailable\" ? \"danger\" : undefined} aria-live=\"polite\">\n {message}\n </p>\n </div>\n );\n}\n"
1046
1046
  },
1047
1047
  {
1048
1048
  "path": "map/map.tsx",
@@ -1074,7 +1074,7 @@
1074
1074
  }
1075
1075
  ],
1076
1076
  "dependencies": [
1077
- "@krizaka/tokens@^3.38.0",
1077
+ "@krizaka/tokens@^3.39.0",
1078
1078
  "@protomaps/basemaps@5.7.2",
1079
1079
  "tailwind-variants@^3.3.1"
1080
1080
  ],
@@ -618,7 +618,7 @@
618
618
  }
619
619
  ],
620
620
  "dependencies": [
621
- "@krizaka/tokens@^3.38.0",
621
+ "@krizaka/tokens@^3.39.0",
622
622
  "radix-ui@^1.7.0",
623
623
  "tailwind-variants@^3.3.1"
624
624
  ],
@@ -236,7 +236,7 @@
236
236
  },
237
237
  {
238
238
  "path": "photo-grid/photo-grid.tsx",
239
- "content": "// Client. A profile's photos in a grid of slots: the first is the main one; drag a photo onto another to reorder, or use\n// its menu (make main, earlier, later, remove) — the keyboard way, every move announced.\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { PlusGlyph } from \"../glyphs\";\n\nexport const photoGrid = tv({\n slots: {\n root: \"grid gap-2\",\n slot: \"relative aspect-[3/4] overflow-hidden rounded-xl\",\n photo: \"group h-full w-full cursor-grab bg-surface-3 outline-hidden focus-visible:ring-2 focus-visible:ring-ring data-[over]:ring-2 data-[over]:ring-accent active:cursor-grabbing\",\n image: \"h-full w-full object-cover\",\n main: \"absolute bottom-2 left-2 rounded-full bg-accent px-2 py-0.5 text-[10px] font-extrabold text-on-accent\",\n tools: \"absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-within:opacity-100 motion-reduce:transition-none\",\n tool: \"inline-flex h-7 w-7 items-center justify-center rounded-full bg-scrim text-fg-on-media hover:bg-scrim-strong focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n empty:\n \"flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl border-[1.5px] border-dashed border-border-strong bg-surface-1 text-xs font-semibold text-fg-secondary \" +\n \"hover:bg-surface-2 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 data-[required]:border-accent\",\n plus: \"flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 data-[next]:bg-accent data-[next]:text-on-accent [&>svg]:h-4 [&>svg]:w-4\",\n },\n});\n\nexport type GridPhoto = {\n /** A stable id. */\n id: string;\n /** Its URL. */\n src: string;\n /** What it shows — its text alternative. */\n alt?: string;\n};\n\nexport type PhotoGridLabels = {\n /** An empty slot (“Add a photo”). */\n add: string;\n /** The first photo's badge (“Main”). */\n main: string;\n /** A photo's name: `(n) => \"Photo \" + n`. */\n photo: (position: number) => string;\n /** Move earlier. */\n moveEarlier: string;\n /** Move later. */\n moveLater: string;\n /** Remove. */\n remove: string;\n /** Announced after a move: `(n) => \"Moved to place \" + n`. */\n moved: (position: number) => string;\n};\n\nexport type PhotoGridProps = {\n /** The photos, in order: the first is the main one. */\n value: readonly GridPhoto[];\n /** Called with the new order, or without a removed photo. */\n onValueChange: (photos: GridPhoto[]) => void;\n /** Called by an empty slot: open the file picker, the camera, the editor. */\n onAdd?: () => void;\n /** How many slots. Default 6. */\n slots?: number;\n /** Fewest photos required: the empty slots up to it are marked. Default 1. */\n required?: number;\n /** Columns. Default 3. */\n columns?: number;\n /** The words, passed translated. */\n labels: PhotoGridLabels;\n /** Classes merged on the root. */\n className?: string;\n};\n\n/** A profile's photos in slots: drag to reorder, or each photo's buttons (earlier, later, remove), every move announced. */\nexport function PhotoGrid({ value, onValueChange, onAdd, slots = 6, required = 1, columns = 3, labels, className }: PhotoGridProps) {\n const s = photoGrid();\n const [dragged, setDragged] = React.useState<number | null>(null);\n const [over, setOver] = React.useState<number | null>(null);\n const [message, setMessage] = React.useState(\"\");\n const move = (from: number, to: number) => {\n if (to < 0 || to >= value.length || from === to) return;\n const next = [...value];\n const [photo] = next.splice(from, 1);\n next.splice(to, 0, photo!);\n onValueChange(next);\n setMessage(labels.moved(to + 1));\n };\n return (\n <div className={s.root({ className })} style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>\n <p className=\"sr-only\" aria-live=\"polite\">\n {message}\n </p>\n {Array.from({ length: slots }, (_, i) => {\n const photo = value[i];\n if (!photo) {\n return (\n <div key={`empty-${i}`} className={s.slot()}>\n <button type=\"button\" disabled={!onAdd || i > value.length} onClick={onAdd} data-required={i < required || undefined} className={s.empty()}>\n <span className={s.plus()} data-next={i === value.length || undefined}>\n <PlusGlyph />\n </span>\n {i === value.length ? labels.add : <span className=\"sr-only\">{labels.add}</span>}\n </button>\n </div>\n );\n }\n const name = [labels.photo(i + 1), photo.alt, i === 0 ? labels.main : null].filter(Boolean).join(\", \");\n return (\n <div key={photo.id} className={s.slot()}>\n <div\n role=\"group\"\n aria-label={name}\n draggable\n tabIndex={0}\n data-over={over === i || undefined}\n className={s.photo()}\n onDragStart={(e) => {\n e.dataTransfer.effectAllowed = \"move\";\n setDragged(i);\n }}\n onDragOver={(e) => {\n if (dragged === null) return;\n e.preventDefault();\n setOver(i);\n }}\n onDragLeave={() => setOver(null)}\n onDrop={(e) => {\n e.preventDefault();\n if (dragged !== null) move(dragged, i);\n setDragged(null);\n setOver(null);\n }}\n onDragEnd={() => {\n setDragged(null);\n setOver(null);\n }}\n >\n <img src={photo.src} alt={photo.alt ?? \"\"} className={s.image()} draggable={false} />\n {i === 0 ? <span className={s.main()} aria-hidden>{labels.main}</span> : null}\n <div className={s.tools()}>\n {i > 0 ? (\n <button type=\"button\" aria-label={`${labels.moveEarlier} — ${labels.photo(i + 1)}`} className={s.tool()} onClick={() => move(i, i - 1)}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" aria-hidden><path d=\"M15 5l-7 7 7 7\" /></svg>\n </button>\n ) : null}\n {i < value.length - 1 ? (\n <button type=\"button\" aria-label={`${labels.moveLater} — ${labels.photo(i + 1)}`} className={s.tool()} onClick={() => move(i, i + 1)}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" aria-hidden><path d=\"M9 5l7 7-7 7\" /></svg>\n </button>\n ) : null}\n <button type=\"button\" aria-label={`${labels.remove} — ${labels.photo(i + 1)}`} className={s.tool()} onClick={() => onValueChange(value.filter((_, j) => j !== i))}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" aria-hidden><path d=\"M18 6 6 18M6 6l12 12\" /></svg>\n </button>\n </div>\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n"
239
+ "content": "// Client. A profile's photos in a grid of slots: the first is the main one; drag a photo onto another to reorder, or use\n// its menu (make main, earlier, later, remove) — the keyboard way, every move announced.\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { PlusGlyph } from \"../glyphs\";\n\nexport const photoGrid = tv({\n slots: {\n root: \"grid gap-2\",\n slot: \"relative aspect-[3/4] overflow-hidden rounded-xl\",\n photo: \"group h-full w-full cursor-grab bg-surface-3 outline-hidden focus-visible:ring-2 focus-visible:ring-ring data-[over]:ring-2 data-[over]:ring-accent active:cursor-grabbing\",\n image: \"h-full w-full object-cover\",\n main: \"absolute bottom-2 left-2 rounded-sm bg-accent px-2 py-0.5 text-[10px] font-extrabold text-on-accent\",\n tools: \"absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-within:opacity-100 motion-reduce:transition-none\",\n tool: \"inline-flex h-7 w-7 items-center justify-center rounded-full bg-scrim text-fg-on-media hover:bg-scrim-strong focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\",\n empty:\n \"flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl border-[1.5px] border-dashed border-border-strong bg-surface-1 text-xs font-semibold text-fg-secondary \" +\n \"hover:bg-surface-2 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 data-[required]:border-accent\",\n plus: \"flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 data-[next]:bg-accent data-[next]:text-on-accent [&>svg]:h-4 [&>svg]:w-4\",\n },\n});\n\nexport type GridPhoto = {\n /** A stable id. */\n id: string;\n /** Its URL. */\n src: string;\n /** What it shows — its text alternative. */\n alt?: string;\n};\n\nexport type PhotoGridLabels = {\n /** An empty slot (“Add a photo”). */\n add: string;\n /** The first photo's badge (“Main”). */\n main: string;\n /** A photo's name: `(n) => \"Photo \" + n`. */\n photo: (position: number) => string;\n /** Move earlier. */\n moveEarlier: string;\n /** Move later. */\n moveLater: string;\n /** Remove. */\n remove: string;\n /** Announced after a move: `(n) => \"Moved to place \" + n`. */\n moved: (position: number) => string;\n};\n\nexport type PhotoGridProps = {\n /** The photos, in order: the first is the main one. */\n value: readonly GridPhoto[];\n /** Called with the new order, or without a removed photo. */\n onValueChange: (photos: GridPhoto[]) => void;\n /** Called by an empty slot: open the file picker, the camera, the editor. */\n onAdd?: () => void;\n /** How many slots. Default 6. */\n slots?: number;\n /** Fewest photos required: the empty slots up to it are marked. Default 1. */\n required?: number;\n /** Columns. Default 3. */\n columns?: number;\n /** The words, passed translated. */\n labels: PhotoGridLabels;\n /** Classes merged on the root. */\n className?: string;\n};\n\n/** A profile's photos in slots: drag to reorder, or each photo's buttons (earlier, later, remove), every move announced. */\nexport function PhotoGrid({ value, onValueChange, onAdd, slots = 6, required = 1, columns = 3, labels, className }: PhotoGridProps) {\n const s = photoGrid();\n const [dragged, setDragged] = React.useState<number | null>(null);\n const [over, setOver] = React.useState<number | null>(null);\n const [message, setMessage] = React.useState(\"\");\n const move = (from: number, to: number) => {\n if (to < 0 || to >= value.length || from === to) return;\n const next = [...value];\n const [photo] = next.splice(from, 1);\n next.splice(to, 0, photo!);\n onValueChange(next);\n setMessage(labels.moved(to + 1));\n };\n return (\n <div className={s.root({ className })} style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>\n <p className=\"sr-only\" aria-live=\"polite\">\n {message}\n </p>\n {Array.from({ length: slots }, (_, i) => {\n const photo = value[i];\n if (!photo) {\n return (\n <div key={`empty-${i}`} className={s.slot()}>\n <button type=\"button\" disabled={!onAdd || i > value.length} onClick={onAdd} data-required={i < required || undefined} className={s.empty()}>\n <span className={s.plus()} data-next={i === value.length || undefined}>\n <PlusGlyph />\n </span>\n {i === value.length ? labels.add : <span className=\"sr-only\">{labels.add}</span>}\n </button>\n </div>\n );\n }\n const name = [labels.photo(i + 1), photo.alt, i === 0 ? labels.main : null].filter(Boolean).join(\", \");\n return (\n <div key={photo.id} className={s.slot()}>\n <div\n role=\"group\"\n aria-label={name}\n draggable\n tabIndex={0}\n data-over={over === i || undefined}\n className={s.photo()}\n onDragStart={(e) => {\n e.dataTransfer.effectAllowed = \"move\";\n setDragged(i);\n }}\n onDragOver={(e) => {\n if (dragged === null) return;\n e.preventDefault();\n setOver(i);\n }}\n onDragLeave={() => setOver(null)}\n onDrop={(e) => {\n e.preventDefault();\n if (dragged !== null) move(dragged, i);\n setDragged(null);\n setOver(null);\n }}\n onDragEnd={() => {\n setDragged(null);\n setOver(null);\n }}\n >\n <img src={photo.src} alt={photo.alt ?? \"\"} className={s.image()} draggable={false} />\n {i === 0 ? <span className={s.main()} aria-hidden>{labels.main}</span> : null}\n <div className={s.tools()}>\n {i > 0 ? (\n <button type=\"button\" aria-label={`${labels.moveEarlier} — ${labels.photo(i + 1)}`} className={s.tool()} onClick={() => move(i, i - 1)}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" aria-hidden><path d=\"M15 5l-7 7 7 7\" /></svg>\n </button>\n ) : null}\n {i < value.length - 1 ? (\n <button type=\"button\" aria-label={`${labels.moveLater} — ${labels.photo(i + 1)}`} className={s.tool()} onClick={() => move(i, i + 1)}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" aria-hidden><path d=\"M9 5l7 7-7 7\" /></svg>\n </button>\n ) : null}\n <button type=\"button\" aria-label={`${labels.remove} — ${labels.photo(i + 1)}`} className={s.tool()} onClick={() => onValueChange(value.filter((_, j) => j !== i))}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" aria-hidden><path d=\"M18 6 6 18M6 6l12 12\" /></svg>\n </button>\n </div>\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n"
240
240
  }
241
241
  ],
242
242
  "dependencies": [
@@ -342,7 +342,7 @@
342
342
  }
343
343
  ],
344
344
  "dependencies": [
345
- "@krizaka/tokens@^3.38.0",
345
+ "@krizaka/tokens@^3.39.0",
346
346
  "radix-ui@^1.7.0",
347
347
  "tailwind-variants@^3.3.1"
348
348
  ],