@krizaka/ui 3.26.0 → 3.28.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 (189) hide show
  1. package/README.md +1 -0
  2. package/dist/chip-link.js +4 -27
  3. package/dist/chip-link.js.map +1 -1
  4. package/dist/chip-row.d.ts +78 -0
  5. package/dist/chip-row.js +61 -0
  6. package/dist/chip-row.js.map +1 -0
  7. package/dist/chunk-BTTDIVVJ.js +33 -0
  8. package/dist/chunk-BTTDIVVJ.js.map +1 -0
  9. package/dist/{chunk-BCGJZ5PG.js → chunk-OUWPBSSL.js} +2 -2
  10. package/dist/{chunk-4DH2HW3D.js → chunk-TFJDIOFK.js} +2 -1
  11. package/dist/document-viewer.js +19 -3
  12. package/dist/document-viewer.js.map +1 -1
  13. package/dist/image-cropper.js +8 -4
  14. package/dist/image-cropper.js.map +1 -1
  15. package/dist/index.d.ts +3 -8
  16. package/dist/index.js +44 -48
  17. package/dist/index.js.map +1 -1
  18. package/dist/language-switch/server.d.ts +17 -0
  19. package/dist/language-switch/server.js +26 -0
  20. package/dist/language-switch/server.js.map +1 -0
  21. package/dist/language-switch.js +4 -2
  22. package/dist/language-switch.js.map +1 -1
  23. package/dist/native.d.ts +32 -1
  24. package/dist/native.js +344 -286
  25. package/dist/native.js.map +1 -1
  26. package/dist/nav-bar.d.ts +12 -2
  27. package/dist/nav-bar.js +10 -3
  28. package/dist/nav-bar.js.map +1 -1
  29. package/dist/site-footer.js +2 -2
  30. package/package.json +31 -7
  31. package/registry/account-menu.json +1 -1
  32. package/registry/achievement.json +1 -1
  33. package/registry/action-bar.json +1 -1
  34. package/registry/activity-feed.json +1 -1
  35. package/registry/alert.json +1 -1
  36. package/registry/app-header.json +1 -1
  37. package/registry/app-shell.json +1 -1
  38. package/registry/argument-card.json +1 -1
  39. package/registry/audio-player.json +1 -1
  40. package/registry/auth-layout.json +1 -1
  41. package/registry/avatar.json +1 -1
  42. package/registry/badge.json +1 -1
  43. package/registry/button.json +1 -1
  44. package/registry/captcha.json +1 -1
  45. package/registry/card-grid.json +1 -1
  46. package/registry/card.json +1 -1
  47. package/registry/catalogue-page.json +1 -1
  48. package/registry/changelog-page.json +1 -1
  49. package/registry/chat.json +1 -1
  50. package/registry/checkbox.json +1 -1
  51. package/registry/chip-link.json +1 -1
  52. package/registry/chip-row.json +173 -0
  53. package/registry/chip.json +1 -1
  54. package/registry/choice-grid.json +1 -1
  55. package/registry/code-block.json +1 -1
  56. package/registry/combobox.json +1 -1
  57. package/registry/command.json +1 -1
  58. package/registry/comparison-table.json +1 -1
  59. package/registry/component-page.json +1 -1
  60. package/registry/confirm-button.json +1 -1
  61. package/registry/contact-page.json +1 -1
  62. package/registry/continue-prompt.json +1 -1
  63. package/registry/countdown.json +1 -1
  64. package/registry/cover-hero.json +1 -1
  65. package/registry/cta-panel.json +1 -1
  66. package/registry/dashboard-layout.json +1 -1
  67. package/registry/data-table.json +1 -1
  68. package/registry/date-picker.json +1 -1
  69. package/registry/decision-list.json +1 -1
  70. package/registry/description-list.json +1 -1
  71. package/registry/detail-layout.json +1 -1
  72. package/registry/dialog.json +1 -1
  73. package/registry/docs-article-page.json +1 -1
  74. package/registry/docs-hub-page.json +1 -1
  75. package/registry/docs-layout.json +1 -1
  76. package/registry/docs-nav.json +1 -1
  77. package/registry/document-viewer.json +2 -2
  78. package/registry/dropdown-menu.json +1 -1
  79. package/registry/empty-state.json +1 -1
  80. package/registry/error-page.json +1 -1
  81. package/registry/examples/chip-row/audience.d.ts +4 -0
  82. package/registry/examples/chip-row/audience.js +15 -0
  83. package/registry/examples/chip-row/audience.tsx +16 -0
  84. package/registry/examples/chip-row/many.d.ts +4 -0
  85. package/registry/examples/chip-row/many.js +12 -0
  86. package/registry/examples/chip-row/many.tsx +16 -0
  87. package/registry/examples/settings-layout/native/account.d.ts +4 -0
  88. package/registry/examples/settings-layout/native/account.dark.png +0 -0
  89. package/registry/examples/settings-layout/native/account.js +22 -0
  90. package/registry/examples/settings-layout/native/account.light.png +0 -0
  91. package/registry/examples/settings-layout/native/account.tsx +18 -0
  92. package/registry/field.json +1 -1
  93. package/registry/file-upload.json +1 -1
  94. package/registry/filter-bar.json +1 -1
  95. package/registry/force-update-screen.json +1 -1
  96. package/registry/form.json +1 -1
  97. package/registry/gallery-page.json +1 -1
  98. package/registry/genui.json +1 -1
  99. package/registry/glow-card.json +1 -1
  100. package/registry/goal-gauge.json +1 -1
  101. package/registry/how-it-works-page.json +1 -1
  102. package/registry/image-cropper.json +2 -2
  103. package/registry/index.json +42 -10
  104. package/registry/iso.json +1 -1
  105. package/registry/kanban.json +1 -1
  106. package/registry/kbd.json +1 -1
  107. package/registry/landing-page.json +1 -1
  108. package/registry/landscape.json +1 -1
  109. package/registry/language-switch.json +6 -2
  110. package/registry/legal-page.json +1 -1
  111. package/registry/link-card.json +1 -1
  112. package/registry/list.json +1 -1
  113. package/registry/loader.json +1 -1
  114. package/registry/longform-page.json +1 -1
  115. package/registry/maintenance-screen.json +1 -1
  116. package/registry/map.json +2 -2
  117. package/registry/markdown.json +1 -1
  118. package/registry/marketing-layout.json +1 -1
  119. package/registry/marquee.json +1 -1
  120. package/registry/mascot-quote.json +1 -1
  121. package/registry/mascot-state.json +1 -1
  122. package/registry/mascots.json +1 -1
  123. package/registry/match-dialog.json +1 -1
  124. package/registry/media-capture.json +1 -1
  125. package/registry/media-gallery.json +1 -1
  126. package/registry/media-grid.json +1 -1
  127. package/registry/meta-row.json +1 -1
  128. package/registry/meter.json +1 -1
  129. package/registry/nav-bar.json +21 -6
  130. package/registry/number-ticker.json +1 -1
  131. package/registry/opening-hours.json +1 -1
  132. package/registry/page-header.json +1 -1
  133. package/registry/page-hero.json +1 -1
  134. package/registry/page.json +1 -1
  135. package/registry/pagination.json +1 -1
  136. package/registry/photo-grid.json +1 -1
  137. package/registry/pointer-light.json +1 -1
  138. package/registry/popover.json +1 -1
  139. package/registry/pricing-page.json +1 -1
  140. package/registry/product-page.json +1 -1
  141. package/registry/profile-card.json +1 -1
  142. package/registry/progress.json +1 -1
  143. package/registry/promise-card.json +1 -1
  144. package/registry/radio-group.json +1 -1
  145. package/registry/rail-nav.json +2 -2
  146. package/registry/recipes.json +1 -1
  147. package/registry/scroll-stage.json +1 -1
  148. package/registry/search-field.json +1 -1
  149. package/registry/section-backdrop.json +1 -1
  150. package/registry/separator.json +1 -1
  151. package/registry/service-card.json +1 -1
  152. package/registry/settings-layout.json +110 -7
  153. package/registry/showcase.json +1 -1
  154. package/registry/site-footer.json +2 -2
  155. package/registry/sites.json +1 -1
  156. package/registry/skeleton.json +1 -1
  157. package/registry/skip-link.json +1 -1
  158. package/registry/slider.json +1 -1
  159. package/registry/slot-picker.json +1 -1
  160. package/registry/slot.json +1 -1
  161. package/registry/spinner.json +1 -1
  162. package/registry/split-view.json +1 -1
  163. package/registry/stage-banner.json +1 -1
  164. package/registry/stat.json +1 -1
  165. package/registry/status-page.json +1 -1
  166. package/registry/step-progress.json +1 -1
  167. package/registry/step-showcase.json +1 -1
  168. package/registry/stepper.json +1 -1
  169. package/registry/sticky-action.json +1 -1
  170. package/registry/swipe-deck.json +1 -1
  171. package/registry/switch.json +1 -1
  172. package/registry/tabs.json +1 -1
  173. package/registry/tag-input.json +1 -1
  174. package/registry/tag-selector.json +1 -1
  175. package/registry/terminal-replay.json +1 -1
  176. package/registry/theme.json +1 -1
  177. package/registry/toast.json +1 -1
  178. package/registry/toc.json +1 -1
  179. package/registry/tooltip.json +1 -1
  180. package/registry/tree-view.json +1 -1
  181. package/registry/update-prompt.json +1 -1
  182. package/registry/verified-badge.json +1 -1
  183. package/registry/vertical-video.json +1 -1
  184. package/registry/video-player.json +1 -1
  185. package/registry/video-trimmer.json +1 -1
  186. package/registry/virtual.json +1 -1
  187. package/registry/waveform.json +1 -1
  188. /package/dist/{chunk-BCGJZ5PG.js.map → chunk-OUWPBSSL.js.map} +0 -0
  189. /package/dist/{chunk-4DH2HW3D.js.map → chunk-TFJDIOFK.js.map} +0 -0
package/README.md CHANGED
@@ -153,6 +153,7 @@ light or a product's — is a set of tokens.
153
153
  | `@krizaka/ui/site-footer` | `SiteFooter` (`current`, `labels`, `version`, `env`, `legal`; own columns as children) `.Column` (`title`) `.Link` (`active`, `asChild`): the footer of every Krizaka site — the family in columns, “A Krizaka company / product / open-source project”, the version, Montréal | client |
154
154
  | `@krizaka/ui/chip` | `Chip` (`selected`/`defaultSelected` + `onSelectedChange`, `size` sm·md; `removable` + `removeLabel` + `onRemove`), `Chip.Group` (`type` single·multiple, `label`, `required` for single), `chip` (the slots) | client |
155
155
  | `@krizaka/ui/chip-link` | `ChipLink` (a filter that is a link: `selected`, `current`, `size`, `asChild`), `chipVariants` (`size`, `selected`) | server |
156
+ | `@krizaka/ui/chip-row` | `ChipRow`, `ChipRow.Item` (a scroll-snap row of chip links, wraps from `md`: `selected`, `badge`, `asChild`) | client (arrow keys) |
156
157
  | `@krizaka/ui/meta-row` | `MetaRow` (`items`, `chips`, `size` sm·md): one quiet line of dot-separated facts with status chips at its end; a real list, dots hidden from assistive technology; `/native` has the same | server |
157
158
  | `@krizaka/ui/switch` | `Switch` (`checked`/`defaultChecked` + `onCheckedChange`, `label` unless a visible label names it, `size` sm·md, `invalid`), `switchVariants` | client |
158
159
  | `@krizaka/ui/slider` | `Slider` (`value`: a number or a `[min, max]` range, `onValueChange`, `onValueCommit`, `label`, `thumbLabels`, `formatValue` → aria-valuetext, `showLabel`, `origin`), `slider` (the slots) | client |
package/dist/chip-link.js CHANGED
@@ -1,30 +1,7 @@
1
- // src/chip-link/chip-link.tsx
2
- import { Slot } from "radix-ui";
3
- import { tv } from "tailwind-variants";
4
- import { jsx } from "react/jsx-runtime";
5
- var chipVariants = tv({
6
- base: "inline-flex shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-full border font-semibold no-underline transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
7
- variants: {
8
- size: { sm: "h-7 px-3 text-xs", md: "h-9 px-3.5 text-xs" },
9
- selected: {
10
- false: "border-border-default bg-surface-1 text-fg-secondary hover:border-border-strong hover:text-fg",
11
- true: "border-accent bg-accent-soft text-fg"
12
- }
13
- },
14
- defaultVariants: { size: "md", selected: false }
15
- });
16
- function ChipLink({ size, selected = false, current = "page", asChild, className, ...props }) {
17
- const Comp = asChild ? Slot.Root : "a";
18
- return /* @__PURE__ */ jsx(
19
- Comp,
20
- {
21
- "data-state": selected ? "on" : "off",
22
- "aria-current": selected ? current : void 0,
23
- className: chipVariants({ size, selected, className }),
24
- ...props
25
- }
26
- );
27
- }
1
+ import {
2
+ ChipLink,
3
+ chipVariants
4
+ } from "./chunk-BTTDIVVJ.js";
28
5
  export {
29
6
  ChipLink,
30
7
  chipVariants
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/chip-link/chip-link.tsx"],"sourcesContent":["// 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"],"mappings":";AAGA,SAAS,YAAY;AAErB,SAAS,UAA6B;AAuClC;AArCG,IAAM,eAAe,GAAG;AAAA,EAC7B,MACE;AAAA,EAEF,UAAU;AAAA,IACR,MAAM,EAAE,IAAI,oBAAoB,IAAI,qBAAqB;AAAA,IACzD,UAAU;AAAA,MACR,OAAO;AAAA,MACP,MAAM;AAAA,IACR;AAAA,EACF;AAAA,EACA,iBAAiB,EAAE,MAAM,MAAM,UAAU,MAAM;AACjD,CAAC;AAsBM,SAAS,SAAS,EAAE,MAAM,WAAW,OAAO,UAAU,QAAQ,SAAS,WAAW,GAAG,MAAM,GAAkB;AAClH,QAAM,OAAO,UAAU,KAAK,OAAO;AACnC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,WAAW,OAAO;AAAA,MAC9B,gBAAc,WAAW,UAAU;AAAA,MACnC,WAAW,aAAa,EAAE,MAAM,UAAU,UAAU,CAAC;AAAA,MACpD,GAAG;AAAA;AAAA,EACN;AAEJ;","names":[]}
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,78 @@
1
+ import * as tailwind_variants from 'tailwind-variants';
2
+ import * as React from 'react';
3
+
4
+ declare const chipRow: tailwind_variants.TVReturnType<{
5
+ [key: string]: {
6
+ [key: string]: tailwind_variants.ClassValue | {
7
+ list?: tailwind_variants.ClassValue;
8
+ hint?: tailwind_variants.ClassValue;
9
+ root?: tailwind_variants.ClassValue;
10
+ item?: tailwind_variants.ClassValue;
11
+ badge?: tailwind_variants.ClassValue;
12
+ };
13
+ };
14
+ } | {
15
+ [x: string]: {
16
+ [x: string]: tailwind_variants.ClassValue | {
17
+ list?: tailwind_variants.ClassValue;
18
+ hint?: tailwind_variants.ClassValue;
19
+ root?: tailwind_variants.ClassValue;
20
+ item?: tailwind_variants.ClassValue;
21
+ badge?: tailwind_variants.ClassValue;
22
+ };
23
+ };
24
+ } | {}, {
25
+ root: string;
26
+ hint: string;
27
+ list: string;
28
+ item: string;
29
+ badge: string;
30
+ }, undefined, {
31
+ [key: string]: {
32
+ [key: string]: tailwind_variants.ClassValue | {
33
+ list?: tailwind_variants.ClassValue;
34
+ hint?: tailwind_variants.ClassValue;
35
+ root?: tailwind_variants.ClassValue;
36
+ item?: tailwind_variants.ClassValue;
37
+ badge?: tailwind_variants.ClassValue;
38
+ };
39
+ };
40
+ } | {}, {
41
+ root: string;
42
+ hint: string;
43
+ list: string;
44
+ item: string;
45
+ badge: string;
46
+ }, tailwind_variants.TVReturnTypeLike<unknown, {
47
+ root: string;
48
+ hint: string;
49
+ list: string;
50
+ item: string;
51
+ badge: string;
52
+ }>>;
53
+ type ChipRowProps = Omit<React.ComponentProps<"nav">, "aria-label"> & {
54
+ /** What the links choose among ("For whom?"): the row's accessible name, passed translated. */
55
+ label: string;
56
+ /** A line under the label that says how to choose; read with the row. */
57
+ hint?: React.ReactNode;
58
+ };
59
+ /** A scroll-snap row of chip links; wraps from `md`. Items: `ChipRow.Item`. */
60
+ declare function ChipRowRoot({ label, hint, className, children, ...props }: ChipRowProps): React.JSX.Element;
61
+ type ChipRowItemProps = React.ComponentProps<"a"> & {
62
+ /** The current choice: the accent and `aria-current`. */
63
+ selected?: boolean;
64
+ /** What `aria-current` says when selected.
65
+ * @default "page" */
66
+ current?: "page" | "true";
67
+ /** A small tag after the label ("Soon"), passed translated. */
68
+ badge?: React.ReactNode;
69
+ /** Renders your router's `<Link>` as the chip; put the label and the badge in your link's children yourself. */
70
+ asChild?: boolean;
71
+ };
72
+ /** One link of the row. */
73
+ declare function ChipRowItem({ selected, current, badge, asChild, className, children, ...props }: ChipRowItemProps): React.JSX.Element;
74
+ declare const ChipRow: typeof ChipRowRoot & {
75
+ Item: typeof ChipRowItem;
76
+ };
77
+
78
+ export { ChipRow, type ChipRowItemProps, type ChipRowProps, chipRow };
@@ -0,0 +1,61 @@
1
+ import {
2
+ chipVariants
3
+ } from "./chunk-BTTDIVVJ.js";
4
+
5
+ // src/chip-row/chip-row.tsx
6
+ import { Slot } from "radix-ui";
7
+ import * as React from "react";
8
+ import { tv } from "tailwind-variants";
9
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
+ var chipRow = tv({
11
+ slots: {
12
+ root: "flex w-full flex-col gap-2",
13
+ hint: "text-xs text-fg-muted",
14
+ list: "m-0 flex list-none snap-x snap-proximity gap-2 overflow-x-auto p-0 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [mask-image:linear-gradient(to_right,#000_calc(100%-2rem),transparent)] md:flex-wrap md:overflow-visible md:pb-0 md:[mask-image:none]",
15
+ item: "snap-start",
16
+ badge: "rounded-full bg-surface-3 px-1.5 py-0.5 text-[0.625rem] font-semibold uppercase leading-none tracking-wide text-fg-muted"
17
+ }
18
+ });
19
+ var s = chipRow();
20
+ function ChipRowRoot({ label, hint, className, children, ...props }) {
21
+ const id = React.useId();
22
+ const list = React.useRef(null);
23
+ const move = (event) => {
24
+ const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
25
+ if (!keys.includes(event.key)) return;
26
+ const links = [...list.current?.querySelectorAll("a[href],button:not([disabled])") ?? []];
27
+ const at = links.indexOf(event.target);
28
+ if (at < 0) return;
29
+ const last = links.length - 1;
30
+ const to = event.key === "Home" ? 0 : event.key === "End" ? last : Math.min(last, Math.max(0, at + (event.key === "ArrowRight" ? 1 : -1)));
31
+ event.preventDefault();
32
+ links[to]?.focus();
33
+ links[to]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
34
+ };
35
+ return /* @__PURE__ */ jsxs("nav", { "aria-label": label, "aria-describedby": hint ? `${id}-hint` : void 0, className: s.root({ className }), ...props, children: [
36
+ hint ? /* @__PURE__ */ jsx("p", { id: `${id}-hint`, className: s.hint(), children: hint }) : null,
37
+ /* @__PURE__ */ jsx("ul", { ref: list, className: s.list(), onKeyDown: move, children })
38
+ ] });
39
+ }
40
+ function ChipRowItem({ selected = false, current = "page", badge, asChild, className, children, ...props }) {
41
+ const Comp = asChild ? Slot.Root : "a";
42
+ return /* @__PURE__ */ jsx("li", { className: s.item(), children: /* @__PURE__ */ jsx(
43
+ Comp,
44
+ {
45
+ "data-state": selected ? "on" : "off",
46
+ "aria-current": selected ? current : void 0,
47
+ className: chipVariants({ selected, className: ["min-h-11", className] }),
48
+ ...props,
49
+ children: asChild ? children : /* @__PURE__ */ jsxs(Fragment, { children: [
50
+ children,
51
+ badge ? /* @__PURE__ */ jsx("span", { className: s.badge(), children: badge }) : null
52
+ ] })
53
+ }
54
+ ) });
55
+ }
56
+ var ChipRow = Object.assign(ChipRowRoot, { Item: ChipRowItem });
57
+ export {
58
+ ChipRow,
59
+ chipRow
60
+ };
61
+ //# sourceMappingURL=chip-row.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/chip-row/chip-row.tsx"],"sourcesContent":["// Client (arrow-key focus only). A row of chip links that scrolls sideways with scroll-snap on a phone (the trailing edge\n// fades, no scrollbar) and wraps from `md`: an audience picker, the filters of a page. Each item is a `ChipLink`-style\n// link at least 44 px high; the current one carries `aria-current`. Tab goes through the links; ← → Home End move between them.\nimport { Slot } from \"radix-ui\";\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { chipVariants } from \"../chip-link\";\n\nexport const chipRow = tv({\n slots: {\n root: \"flex w-full flex-col gap-2\",\n hint: \"text-xs text-fg-muted\",\n list:\n \"m-0 flex list-none snap-x snap-proximity gap-2 overflow-x-auto p-0 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden \" +\n \"[mask-image:linear-gradient(to_right,#000_calc(100%-2rem),transparent)] md:flex-wrap md:overflow-visible md:pb-0 md:[mask-image:none]\",\n item: \"snap-start\",\n badge: \"rounded-full bg-surface-3 px-1.5 py-0.5 text-[0.625rem] font-semibold uppercase leading-none tracking-wide text-fg-muted\",\n },\n});\n\nconst s = chipRow();\n\nexport type ChipRowProps = Omit<React.ComponentProps<\"nav\">, \"aria-label\"> & {\n /** What the links choose among (\"For whom?\"): the row's accessible name, passed translated. */\n label: string;\n /** A line under the label that says how to choose; read with the row. */\n hint?: React.ReactNode;\n};\n\n/** A scroll-snap row of chip links; wraps from `md`. Items: `ChipRow.Item`. */\nfunction ChipRowRoot({ label, hint, className, children, ...props }: ChipRowProps) {\n const id = React.useId();\n const list = React.useRef<HTMLUListElement>(null);\n const move = (event: React.KeyboardEvent<HTMLUListElement>) => {\n const keys = [\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"];\n if (!keys.includes(event.key)) return;\n const links = [...(list.current?.querySelectorAll<HTMLElement>(\"a[href],button:not([disabled])\") ?? [])];\n const at = links.indexOf(event.target as HTMLElement);\n if (at < 0) return;\n const last = links.length - 1;\n const to = event.key === \"Home\" ? 0 : event.key === \"End\" ? last : Math.min(last, Math.max(0, at + (event.key === \"ArrowRight\" ? 1 : -1)));\n event.preventDefault();\n links[to]?.focus();\n links[to]?.scrollIntoView?.({ block: \"nearest\", inline: \"nearest\" });\n };\n return (\n <nav aria-label={label} aria-describedby={hint ? `${id}-hint` : undefined} className={s.root({ className })} {...props}>\n {hint ? (\n <p id={`${id}-hint`} className={s.hint()}>\n {hint}\n </p>\n ) : null}\n <ul ref={list} className={s.list()} onKeyDown={move}>\n {children}\n </ul>\n </nav>\n );\n}\n\nexport type ChipRowItemProps = React.ComponentProps<\"a\"> & {\n /** The current choice: the accent and `aria-current`. */\n selected?: boolean;\n /** What `aria-current` says when selected.\n * @default \"page\" */\n current?: \"page\" | \"true\";\n /** A small tag after the label (\"Soon\"), passed translated. */\n badge?: React.ReactNode;\n /** Renders your router's `<Link>` as the chip; put the label and the badge in your link's children yourself. */\n asChild?: boolean;\n};\n\n/** One link of the row. */\nfunction ChipRowItem({ selected = false, current = \"page\", badge, asChild, className, children, ...props }: ChipRowItemProps) {\n const Comp = asChild ? Slot.Root : \"a\";\n return (\n <li className={s.item()}>\n <Comp\n data-state={selected ? \"on\" : \"off\"}\n aria-current={selected ? current : undefined}\n className={chipVariants({ selected, className: [\"min-h-11\", className] })}\n {...props}\n >\n {asChild ? (\n children\n ) : (\n <>\n {children}\n {badge ? <span className={s.badge()}>{badge}</span> : null}\n </>\n )}\n </Comp>\n </li>\n );\n}\n\nexport const ChipRow = Object.assign(ChipRowRoot, { Item: ChipRowItem });\n"],"mappings":";;;;;AAGA,SAAS,YAAY;AACrB,YAAY,WAAW;AACvB,SAAS,UAAU;AA0Cf,SAuCM,UArCF,KAFJ;AAtCG,IAAM,UAAU,GAAG;AAAA,EACxB,OAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM;AAAA,IACN,MACE;AAAA,IAEF,MAAM;AAAA,IACN,OAAO;AAAA,EACT;AACF,CAAC;AAED,IAAM,IAAI,QAAQ;AAUlB,SAAS,YAAY,EAAE,OAAO,MAAM,WAAW,UAAU,GAAG,MAAM,GAAiB;AACjF,QAAM,KAAW,YAAM;AACvB,QAAM,OAAa,aAAyB,IAAI;AAChD,QAAM,OAAO,CAAC,UAAiD;AAC7D,UAAM,OAAO,CAAC,aAAa,cAAc,QAAQ,KAAK;AACtD,QAAI,CAAC,KAAK,SAAS,MAAM,GAAG,EAAG;AAC/B,UAAM,QAAQ,CAAC,GAAI,KAAK,SAAS,iBAA8B,gCAAgC,KAAK,CAAC,CAAE;AACvG,UAAM,KAAK,MAAM,QAAQ,MAAM,MAAqB;AACpD,QAAI,KAAK,EAAG;AACZ,UAAM,OAAO,MAAM,SAAS;AAC5B,UAAM,KAAK,MAAM,QAAQ,SAAS,IAAI,MAAM,QAAQ,QAAQ,OAAO,KAAK,IAAI,MAAM,KAAK,IAAI,GAAG,MAAM,MAAM,QAAQ,eAAe,IAAI,GAAG,CAAC;AACzI,UAAM,eAAe;AACrB,UAAM,EAAE,GAAG,MAAM;AACjB,UAAM,EAAE,GAAG,iBAAiB,EAAE,OAAO,WAAW,QAAQ,UAAU,CAAC;AAAA,EACrE;AACA,SACE,qBAAC,SAAI,cAAY,OAAO,oBAAkB,OAAO,GAAG,EAAE,UAAU,QAAW,WAAW,EAAE,KAAK,EAAE,UAAU,CAAC,GAAI,GAAG,OAC9G;AAAA,WACC,oBAAC,OAAE,IAAI,GAAG,EAAE,SAAS,WAAW,EAAE,KAAK,GACpC,gBACH,IACE;AAAA,IACJ,oBAAC,QAAG,KAAK,MAAM,WAAW,EAAE,KAAK,GAAG,WAAW,MAC5C,UACH;AAAA,KACF;AAEJ;AAeA,SAAS,YAAY,EAAE,WAAW,OAAO,UAAU,QAAQ,OAAO,SAAS,WAAW,UAAU,GAAG,MAAM,GAAqB;AAC5H,QAAM,OAAO,UAAU,KAAK,OAAO;AACnC,SACE,oBAAC,QAAG,WAAW,EAAE,KAAK,GACpB;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,WAAW,OAAO;AAAA,MAC9B,gBAAc,WAAW,UAAU;AAAA,MACnC,WAAW,aAAa,EAAE,UAAU,WAAW,CAAC,YAAY,SAAS,EAAE,CAAC;AAAA,MACvE,GAAG;AAAA,MAEH,oBACC,WAEA,iCACG;AAAA;AAAA,QACA,QAAQ,oBAAC,UAAK,WAAW,EAAE,MAAM,GAAI,iBAAM,IAAU;AAAA,SACxD;AAAA;AAAA,EAEJ,GACF;AAEJ;AAEO,IAAM,UAAU,OAAO,OAAO,aAAa,EAAE,MAAM,YAAY,CAAC;","names":[]}
@@ -0,0 +1,33 @@
1
+ // src/chip-link/chip-link.tsx
2
+ import { Slot } from "radix-ui";
3
+ import { tv } from "tailwind-variants";
4
+ import { jsx } from "react/jsx-runtime";
5
+ var chipVariants = tv({
6
+ base: "inline-flex shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-full border font-semibold no-underline transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
7
+ variants: {
8
+ size: { sm: "h-7 px-3 text-xs", md: "h-9 px-3.5 text-xs" },
9
+ selected: {
10
+ false: "border-border-default bg-surface-1 text-fg-secondary hover:border-border-strong hover:text-fg",
11
+ true: "border-accent bg-accent-soft text-fg"
12
+ }
13
+ },
14
+ defaultVariants: { size: "md", selected: false }
15
+ });
16
+ function ChipLink({ size, selected = false, current = "page", asChild, className, ...props }) {
17
+ const Comp = asChild ? Slot.Root : "a";
18
+ return /* @__PURE__ */ jsx(
19
+ Comp,
20
+ {
21
+ "data-state": selected ? "on" : "off",
22
+ "aria-current": selected ? current : void 0,
23
+ className: chipVariants({ size, selected, className }),
24
+ ...props
25
+ }
26
+ );
27
+ }
28
+
29
+ export {
30
+ chipVariants,
31
+ ChipLink
32
+ };
33
+ //# sourceMappingURL=chunk-BTTDIVVJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/chip-link/chip-link.tsx"],"sourcesContent":["// 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"],"mappings":";AAGA,SAAS,YAAY;AAErB,SAAS,UAA6B;AAuClC;AArCG,IAAM,eAAe,GAAG;AAAA,EAC7B,MACE;AAAA,EAEF,UAAU;AAAA,IACR,MAAM,EAAE,IAAI,oBAAoB,IAAI,qBAAqB;AAAA,IACzD,UAAU;AAAA,MACR,OAAO;AAAA,MACP,MAAM;AAAA,IACR;AAAA,EACF;AAAA,EACA,iBAAiB,EAAE,MAAM,MAAM,UAAU,MAAM;AACjD,CAAC;AAsBM,SAAS,SAAS,EAAE,MAAM,WAAW,OAAO,UAAU,QAAQ,SAAS,WAAW,GAAG,MAAM,GAAkB;AAClH,QAAM,OAAO,UAAU,KAAK,OAAO;AACnC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,WAAW,OAAO;AAAA,MAC9B,gBAAc,WAAW,UAAU;AAAA,MACnC,WAAW,aAAa,EAAE,MAAM,UAAU,UAAU,CAAC;AAAA,MACpD,GAAG;AAAA;AAAA,EACN;AAEJ;","names":[]}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import {
3
3
  ProductLogo
4
- } from "./chunk-4DH2HW3D.js";
4
+ } from "./chunk-TFJDIOFK.js";
5
5
 
6
6
  // src/family-mark.tsx
7
7
  import { jsx } from "react/jsx-runtime";
@@ -92,4 +92,4 @@ export {
92
92
  sitesByGroup,
93
93
  familyLabels
94
94
  };
95
- //# sourceMappingURL=chunk-BCGJZ5PG.js.map
95
+ //# sourceMappingURL=chunk-OUWPBSSL.js.map
@@ -511,6 +511,7 @@ function ProductLogo({ id, ...props }) {
511
511
 
512
512
  export {
513
513
  neutral,
514
+ useIds,
514
515
  a11y,
515
516
  AkinazaLogo,
516
517
  KachizakaLogo,
@@ -523,4 +524,4 @@ export {
523
524
  YororisLogo,
524
525
  ProductLogo
525
526
  };
526
- //# sourceMappingURL=chunk-4DH2HW3D.js.map
527
+ //# sourceMappingURL=chunk-TFJDIOFK.js.map
@@ -32,6 +32,23 @@ function indexPage(items) {
32
32
  });
33
33
  return { text, at };
34
34
  }
35
+ var ADVANCE = "278 278 355 556 556 889 667 191 333 333 389 584 278 333 278 278 556 556 556 556 556 556 556 556 556 556 278 278 584 584 584 556 1015 667 667 722 722 667 611 778 722 278 500 667 556 833 722 778 667 778 722 667 611 722 667 944 667 667 611 278 278 278 469 556 333 556 556 500 556 556 278 556 556 222 222 500 222 833 556 556 556 556 333 500 278 556 500 722 500 500 500";
36
+ var ADVANCES = ADVANCE.split(" ").map(Number);
37
+ function advance(ch) {
38
+ const base = ch.normalize("NFD")[0] ?? ch;
39
+ const code = base.charCodeAt(0);
40
+ return code >= 32 && code <= 126 ? ADVANCES[code - 32] ?? 556 : 556;
41
+ }
42
+ function shareBefore(text, n) {
43
+ let before = 0;
44
+ let total = 0;
45
+ for (let i = 0; i < text.length; i++) {
46
+ const a = advance(text[i]);
47
+ if (i < n) before += a;
48
+ total += a;
49
+ }
50
+ return total > 0 ? before / total : n / Math.max(1, text.length);
51
+ }
35
52
  function boxesOf(page, index, from, to) {
36
53
  const spans = /* @__PURE__ */ new Map();
37
54
  for (let k = from; k < to; k++) {
@@ -42,10 +59,9 @@ function boxesOf(page, index, from, to) {
42
59
  const raw = [];
43
60
  for (const [i, [c0, c1]] of [...spans].sort((a, b) => a[0] - b[0])) {
44
61
  const item = page.items[i];
45
- const len = Math.max(1, item.str.length);
46
62
  const size = item.height || Math.abs(item.transform[3] ?? 0) || 10;
47
- const x = (item.transform[4] ?? 0) + item.width * c0 / len;
48
- const w = item.width * (c1 - c0) / len;
63
+ const x = (item.transform[4] ?? 0) + item.width * shareBefore(item.str, c0);
64
+ const w = item.width * (shareBefore(item.str, c1) - shareBefore(item.str, c0));
49
65
  const top = page.height - ((item.transform[5] ?? 0) + size * 0.9);
50
66
  raw.push({ x: x / page.width, y: top / page.height, width: w / page.width, height: size * 1.15 / page.height });
51
67
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/document-viewer/document-viewer.tsx","../src/document-viewer/locate.ts"],"sourcesContent":["// Client. A PDF drawn page by page (pdf.js), and passages of it lit where they sit. The app hands over the engine\n// (`loadPdfjs={() => import(\"pdfjs-dist\")}` — an optional peer, loaded only on a page that shows a document) and the\n// worker's address; it hands over the passages as quotes (`highlights`), and the viewer finds their place from the\n// PDF's own text positions (`locateQuote`): one box per line, in fractions of the page, so they follow any zoom.\n// `activeId` (controlled) scrolls to a passage and strengthens it; a click on a box calls `onActiveChange`. A passage\n// the text layer cannot place is never drawn at a guessed place: the viewer goes to its page when it is known and shows\n// the quote in a bar — and tells the app (`onLocate`) so its list can say \"page 3\" or \"not found\".\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { useLatest, usePrefersReducedMotion } from \"../media\";\nimport { type Located, locateQuote, type PageText } from \"./locate\";\n\nexport const documentViewer = tv({\n slots: {\n root: \"relative flex min-w-0 flex-col gap-3\",\n pages: \"flex max-h-(--kz-document-viewer-height,70vh) flex-col gap-3 overflow-auto rounded-lg border border-border-default bg-surface-2 p-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\",\n page: \"relative mx-auto w-full max-w-(--kz-document-viewer-page,56rem) shrink-0 overflow-hidden rounded-sm bg-white shadow-sm\",\n canvas: \"block h-auto w-full\",\n caption: \"absolute top-1 left-1 rounded-sm bg-surface-1/90 px-1.5 py-0.5 text-xs text-fg-secondary\",\n box: \"absolute cursor-pointer rounded-xs border border-warning/70 bg-warning/25 mix-blend-multiply transition-colors data-[active=true]:border-2 data-[active=true]:border-danger data-[active=true]:bg-danger/25 motion-reduce:transition-none\",\n bar: \"rounded-lg border border-border-default bg-surface-1 px-3 py-2 text-sm text-fg-secondary\",\n status: \"p-6 text-center text-sm text-fg-secondary\",\n },\n});\n\n/** The words of the viewer, passed translated. `{page}` and `{total}` are replaced. */\nexport type DocumentViewerLabels = {\n /** The accessible name of the pages' region. */\n region: string;\n loading: string;\n failed: string;\n /** A page's caption and accessible name: \"Page {page} of {total}\". */\n page: string;\n /** The bar above the pages when the active passage could only be placed on a page: \"Passage on page {page}:\". */\n onPage: string;\n /** The bar when the active passage is not in the text layer. */\n notFound: string;\n};\n\nexport const documentViewerLabels: DocumentViewerLabels = {\n region: \"Document pages\",\n loading: \"Loading the document…\",\n failed: \"The document could not be displayed.\",\n page: \"Page {page} of {total}\",\n onPage: \"Passage on page {page}:\",\n notFound: \"This passage is not in the document's text layer:\",\n};\n\n/** A passage to light: its quote (as the text layer reads it), the page if the app knows it, a name for screen readers. */\nexport type DocumentHighlight = { id: string; quote: string; page?: number; label?: string };\n\n/** What the viewer found for a passage. */\nexport type { Located };\n\n/** The members of pdf.js the viewer uses (`pdfjs-dist` ≥ 5), typed here so the package does not depend on it. */\nexport type PdfjsModule = {\n GlobalWorkerOptions: { workerSrc: string };\n getDocument(source: { url?: string; data?: Uint8Array }): { promise: Promise<PdfDocument>; destroy?: () => Promise<void> | void };\n};\nexport type PdfDocument = { numPages: number; getPage(n: number): Promise<PdfPage>; destroy?: () => Promise<void> | void };\nexport type PdfPage = {\n rotate: number;\n view: number[];\n getViewport(options: { scale: number }): { width: number; height: number };\n getTextContent(): Promise<{ items: readonly unknown[] }>;\n render(options: { canvas: HTMLCanvasElement; canvasContext: CanvasRenderingContext2D; viewport: { width: number; height: number } }): { promise: Promise<void>; cancel?: () => void };\n cleanup?: () => void;\n};\n\nexport type DocumentViewerProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n /** The PDF: an address (same origin or CORS) or its bytes. */\n src: string | Uint8Array;\n /** Loads pdf.js: `() => import(\"pdfjs-dist\")`. An optional peer, never in a bundle without a document. */\n loadPdfjs: () => Promise<PdfjsModule>;\n /** The address of pdf.js's worker (`new URL(\"pdfjs-dist/build/pdf.worker.min.mjs\", import.meta.url).toString()`). */\n workerSrc: string;\n /** The passages to light. */\n highlights?: readonly DocumentHighlight[];\n /** The passage in focus: the viewer scrolls to it. */\n activeId?: string | null;\n /** A box was clicked (the id) or the focus was released (`null`). */\n onActiveChange?: (id: string | null) => void;\n /** Where each passage was found (`null`: nowhere) — once the text layer is read, and again if `highlights` change. */\n onLocate?: (found: Record<string, Located | null>) => void;\n /** Called when the document is open, with its page count. */\n onLoad?: (info: { pages: number }) => void;\n /** The words of the viewer, translated (English by default). */\n labels?: Partial<DocumentViewerLabels>;\n};\n\nconst fill = (template: string, vars: Record<string, string | number>) => template.replace(/\\{(\\w+)\\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m));\n\ntype Loaded = { doc: PdfDocument; texts: PageText[]; sizes: { width: number; height: number }[] };\ntype Phase = { state: \"loading\" } | { state: \"error\"; key: string } | ({ state: \"ready\"; key: string } & Loaded);\n\nconst isItem = (x: unknown): x is PageText[\"items\"][number] => typeof x === \"object\" && x !== null && typeof (x as { str?: unknown }).str === \"string\" && Array.isArray((x as { transform?: unknown }).transform);\n\nfunction PageCanvas({ page, doc, caption, onReady, className }: { page: number; doc: PdfDocument; caption: string; onReady?: () => void; className: string }) {\n const ref = React.useRef<HTMLCanvasElement>(null);\n const wrapper = React.useRef<HTMLDivElement>(null);\n const [visible, setVisible] = React.useState(() => typeof IntersectionObserver === \"undefined\");\n const ready = useLatest(onReady);\n React.useEffect(() => {\n const node = wrapper.current?.parentElement;\n if (!node) return;\n if (typeof IntersectionObserver === \"undefined\") return;\n const observer = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setVisible(true), { rootMargin: \"600px 0px\" });\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n React.useEffect(() => {\n if (!visible) return;\n let task: { promise: Promise<void>; cancel?: () => void } | undefined;\n let cancelled = false;\n void (async () => {\n const p = await doc.getPage(page);\n const canvas = ref.current;\n const ctx = canvas?.getContext(\"2d\");\n if (cancelled || !canvas || !ctx) return;\n const base = p.getViewport({ scale: 1 });\n const dpr = typeof window === \"undefined\" ? 1 : Math.min(2, window.devicePixelRatio || 1);\n const scale = Math.min(2.5, Math.max(1, (canvas.parentElement?.clientWidth || base.width) / base.width)) * dpr;\n const viewport = p.getViewport({ scale });\n canvas.width = Math.floor(viewport.width);\n canvas.height = Math.floor(viewport.height);\n task = p.render({ canvas, canvasContext: ctx, viewport });\n await task.promise.catch(() => undefined);\n if (!cancelled) ready.current?.();\n })().catch(() => undefined);\n return () => {\n cancelled = true;\n task?.cancel?.();\n };\n }, [visible, doc, page, ready]);\n return (\n <div ref={wrapper} className=\"contents\">\n <canvas ref={ref} aria-hidden=\"true\" className={className} />\n <span className=\"sr-only\">{caption}</span>\n </div>\n );\n}\n\n/** A PDF with its passages lit — see the file's header. Client. */\nexport function DocumentViewer({ src, loadPdfjs, workerSrc, highlights = [], activeId = null, onActiveChange, onLocate, onLoad, labels, className, ...props }: DocumentViewerProps) {\n const words = { ...documentViewerLabels, ...labels };\n const s = documentViewer();\n const reduce = usePrefersReducedMotion();\n const [loaded, setPhase] = React.useState<Phase>({ state: \"loading\" });\n const callbacks = useLatest({ onLoad, onLocate });\n const containerRef = React.useRef<HTMLDivElement>(null);\n const bytes = typeof src === \"string\" ? src : src.byteLength + \":\" + src[0] + \":\" + src[src.length - 1];\n // A result belongs to the document it was read from: another `src` is loading again, with no reset inside the effect.\n const phase: Phase = loaded.state !== \"loading\" && loaded.key !== bytes ? { state: \"loading\" } : loaded;\n\n // Open the document and read every page's text layer (positions only; nothing is sent anywhere).\n React.useEffect(() => {\n let cancelled = false;\n let opened: { destroy?: () => Promise<void> | void } | undefined;\n void (async () => {\n const pdfjs = await loadPdfjs();\n pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;\n const loading = pdfjs.getDocument(typeof src === \"string\" ? { url: src } : { data: src.slice() });\n opened = loading;\n const doc = await loading.promise;\n const texts: PageText[] = [];\n const sizes: Loaded[\"sizes\"] = [];\n for (let n = 1; n <= doc.numPages; n++) {\n const page = await doc.getPage(n);\n const vp = page.getViewport({ scale: 1 });\n sizes.push({ width: vp.width, height: vp.height });\n const content = await page.getTextContent();\n // A rotated page has no reliable boxes: it keeps its size and an empty text layer (the app's page hint still works).\n texts.push({ page: n, width: vp.width, height: vp.height, items: page.rotate % 360 === 0 ? content.items.filter(isItem) : [] });\n page.cleanup?.();\n }\n if (cancelled) return;\n setPhase({ state: \"ready\", key: bytes, doc, texts, sizes });\n callbacks.current.onLoad?.({ pages: doc.numPages });\n })().catch(() => !cancelled && setPhase({ state: \"error\", key: bytes }));\n return () => {\n cancelled = true;\n void opened?.destroy?.();\n };\n // `src` is compared by content (`bytes`) so a re-render with the same bytes does not reopen the file.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [bytes, loadPdfjs, workerSrc, callbacks]);\n\n const key = highlights.map((h) => `${h.id}\\u0000${h.page ?? \"\"}\\u0000${h.quote}`).join(\"\\u0001\");\n const found = React.useMemo(() => {\n const out: Record<string, Located | null> = {};\n if (phase.state === \"ready\") for (const h of highlights) out[h.id] = locateQuote(phase.texts, h.quote, h.page);\n return out;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [phase, key]);\n React.useEffect(() => {\n if (phase.state === \"ready\") callbacks.current.onLocate?.(found);\n }, [found, phase.state, callbacks]);\n\n // Go to the active passage.\n const active = activeId ? highlights.find((h) => h.id === activeId) : undefined;\n const where = activeId ? found[activeId] : undefined;\n React.useEffect(() => {\n if (phase.state !== \"ready\" || !where) return;\n const root = containerRef.current;\n const target = root?.querySelector<HTMLElement>(where.boxes.length > 0 ? `[data-highlight=\"${CSS.escape(activeId!)}\"]` : `[data-page=\"${where.page}\"]`) ?? null;\n target?.scrollIntoView?.({ block: where.boxes.length > 0 ? \"center\" : \"start\", behavior: reduce ? \"auto\" : \"smooth\" });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [activeId, where, phase.state]);\n\n return (\n <div className={s.root({ className })} {...props}>\n {active && where !== undefined && where?.match !== \"exact\" && where?.match !== \"start\" ? (\n <p className={s.bar()} data-testid=\"document-viewer-bar\">\n {where?.match === \"page\" ? fill(words.onPage, { page: where.page }) : words.notFound} <q>{active.quote}</q>\n </p>\n ) : null}\n <div ref={containerRef} className={s.pages()} role=\"region\" aria-label={words.region} tabIndex={0}>\n {phase.state === \"loading\" ? <p className={s.status()}>{words.loading}</p> : null}\n {phase.state === \"error\" ? <p role=\"alert\" className={s.status()}>{words.failed}</p> : null}\n {phase.state === \"ready\"\n ? phase.texts.map((t, i) => {\n const size = phase.sizes[i]!;\n const caption = fill(words.page, { page: t.page, total: phase.doc.numPages });\n return (\n <div key={t.page} className={s.page()} data-page={t.page} style={{ aspectRatio: `${size.width} / ${size.height}` }}>\n <PageCanvas page={t.page} doc={phase.doc} caption={caption} className={s.canvas()} />\n <span className={s.caption()} aria-hidden=\"true\">{caption}</span>\n {highlights.flatMap((h) => {\n const f = found[h.id];\n if (!f || f.page !== t.page) return [];\n return f.boxes.map((b, k) => (\n <div\n key={`${h.id}:${k}`}\n aria-hidden=\"true\"\n data-highlight={h.id}\n data-active={h.id === activeId}\n className={s.box()}\n style={{ left: `${b.x * 100}%`, top: `${b.y * 100}%`, width: `${b.width * 100}%`, height: `${b.height * 100}%` }}\n onClick={() => onActiveChange?.(h.id === activeId ? null : h.id)}\n />\n ));\n })}\n </div>\n );\n })\n : null}\n </div>\n </div>\n );\n}\n","// Pure: where a quoted passage sits on the pages of a PDF, from pdf.js's text positions. No DOM, no pdf.js import —\n// the viewer feeds it what `page.getTextContent()` returned, a test feeds it a fixture.\n\n/** A text item as pdf.js returns it (`TextItem`): the string, its matrix (`[a, b, c, d, x, baseline]`) and its size. */\nexport type TextItem = { str: string; transform: number[]; width: number; height: number };\n\n/** A rectangle in fractions of the page (0–1), origin top-left — independent of the zoom the page is drawn at. */\nexport type Box = { x: number; y: number; width: number; height: number };\n\n/** The text layer of one page: its size in PDF units (rotation 0) and its items in reading order. */\nexport type PageText = { page: number; width: number; height: number; items: readonly TextItem[] };\n\nexport type Located = {\n /** 1-based page where the passage starts. */\n page: number;\n /** One box per line of the passage (merged along a line); empty when only the page is known. */\n boxes: Box[];\n /** `exact`: the whole quote was found; `start`: only its opening words were (a quote cut by a different layout). */\n match: \"exact\" | \"start\" | \"page\";\n};\n\nconst FOLD: Record<string, string> = { \"’\": \"'\", \"‘\": \"'\", \"“\": '\"', \"”\": '\"', \"«\": '\"', \"»\": '\"', \"–\": \"-\", \"—\": \"-\", \" \": \" \", \" \": \" \", \"fi\": \"fi\", \"fl\": \"fl\" };\n\n/** The comparable form of a text: no whitespace, no soft hyphen, straight quotes, case and accents folded. */\nexport function fold(text: string): string {\n let out = \"\";\n for (const ch of text.normalize(\"NFKD\")) {\n if (/\\p{M}/u.test(ch) || /\\s/u.test(ch) || ch === \"­\" || ch === \"​\") continue;\n out += (FOLD[ch] ?? ch).toLowerCase();\n }\n return out;\n}\n\ntype Index = { text: string; at: { item: number; char: number }[] };\n\nfunction indexPage(items: readonly TextItem[]): Index {\n let text = \"\";\n const at: Index[\"at\"] = [];\n items.forEach((item, i) => {\n for (let c = 0; c < item.str.length; c++) {\n const piece = fold(item.str[c]!);\n for (let k = 0; k < piece.length; k++) {\n text += piece[k];\n at.push({ item: i, char: c });\n }\n }\n });\n return { text, at };\n}\n\nfunction boxesOf(page: PageText, index: Index, from: number, to: number): Box[] {\n const spans = new Map<number, [number, number]>();\n for (let k = from; k < to; k++) {\n const { item, char } = index.at[k]!;\n const s = spans.get(item);\n spans.set(item, s ? [Math.min(s[0], char), Math.max(s[1], char + 1)] : [char, char + 1]);\n }\n const raw: Box[] = [];\n for (const [i, [c0, c1]] of [...spans].sort((a, b) => a[0] - b[0])) {\n const item = page.items[i]!;\n const len = Math.max(1, item.str.length);\n const size = item.height || Math.abs(item.transform[3] ?? 0) || 10;\n const x = (item.transform[4] ?? 0) + (item.width * c0) / len;\n const w = (item.width * (c1 - c0)) / len;\n const top = page.height - ((item.transform[5] ?? 0) + size * 0.9);\n raw.push({ x: x / page.width, y: top / page.height, width: w / page.width, height: (size * 1.15) / page.height });\n }\n // Merge the pieces that sit on one line and touch (several items of one sentence) into one rectangle.\n const merged: Box[] = [];\n for (const b of raw) {\n const last = merged[merged.length - 1];\n const sameLine = last && Math.abs(last.y - b.y) < last.height * 0.5 && b.x - (last.x + last.width) < last.height * 1.5 && b.x >= last.x - 1e-9;\n if (last && sameLine) {\n const right = Math.max(last.x + last.width, b.x + b.width);\n last.y = Math.min(last.y, b.y);\n last.height = Math.max(last.height, b.height);\n last.width = right - last.x;\n } else merged.push({ ...b });\n }\n return merged.map((b) => ({ x: clamp01(b.x), y: clamp01(b.y), width: Math.min(b.width, 1 - clamp01(b.x)), height: Math.min(b.height, 1 - clamp01(b.y)) }));\n}\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\n\n/** The shortest opening worth highlighting alone: a few words, not a stray letter. */\nconst MIN_START = 24;\n\n/**\n * Where `quote` sits in the pages: the first page (starting with `hintPage`, if any) whose text contains it, and the\n * boxes of its lines. A quote that a different layout cut (a column break, a hyphen) is looked for by its opening\n * words (`match: \"start\"`); one that is nowhere gives `null`, or the hinted page alone (`match: \"page\"`). Never a guess:\n * a rotated page has no boxes, only its page.\n */\nexport function locateQuote(pages: readonly PageText[], quote: string, hintPage?: number): Located | null {\n const needle = fold(quote);\n const ordered = hintPage ? [...pages].sort((a, b) => Number(b.page === hintPage) - Number(a.page === hintPage)) : pages;\n if (needle.length > 0) {\n for (const mode of [\"exact\", \"start\"] as const) {\n const wanted = mode === \"exact\" ? needle : needle.slice(0, Math.max(MIN_START, Math.min(40, Math.floor(needle.length * 0.6))));\n if (mode === \"start\" && (needle.length <= wanted.length || wanted.length < MIN_START)) break;\n for (const page of ordered) {\n const index = indexPage(page.items);\n const found = index.text.indexOf(wanted);\n if (found < 0) continue;\n return { page: page.page, boxes: boxesOf(page, index, found, found + wanted.length), match: mode };\n }\n }\n }\n return hintPage && pages.some((p) => p.page === hintPage) ? { page: hintPage, boxes: [], match: \"page\" } : null;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/document-viewer/document-viewer.tsx","../src/document-viewer/locate.ts"],"sourcesContent":["// Client. A PDF drawn page by page (pdf.js), and passages of it lit where they sit. The app hands over the engine\n// (`loadPdfjs={() => import(\"pdfjs-dist\")}` — an optional peer, loaded only on a page that shows a document) and the\n// worker's address; it hands over the passages as quotes (`highlights`), and the viewer finds their place from the\n// PDF's own text positions (`locateQuote`): one box per line, in fractions of the page, so they follow any zoom.\n// `activeId` (controlled) scrolls to a passage and strengthens it; a click on a box calls `onActiveChange`. A passage\n// the text layer cannot place is never drawn at a guessed place: the viewer goes to its page when it is known and shows\n// the quote in a bar — and tells the app (`onLocate`) so its list can say \"page 3\" or \"not found\".\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { useLatest, usePrefersReducedMotion } from \"../media\";\nimport { type Located, locateQuote, type PageText } from \"./locate\";\n\nexport const documentViewer = tv({\n slots: {\n root: \"relative flex min-w-0 flex-col gap-3\",\n pages: \"flex max-h-(--kz-document-viewer-height,70vh) flex-col gap-3 overflow-auto rounded-lg border border-border-default bg-surface-2 p-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\",\n page: \"relative mx-auto w-full max-w-(--kz-document-viewer-page,56rem) shrink-0 overflow-hidden rounded-sm bg-white shadow-sm\",\n canvas: \"block h-auto w-full\",\n caption: \"absolute top-1 left-1 rounded-sm bg-surface-1/90 px-1.5 py-0.5 text-xs text-fg-secondary\",\n box: \"absolute cursor-pointer rounded-xs border border-warning/70 bg-warning/25 mix-blend-multiply transition-colors data-[active=true]:border-2 data-[active=true]:border-danger data-[active=true]:bg-danger/25 motion-reduce:transition-none\",\n bar: \"rounded-lg border border-border-default bg-surface-1 px-3 py-2 text-sm text-fg-secondary\",\n status: \"p-6 text-center text-sm text-fg-secondary\",\n },\n});\n\n/** The words of the viewer, passed translated. `{page}` and `{total}` are replaced. */\nexport type DocumentViewerLabels = {\n /** The accessible name of the pages' region. */\n region: string;\n loading: string;\n failed: string;\n /** A page's caption and accessible name: \"Page {page} of {total}\". */\n page: string;\n /** The bar above the pages when the active passage could only be placed on a page: \"Passage on page {page}:\". */\n onPage: string;\n /** The bar when the active passage is not in the text layer. */\n notFound: string;\n};\n\nexport const documentViewerLabels: DocumentViewerLabels = {\n region: \"Document pages\",\n loading: \"Loading the document…\",\n failed: \"The document could not be displayed.\",\n page: \"Page {page} of {total}\",\n onPage: \"Passage on page {page}:\",\n notFound: \"This passage is not in the document's text layer:\",\n};\n\n/** A passage to light: its quote (as the text layer reads it), the page if the app knows it, a name for screen readers. */\nexport type DocumentHighlight = { id: string; quote: string; page?: number; label?: string };\n\n/** What the viewer found for a passage. */\nexport type { Located };\n\n/** The members of pdf.js the viewer uses (`pdfjs-dist` ≥ 5), typed here so the package does not depend on it. */\nexport type PdfjsModule = {\n GlobalWorkerOptions: { workerSrc: string };\n getDocument(source: { url?: string; data?: Uint8Array }): { promise: Promise<PdfDocument>; destroy?: () => Promise<void> | void };\n};\nexport type PdfDocument = { numPages: number; getPage(n: number): Promise<PdfPage>; destroy?: () => Promise<void> | void };\nexport type PdfPage = {\n rotate: number;\n view: number[];\n getViewport(options: { scale: number }): { width: number; height: number };\n getTextContent(): Promise<{ items: readonly unknown[] }>;\n render(options: { canvas: HTMLCanvasElement; canvasContext: CanvasRenderingContext2D; viewport: { width: number; height: number } }): { promise: Promise<void>; cancel?: () => void };\n cleanup?: () => void;\n};\n\nexport type DocumentViewerProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n /** The PDF: an address (same origin or CORS) or its bytes. */\n src: string | Uint8Array;\n /** Loads pdf.js: `() => import(\"pdfjs-dist\")`. An optional peer, never in a bundle without a document. */\n loadPdfjs: () => Promise<PdfjsModule>;\n /** The address of pdf.js's worker (`new URL(\"pdfjs-dist/build/pdf.worker.min.mjs\", import.meta.url).toString()`). */\n workerSrc: string;\n /** The passages to light. */\n highlights?: readonly DocumentHighlight[];\n /** The passage in focus: the viewer scrolls to it. */\n activeId?: string | null;\n /** A box was clicked (the id) or the focus was released (`null`). */\n onActiveChange?: (id: string | null) => void;\n /** Where each passage was found (`null`: nowhere) — once the text layer is read, and again if `highlights` change. */\n onLocate?: (found: Record<string, Located | null>) => void;\n /** Called when the document is open, with its page count. */\n onLoad?: (info: { pages: number }) => void;\n /** The words of the viewer, translated (English by default). */\n labels?: Partial<DocumentViewerLabels>;\n};\n\nconst fill = (template: string, vars: Record<string, string | number>) => template.replace(/\\{(\\w+)\\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m));\n\ntype Loaded = { doc: PdfDocument; texts: PageText[]; sizes: { width: number; height: number }[] };\ntype Phase = { state: \"loading\" } | { state: \"error\"; key: string } | ({ state: \"ready\"; key: string } & Loaded);\n\nconst isItem = (x: unknown): x is PageText[\"items\"][number] => typeof x === \"object\" && x !== null && typeof (x as { str?: unknown }).str === \"string\" && Array.isArray((x as { transform?: unknown }).transform);\n\nfunction PageCanvas({ page, doc, caption, onReady, className }: { page: number; doc: PdfDocument; caption: string; onReady?: () => void; className: string }) {\n const ref = React.useRef<HTMLCanvasElement>(null);\n const wrapper = React.useRef<HTMLDivElement>(null);\n const [visible, setVisible] = React.useState(() => typeof IntersectionObserver === \"undefined\");\n const ready = useLatest(onReady);\n React.useEffect(() => {\n const node = wrapper.current?.parentElement;\n if (!node) return;\n if (typeof IntersectionObserver === \"undefined\") return;\n const observer = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setVisible(true), { rootMargin: \"600px 0px\" });\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n React.useEffect(() => {\n if (!visible) return;\n let task: { promise: Promise<void>; cancel?: () => void } | undefined;\n let cancelled = false;\n void (async () => {\n const p = await doc.getPage(page);\n const canvas = ref.current;\n const ctx = canvas?.getContext(\"2d\");\n if (cancelled || !canvas || !ctx) return;\n const base = p.getViewport({ scale: 1 });\n const dpr = typeof window === \"undefined\" ? 1 : Math.min(2, window.devicePixelRatio || 1);\n const scale = Math.min(2.5, Math.max(1, (canvas.parentElement?.clientWidth || base.width) / base.width)) * dpr;\n const viewport = p.getViewport({ scale });\n canvas.width = Math.floor(viewport.width);\n canvas.height = Math.floor(viewport.height);\n task = p.render({ canvas, canvasContext: ctx, viewport });\n await task.promise.catch(() => undefined);\n if (!cancelled) ready.current?.();\n })().catch(() => undefined);\n return () => {\n cancelled = true;\n task?.cancel?.();\n };\n }, [visible, doc, page, ready]);\n return (\n <div ref={wrapper} className=\"contents\">\n <canvas ref={ref} aria-hidden=\"true\" className={className} />\n <span className=\"sr-only\">{caption}</span>\n </div>\n );\n}\n\n/** A PDF with its passages lit — see the file's header. Client. */\nexport function DocumentViewer({ src, loadPdfjs, workerSrc, highlights = [], activeId = null, onActiveChange, onLocate, onLoad, labels, className, ...props }: DocumentViewerProps) {\n const words = { ...documentViewerLabels, ...labels };\n const s = documentViewer();\n const reduce = usePrefersReducedMotion();\n const [loaded, setPhase] = React.useState<Phase>({ state: \"loading\" });\n const callbacks = useLatest({ onLoad, onLocate });\n const containerRef = React.useRef<HTMLDivElement>(null);\n const bytes = typeof src === \"string\" ? src : src.byteLength + \":\" + src[0] + \":\" + src[src.length - 1];\n // A result belongs to the document it was read from: another `src` is loading again, with no reset inside the effect.\n const phase: Phase = loaded.state !== \"loading\" && loaded.key !== bytes ? { state: \"loading\" } : loaded;\n\n // Open the document and read every page's text layer (positions only; nothing is sent anywhere).\n React.useEffect(() => {\n let cancelled = false;\n let opened: { destroy?: () => Promise<void> | void } | undefined;\n void (async () => {\n const pdfjs = await loadPdfjs();\n pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;\n const loading = pdfjs.getDocument(typeof src === \"string\" ? { url: src } : { data: src.slice() });\n opened = loading;\n const doc = await loading.promise;\n const texts: PageText[] = [];\n const sizes: Loaded[\"sizes\"] = [];\n for (let n = 1; n <= doc.numPages; n++) {\n const page = await doc.getPage(n);\n const vp = page.getViewport({ scale: 1 });\n sizes.push({ width: vp.width, height: vp.height });\n const content = await page.getTextContent();\n // A rotated page has no reliable boxes: it keeps its size and an empty text layer (the app's page hint still works).\n texts.push({ page: n, width: vp.width, height: vp.height, items: page.rotate % 360 === 0 ? content.items.filter(isItem) : [] });\n page.cleanup?.();\n }\n if (cancelled) return;\n setPhase({ state: \"ready\", key: bytes, doc, texts, sizes });\n callbacks.current.onLoad?.({ pages: doc.numPages });\n })().catch(() => !cancelled && setPhase({ state: \"error\", key: bytes }));\n return () => {\n cancelled = true;\n void opened?.destroy?.();\n };\n // `src` is compared by content (`bytes`) so a re-render with the same bytes does not reopen the file.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [bytes, loadPdfjs, workerSrc, callbacks]);\n\n const key = highlights.map((h) => `${h.id}\\u0000${h.page ?? \"\"}\\u0000${h.quote}`).join(\"\\u0001\");\n const found = React.useMemo(() => {\n const out: Record<string, Located | null> = {};\n if (phase.state === \"ready\") for (const h of highlights) out[h.id] = locateQuote(phase.texts, h.quote, h.page);\n return out;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [phase, key]);\n React.useEffect(() => {\n if (phase.state === \"ready\") callbacks.current.onLocate?.(found);\n }, [found, phase.state, callbacks]);\n\n // Go to the active passage.\n const active = activeId ? highlights.find((h) => h.id === activeId) : undefined;\n const where = activeId ? found[activeId] : undefined;\n React.useEffect(() => {\n if (phase.state !== \"ready\" || !where) return;\n const root = containerRef.current;\n const target = root?.querySelector<HTMLElement>(where.boxes.length > 0 ? `[data-highlight=\"${CSS.escape(activeId!)}\"]` : `[data-page=\"${where.page}\"]`) ?? null;\n target?.scrollIntoView?.({ block: where.boxes.length > 0 ? \"center\" : \"start\", behavior: reduce ? \"auto\" : \"smooth\" });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [activeId, where, phase.state]);\n\n return (\n <div className={s.root({ className })} {...props}>\n {active && where !== undefined && where?.match !== \"exact\" && where?.match !== \"start\" ? (\n <p className={s.bar()} data-testid=\"document-viewer-bar\">\n {where?.match === \"page\" ? fill(words.onPage, { page: where.page }) : words.notFound} <q>{active.quote}</q>\n </p>\n ) : null}\n <div ref={containerRef} className={s.pages()} role=\"region\" aria-label={words.region} tabIndex={0}>\n {phase.state === \"loading\" ? <p className={s.status()}>{words.loading}</p> : null}\n {phase.state === \"error\" ? <p role=\"alert\" className={s.status()}>{words.failed}</p> : null}\n {phase.state === \"ready\"\n ? phase.texts.map((t, i) => {\n const size = phase.sizes[i]!;\n const caption = fill(words.page, { page: t.page, total: phase.doc.numPages });\n return (\n <div key={t.page} className={s.page()} data-page={t.page} style={{ aspectRatio: `${size.width} / ${size.height}` }}>\n <PageCanvas page={t.page} doc={phase.doc} caption={caption} className={s.canvas()} />\n <span className={s.caption()} aria-hidden=\"true\">{caption}</span>\n {highlights.flatMap((h) => {\n const f = found[h.id];\n if (!f || f.page !== t.page) return [];\n return f.boxes.map((b, k) => (\n <div\n key={`${h.id}:${k}`}\n aria-hidden=\"true\"\n data-highlight={h.id}\n data-active={h.id === activeId}\n className={s.box()}\n style={{ left: `${b.x * 100}%`, top: `${b.y * 100}%`, width: `${b.width * 100}%`, height: `${b.height * 100}%` }}\n onClick={() => onActiveChange?.(h.id === activeId ? null : h.id)}\n />\n ));\n })}\n </div>\n );\n })\n : null}\n </div>\n </div>\n );\n}\n","// Pure: where a quoted passage sits on the pages of a PDF, from pdf.js's text positions. No DOM, no pdf.js import —\n// the viewer feeds it what `page.getTextContent()` returned, a test feeds it a fixture.\n\n/** A text item as pdf.js returns it (`TextItem`): the string, its matrix (`[a, b, c, d, x, baseline]`) and its size. */\nexport type TextItem = { str: string; transform: number[]; width: number; height: number };\n\n/** A rectangle in fractions of the page (0–1), origin top-left — independent of the zoom the page is drawn at. */\nexport type Box = { x: number; y: number; width: number; height: number };\n\n/** The text layer of one page: its size in PDF units (rotation 0) and its items in reading order. */\nexport type PageText = { page: number; width: number; height: number; items: readonly TextItem[] };\n\nexport type Located = {\n /** 1-based page where the passage starts. */\n page: number;\n /** One box per line of the passage (merged along a line); empty when only the page is known. */\n boxes: Box[];\n /** `exact`: the whole quote was found; `start`: only its opening words were (a quote cut by a different layout). */\n match: \"exact\" | \"start\" | \"page\";\n};\n\nconst FOLD: Record<string, string> = { \"’\": \"'\", \"‘\": \"'\", \"“\": '\"', \"”\": '\"', \"«\": '\"', \"»\": '\"', \"–\": \"-\", \"—\": \"-\", \" \": \" \", \" \": \" \", \"fi\": \"fi\", \"fl\": \"fl\" };\n\n/** The comparable form of a text: no whitespace, no soft hyphen, straight quotes, case and accents folded. */\nexport function fold(text: string): string {\n let out = \"\";\n for (const ch of text.normalize(\"NFKD\")) {\n if (/\\p{M}/u.test(ch) || /\\s/u.test(ch) || ch === \"­\" || ch === \"​\") continue;\n out += (FOLD[ch] ?? ch).toLowerCase();\n }\n return out;\n}\n\ntype Index = { text: string; at: { item: number; char: number }[] };\n\nfunction indexPage(items: readonly TextItem[]): Index {\n let text = \"\";\n const at: Index[\"at\"] = [];\n items.forEach((item, i) => {\n for (let c = 0; c < item.str.length; c++) {\n const piece = fold(item.str[c]!);\n for (let k = 0; k < piece.length; k++) {\n text += piece[k];\n at.push({ item: i, char: c });\n }\n }\n });\n return { text, at };\n}\n\n// Proportional faces: \"locataire\" is not as wide as nine capitals. pdf.js gives one width per item, so a position inside an\n// item is shared out by the Helvetica advance widths (per 1000 em) — close for any sans text, far better than a flat share.\nconst ADVANCE =\n \"278 278 355 556 556 889 667 191 333 333 389 584 278 333 278 278 556 556 556 556 556 556 556 556 556 556 278 278 584 584 584 556 1015 \" +\n \"667 667 722 722 667 611 778 722 278 500 667 556 833 722 778 667 778 722 667 611 722 667 944 667 667 611 278 278 278 469 556 333 \" +\n \"556 556 500 556 556 278 556 556 222 222 500 222 833 556 556 556 556 333 500 278 556 500 722 500 500 500\";\nconst ADVANCES = ADVANCE.split(\" \").map(Number);\nfunction advance(ch: string): number {\n const base = ch.normalize(\"NFD\")[0] ?? ch;\n const code = base.charCodeAt(0);\n return code >= 32 && code <= 126 ? (ADVANCES[code - 32] ?? 556) : 556;\n}\n/** The share (0–1) of an item's width that lies before character `n` of `text`. */\nfunction shareBefore(text: string, n: number): number {\n let before = 0;\n let total = 0;\n for (let i = 0; i < text.length; i++) {\n const a = advance(text[i]!);\n if (i < n) before += a;\n total += a;\n }\n return total > 0 ? before / total : n / Math.max(1, text.length);\n}\n\nfunction boxesOf(page: PageText, index: Index, from: number, to: number): Box[] {\n const spans = new Map<number, [number, number]>();\n for (let k = from; k < to; k++) {\n const { item, char } = index.at[k]!;\n const s = spans.get(item);\n spans.set(item, s ? [Math.min(s[0], char), Math.max(s[1], char + 1)] : [char, char + 1]);\n }\n const raw: Box[] = [];\n for (const [i, [c0, c1]] of [...spans].sort((a, b) => a[0] - b[0])) {\n const item = page.items[i]!;\n const size = item.height || Math.abs(item.transform[3] ?? 0) || 10;\n const x = (item.transform[4] ?? 0) + item.width * shareBefore(item.str, c0);\n const w = item.width * (shareBefore(item.str, c1) - shareBefore(item.str, c0));\n const top = page.height - ((item.transform[5] ?? 0) + size * 0.9);\n raw.push({ x: x / page.width, y: top / page.height, width: w / page.width, height: (size * 1.15) / page.height });\n }\n // Merge the pieces that sit on one line and touch (several items of one sentence) into one rectangle.\n const merged: Box[] = [];\n for (const b of raw) {\n const last = merged[merged.length - 1];\n const sameLine = last && Math.abs(last.y - b.y) < last.height * 0.5 && b.x - (last.x + last.width) < last.height * 1.5 && b.x >= last.x - 1e-9;\n if (last && sameLine) {\n const right = Math.max(last.x + last.width, b.x + b.width);\n last.y = Math.min(last.y, b.y);\n last.height = Math.max(last.height, b.height);\n last.width = right - last.x;\n } else merged.push({ ...b });\n }\n return merged.map((b) => ({ x: clamp01(b.x), y: clamp01(b.y), width: Math.min(b.width, 1 - clamp01(b.x)), height: Math.min(b.height, 1 - clamp01(b.y)) }));\n}\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\n\n/** The shortest opening worth highlighting alone: a few words, not a stray letter. */\nconst MIN_START = 24;\n\n/**\n * Where `quote` sits in the pages: the first page (starting with `hintPage`, if any) whose text contains it, and the\n * boxes of its lines. A quote that a different layout cut (a column break, a hyphen) is looked for by its opening\n * words (`match: \"start\"`); one that is nowhere gives `null`, or the hinted page alone (`match: \"page\"`). Never a guess:\n * a rotated page has no boxes, only its page.\n */\nexport function locateQuote(pages: readonly PageText[], quote: string, hintPage?: number): Located | null {\n const needle = fold(quote);\n const ordered = hintPage ? [...pages].sort((a, b) => Number(b.page === hintPage) - Number(a.page === hintPage)) : pages;\n if (needle.length > 0) {\n for (const mode of [\"exact\", \"start\"] as const) {\n const wanted = mode === \"exact\" ? needle : needle.slice(0, Math.max(MIN_START, Math.min(40, Math.floor(needle.length * 0.6))));\n if (mode === \"start\" && (needle.length <= wanted.length || wanted.length < MIN_START)) break;\n for (const page of ordered) {\n const index = indexPage(page.items);\n const found = index.text.indexOf(wanted);\n if (found < 0) continue;\n return { page: page.page, boxes: boxesOf(page, index, found, found + wanted.length), match: mode };\n }\n }\n }\n return hintPage && pages.some((p) => p.page === hintPage) ? { page: hintPage, boxes: [], match: \"page\" } : null;\n}\n"],"mappings":"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","names":[]}
@@ -73,11 +73,12 @@ function ImageCropper({
73
73
  };
74
74
  const x = clamp(value.x, halfX, 1 - halfX);
75
75
  const y = clamp(value.y, halfY, 1 - halfY);
76
+ const pct = (n) => `${Math.round(n * 1e4) / 1e4}%`;
76
77
  const style = {
77
- width: `${widthPct}%`,
78
- height: `${heightPct}%`,
79
- left: `${50 - x * widthPct}%`,
80
- top: `${50 - y * heightPct}%`
78
+ width: pct(widthPct),
79
+ height: pct(heightPct),
80
+ left: pct(50 - x * widthPct),
81
+ top: pct(50 - y * heightPct)
81
82
  };
82
83
  return /* @__PURE__ */ jsxs("div", { className: s.root({ className }), children: [
83
84
  /* @__PURE__ */ jsxs(
@@ -137,6 +138,9 @@ function ImageCropper({
137
138
  draggable: false,
138
139
  className: s.media(),
139
140
  style,
141
+ ref: (img) => {
142
+ if (img?.complete && img.naturalWidth > 0) setLoaded((was) => was?.width === img.naturalWidth && was.height === img.naturalHeight ? was : { width: img.naturalWidth, height: img.naturalHeight });
143
+ },
140
144
  onLoad: (event) => setLoaded({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })
141
145
  }
142
146
  ) : null,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/image-cropper/image-cropper.tsx"],"sourcesContent":["// Client. A fixed frame (a profile picture's square, a vertical 9:16, a banner) over a picture or a video the person\n// drags and zooms to choose what stays in. The value is a focus point (0–1 on each axis) and a zoom — independent of\n// the screen, the same on the server — and `cropRect` turns it into pixels of the source. Keyboard: the frame takes the\n// focus, the arrows move (Shift: faster), + / − zoom; the zoom is also a slider. No motion but the person's own.\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { clamp } from \"../media\";\nimport { Slider } from \"../slider\";\n\nexport const imageCropper = tv({\n slots: {\n root: \"flex w-full flex-col gap-3\",\n frame:\n \"relative w-full cursor-grab touch-none overflow-hidden rounded-xl bg-media active:cursor-grabbing \" +\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 media: \"pointer-events-none absolute max-w-none select-none\",\n grid:\n \"pointer-events-none absolute inset-0 rounded-xl border-2 border-fg-on-media/80 \" +\n \"bg-[linear-gradient(to_right,transparent_33.2%,var(--kz-text-on-media)_33.3%,transparent_33.5%,transparent_66.5%,var(--kz-text-on-media)_66.6%,transparent_66.8%),linear-gradient(to_bottom,transparent_33.2%,var(--kz-text-on-media)_33.3%,transparent_33.5%,transparent_66.5%,var(--kz-text-on-media)_66.6%,transparent_66.8%)] opacity-40\",\n round: \"pointer-events-none absolute inset-0 rounded-full shadow-[0_0_0_9999px_var(--kz-scrim-strong)]\",\n },\n});\n\n/** Where the frame looks: the focus point (0–1 from the left and the top of the source) and the zoom (1 = covers). */\nexport type CropValue = { x: number; y: number; zoom: number };\n\n/** The crop in pixels of the source: what to cut from an image of `width` × `height` for a frame of `aspect`. */\nexport function cropRect(value: CropValue, source: { width: number; height: number }, aspect: number) {\n const mediaAspect = source.width / source.height;\n const width = (mediaAspect > aspect ? source.height * aspect : source.width) / value.zoom;\n const height = width / aspect;\n const x = clamp(value.x * source.width - width / 2, 0, source.width - width);\n const y = clamp(value.y * source.height - height / 2, 0, source.height - height);\n return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };\n}\n\nexport type ImageCropperLabels = {\n /** The frame's accessible name (\"Crop area\"). */\n frame: string;\n /** How to use it with the keyboard, read with the frame (\"Use the arrow keys to move the picture, + and − to zoom.\"). */\n instructions: string;\n /** The zoom slider (\"Zoom\"). */\n zoom: string;\n};\n\nexport const imageCropperLabels: ImageCropperLabels = {\n frame: \"Crop area\",\n instructions: \"Drag or use the arrow keys to move the picture, + and − to zoom.\",\n zoom: \"Zoom\",\n};\n\nexport type ImageCropperProps = {\n /** The picture to crop. Or `children`: a `<video>` (with the class it is given through `mediaClassName`). */\n src?: string;\n /** The picture's alternative text, passed translated (empty when the frame's name says enough). */\n alt?: string;\n /** A media element instead of `src` — a `<video>` to choose the frame of a clip. Its natural size is in `naturalSize`. */\n children?: React.ReactElement<{ className?: string; style?: React.CSSProperties }>;\n /** The source's size when `children` is a video: `{ width: video.videoWidth, height: video.videoHeight }`. */\n naturalSize?: { width: number; height: number };\n /** The frame's width / height: 1 (a square), 9 / 16 (vertical), 3 (a banner). */\n aspect: number;\n /** A round mask over the square frame (a profile picture). */\n round?: boolean;\n /** The crop (controlled). */\n value?: CropValue;\n /** The crop at first, uncontrolled. Default: centred, no zoom. */\n defaultValue?: CropValue;\n /** Called on every move. */\n onValueChange?: (value: CropValue) => void;\n /** The largest zoom. Default 4. */\n maxZoom?: number;\n /** Shows the zoom slider under the frame. Default `true`. */\n showZoom?: boolean;\n /** The words, passed translated; English by default. */\n labels?: Partial<ImageCropperLabels>;\n /** Classes merged on the root. */\n className?: string;\n};\n\n/** Chooses what a fixed frame keeps of a picture or a video: drag, zoom, keyboard; a focus point and a zoom out. */\nexport function ImageCropper({\n src,\n alt = \"\",\n children,\n naturalSize,\n aspect,\n round,\n value: valueProp,\n defaultValue = { x: 0.5, y: 0.5, zoom: 1 },\n onValueChange,\n maxZoom = 4,\n showZoom = true,\n labels: given,\n className,\n}: ImageCropperProps) {\n const labels = { ...imageCropperLabels, ...given };\n const s = imageCropper();\n const frame = React.useRef<HTMLDivElement>(null);\n const drag = React.useRef<{ x: number; y: number; from: CropValue } | null>(null);\n const [inner, setInner] = React.useState(defaultValue);\n const [loaded, setLoaded] = React.useState<{ width: number; height: number } | null>(null);\n const value = valueProp ?? inner;\n const id = React.useId();\n const size = naturalSize ?? loaded;\n const mediaAspect = size ? size.width / size.height : aspect;\n\n // The media's box, in % of the frame: it covers the frame at zoom 1.\n const widthPct = (mediaAspect > aspect ? (mediaAspect / aspect) * 100 : 100) * value.zoom;\n const heightPct = (mediaAspect > aspect ? 100 : (aspect / mediaAspect) * 100) * value.zoom;\n // The focus point's limits: the media never leaves a gap in the frame.\n const halfX = 50 / widthPct;\n const halfY = 50 / heightPct;\n\n const set = (next: CropValue) => {\n const zoom = clamp(next.zoom, 1, maxZoom);\n const wPct = (mediaAspect > aspect ? (mediaAspect / aspect) * 100 : 100) * zoom;\n const hPct = (mediaAspect > aspect ? 100 : (aspect / mediaAspect) * 100) * zoom;\n const fixed = { x: clamp(next.x, 50 / wPct, 1 - 50 / wPct), y: clamp(next.y, 50 / hPct, 1 - 50 / hPct), zoom };\n if (!valueProp) setInner(fixed);\n onValueChange?.(fixed);\n };\n\n const x = clamp(value.x, halfX, 1 - halfX);\n const y = clamp(value.y, halfY, 1 - halfY);\n const style: React.CSSProperties = {\n width: `${widthPct}%`,\n height: `${heightPct}%`,\n left: `${50 - x * widthPct}%`,\n top: `${50 - y * heightPct}%`,\n };\n\n return (\n <div className={s.root({ className })}>\n <div\n ref={frame}\n role=\"group\"\n tabIndex={0}\n aria-label={labels.frame}\n aria-describedby={`${id}-help`}\n className={s.frame()}\n style={{ aspectRatio: String(aspect) }}\n onPointerDown={(event) => {\n frame.current?.setPointerCapture?.(event.pointerId);\n drag.current = { x: event.clientX, y: event.clientY, from: { x, y, zoom: value.zoom } };\n }}\n onPointerMove={(event) => {\n const start = drag.current;\n const box = frame.current?.getBoundingClientRect();\n if (!start || !box?.width) return;\n set({\n x: start.from.x - (event.clientX - start.x) / ((box.width * widthPct) / 100),\n y: start.from.y - (event.clientY - start.y) / ((box.height * heightPct) / 100),\n zoom: value.zoom,\n });\n }}\n onPointerUp={() => {\n drag.current = null;\n }}\n onWheel={(event) => {\n if (!event.ctrlKey && !event.metaKey) return;\n event.preventDefault();\n set({ x, y, zoom: value.zoom - event.deltaY * 0.01 });\n }}\n onKeyDown={(event) => {\n const nudge = event.shiftKey ? 0.1 : 0.02;\n const moves: Record<string, CropValue> = {\n ArrowLeft: { x: x - nudge, y, zoom: value.zoom },\n ArrowRight: { x: x + nudge, y, zoom: value.zoom },\n ArrowUp: { x, y: y - nudge, zoom: value.zoom },\n ArrowDown: { x, y: y + nudge, zoom: value.zoom },\n \"+\": { x, y, zoom: value.zoom + 0.1 },\n \"=\": { x, y, zoom: value.zoom + 0.1 },\n \"-\": { x, y, zoom: value.zoom - 0.1 },\n };\n const next = moves[event.key];\n if (!next) return;\n event.preventDefault();\n set(next);\n }}\n >\n {children ? (\n React.cloneElement(children, { className: s.media({ className: children.props.className }), style: { ...children.props.style, ...style } })\n ) : src ? (\n <img\n src={src}\n alt={alt}\n draggable={false}\n className={s.media()}\n style={style}\n onLoad={(event) => setLoaded({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}\n />\n ) : null}\n <span className={round ? s.round() : s.grid()} aria-hidden />\n <span id={`${id}-help`} className=\"sr-only\">\n {labels.instructions}\n </span>\n </div>\n {showZoom && (\n <Slider\n label={labels.zoom}\n min={1}\n max={maxZoom}\n step={0.05}\n value={value.zoom}\n onValueChange={(zoom) => set({ x, y, zoom })}\n formatValue={(v) => `${Math.round(v * 100)} %`}\n />\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/image-cropper/image-cropper.tsx"],"sourcesContent":["// Client. A fixed frame (a profile picture's square, a vertical 9:16, a banner) over a picture or a video the person\n// drags and zooms to choose what stays in. The value is a focus point (0–1 on each axis) and a zoom — independent of\n// the screen, the same on the server — and `cropRect` turns it into pixels of the source. Keyboard: the frame takes the\n// focus, the arrows move (Shift: faster), + / − zoom; the zoom is also a slider. No motion but the person's own.\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { clamp } from \"../media\";\nimport { Slider } from \"../slider\";\n\nexport const imageCropper = tv({\n slots: {\n root: \"flex w-full flex-col gap-3\",\n frame:\n \"relative w-full cursor-grab touch-none overflow-hidden rounded-xl bg-media active:cursor-grabbing \" +\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 media: \"pointer-events-none absolute max-w-none select-none\",\n grid:\n \"pointer-events-none absolute inset-0 rounded-xl border-2 border-fg-on-media/80 \" +\n \"bg-[linear-gradient(to_right,transparent_33.2%,var(--kz-text-on-media)_33.3%,transparent_33.5%,transparent_66.5%,var(--kz-text-on-media)_66.6%,transparent_66.8%),linear-gradient(to_bottom,transparent_33.2%,var(--kz-text-on-media)_33.3%,transparent_33.5%,transparent_66.5%,var(--kz-text-on-media)_66.6%,transparent_66.8%)] opacity-40\",\n round: \"pointer-events-none absolute inset-0 rounded-full shadow-[0_0_0_9999px_var(--kz-scrim-strong)]\",\n },\n});\n\n/** Where the frame looks: the focus point (0–1 from the left and the top of the source) and the zoom (1 = covers). */\nexport type CropValue = { x: number; y: number; zoom: number };\n\n/** The crop in pixels of the source: what to cut from an image of `width` × `height` for a frame of `aspect`. */\nexport function cropRect(value: CropValue, source: { width: number; height: number }, aspect: number) {\n const mediaAspect = source.width / source.height;\n const width = (mediaAspect > aspect ? source.height * aspect : source.width) / value.zoom;\n const height = width / aspect;\n const x = clamp(value.x * source.width - width / 2, 0, source.width - width);\n const y = clamp(value.y * source.height - height / 2, 0, source.height - height);\n return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };\n}\n\nexport type ImageCropperLabels = {\n /** The frame's accessible name (\"Crop area\"). */\n frame: string;\n /** How to use it with the keyboard, read with the frame (\"Use the arrow keys to move the picture, + and − to zoom.\"). */\n instructions: string;\n /** The zoom slider (\"Zoom\"). */\n zoom: string;\n};\n\nexport const imageCropperLabels: ImageCropperLabels = {\n frame: \"Crop area\",\n instructions: \"Drag or use the arrow keys to move the picture, + and − to zoom.\",\n zoom: \"Zoom\",\n};\n\nexport type ImageCropperProps = {\n /** The picture to crop. Or `children`: a `<video>` (with the class it is given through `mediaClassName`). */\n src?: string;\n /** The picture's alternative text, passed translated (empty when the frame's name says enough). */\n alt?: string;\n /** A media element instead of `src` — a `<video>` to choose the frame of a clip. Its natural size is in `naturalSize`. */\n children?: React.ReactElement<{ className?: string; style?: React.CSSProperties }>;\n /** The source's size when `children` is a video: `{ width: video.videoWidth, height: video.videoHeight }`. */\n naturalSize?: { width: number; height: number };\n /** The frame's width / height: 1 (a square), 9 / 16 (vertical), 3 (a banner). */\n aspect: number;\n /** A round mask over the square frame (a profile picture). */\n round?: boolean;\n /** The crop (controlled). */\n value?: CropValue;\n /** The crop at first, uncontrolled. Default: centred, no zoom. */\n defaultValue?: CropValue;\n /** Called on every move. */\n onValueChange?: (value: CropValue) => void;\n /** The largest zoom. Default 4. */\n maxZoom?: number;\n /** Shows the zoom slider under the frame. Default `true`. */\n showZoom?: boolean;\n /** The words, passed translated; English by default. */\n labels?: Partial<ImageCropperLabels>;\n /** Classes merged on the root. */\n className?: string;\n};\n\n/** Chooses what a fixed frame keeps of a picture or a video: drag, zoom, keyboard; a focus point and a zoom out. */\nexport function ImageCropper({\n src,\n alt = \"\",\n children,\n naturalSize,\n aspect,\n round,\n value: valueProp,\n defaultValue = { x: 0.5, y: 0.5, zoom: 1 },\n onValueChange,\n maxZoom = 4,\n showZoom = true,\n labels: given,\n className,\n}: ImageCropperProps) {\n const labels = { ...imageCropperLabels, ...given };\n const s = imageCropper();\n const frame = React.useRef<HTMLDivElement>(null);\n const drag = React.useRef<{ x: number; y: number; from: CropValue } | null>(null);\n const [inner, setInner] = React.useState(defaultValue);\n const [loaded, setLoaded] = React.useState<{ width: number; height: number } | null>(null);\n const value = valueProp ?? inner;\n const id = React.useId();\n const size = naturalSize ?? loaded;\n const mediaAspect = size ? size.width / size.height : aspect;\n\n // The media's box, in % of the frame: it covers the frame at zoom 1.\n const widthPct = (mediaAspect > aspect ? (mediaAspect / aspect) * 100 : 100) * value.zoom;\n const heightPct = (mediaAspect > aspect ? 100 : (aspect / mediaAspect) * 100) * value.zoom;\n // The focus point's limits: the media never leaves a gap in the frame.\n const halfX = 50 / widthPct;\n const halfY = 50 / heightPct;\n\n const set = (next: CropValue) => {\n const zoom = clamp(next.zoom, 1, maxZoom);\n const wPct = (mediaAspect > aspect ? (mediaAspect / aspect) * 100 : 100) * zoom;\n const hPct = (mediaAspect > aspect ? 100 : (aspect / mediaAspect) * 100) * zoom;\n const fixed = { x: clamp(next.x, 50 / wPct, 1 - 50 / wPct), y: clamp(next.y, 50 / hPct, 1 - 50 / hPct), zoom };\n if (!valueProp) setInner(fixed);\n onValueChange?.(fixed);\n };\n\n const x = clamp(value.x, halfX, 1 - halfX);\n const y = clamp(value.y, halfY, 1 - halfY);\n // Rounded: float noise (5e-15) is not a length every parser takes, and a dropped `top` is a gap.\n const pct = (n: number) => `${Math.round(n * 1e4) / 1e4}%`;\n const style: React.CSSProperties = {\n width: pct(widthPct),\n height: pct(heightPct),\n left: pct(50 - x * widthPct),\n top: pct(50 - y * heightPct),\n };\n\n return (\n <div className={s.root({ className })}>\n <div\n ref={frame}\n role=\"group\"\n tabIndex={0}\n aria-label={labels.frame}\n aria-describedby={`${id}-help`}\n className={s.frame()}\n style={{ aspectRatio: String(aspect) }}\n onPointerDown={(event) => {\n frame.current?.setPointerCapture?.(event.pointerId);\n drag.current = { x: event.clientX, y: event.clientY, from: { x, y, zoom: value.zoom } };\n }}\n onPointerMove={(event) => {\n const start = drag.current;\n const box = frame.current?.getBoundingClientRect();\n if (!start || !box?.width) return;\n set({\n x: start.from.x - (event.clientX - start.x) / ((box.width * widthPct) / 100),\n y: start.from.y - (event.clientY - start.y) / ((box.height * heightPct) / 100),\n zoom: value.zoom,\n });\n }}\n onPointerUp={() => {\n drag.current = null;\n }}\n onWheel={(event) => {\n if (!event.ctrlKey && !event.metaKey) return;\n event.preventDefault();\n set({ x, y, zoom: value.zoom - event.deltaY * 0.01 });\n }}\n onKeyDown={(event) => {\n const nudge = event.shiftKey ? 0.1 : 0.02;\n const moves: Record<string, CropValue> = {\n ArrowLeft: { x: x - nudge, y, zoom: value.zoom },\n ArrowRight: { x: x + nudge, y, zoom: value.zoom },\n ArrowUp: { x, y: y - nudge, zoom: value.zoom },\n ArrowDown: { x, y: y + nudge, zoom: value.zoom },\n \"+\": { x, y, zoom: value.zoom + 0.1 },\n \"=\": { x, y, zoom: value.zoom + 0.1 },\n \"-\": { x, y, zoom: value.zoom - 0.1 },\n };\n const next = moves[event.key];\n if (!next) return;\n event.preventDefault();\n set(next);\n }}\n >\n {children ? (\n React.cloneElement(children, { className: s.media({ className: children.props.className }), style: { ...children.props.style, ...style } })\n ) : src ? (\n <img\n src={src}\n alt={alt}\n draggable={false}\n className={s.media()}\n style={style}\n // A picture already in the cache (or rendered on the server) is complete before React attaches: no load event.\n ref={(img) => {\n if (img?.complete && img.naturalWidth > 0) setLoaded((was) => (was?.width === img.naturalWidth && was.height === img.naturalHeight ? was : { width: img.naturalWidth, height: img.naturalHeight }));\n }}\n onLoad={(event) => setLoaded({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}\n />\n ) : null}\n <span className={round ? s.round() : s.grid()} aria-hidden />\n <span id={`${id}-help`} className=\"sr-only\">\n {labels.instructions}\n </span>\n </div>\n {showZoom && (\n <Slider\n label={labels.zoom}\n min={1}\n max={maxZoom}\n step={0.05}\n value={value.zoom}\n onValueChange={(zoom) => set({ x, y, zoom })}\n formatValue={(v) => `${Math.round(v * 100)} %`}\n />\n )}\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;AAIA,YAAY,WAAW;AACvB,SAAS,UAAU;AAoIb,SAkDI,KAlDJ;AA/HC,IAAM,eAAe,GAAG;AAAA,EAC7B,OAAO;AAAA,IACL,MAAM;AAAA,IACN,OACE;AAAA,IAEF,OAAO;AAAA,IACP,MACE;AAAA,IAEF,OAAO;AAAA,EACT;AACF,CAAC;AAMM,SAAS,SAAS,OAAkB,QAA2C,QAAgB;AACpG,QAAM,cAAc,OAAO,QAAQ,OAAO;AAC1C,QAAM,SAAS,cAAc,SAAS,OAAO,SAAS,SAAS,OAAO,SAAS,MAAM;AACrF,QAAM,SAAS,QAAQ;AACvB,QAAM,IAAI,MAAM,MAAM,IAAI,OAAO,QAAQ,QAAQ,GAAG,GAAG,OAAO,QAAQ,KAAK;AAC3E,QAAM,IAAI,MAAM,MAAM,IAAI,OAAO,SAAS,SAAS,GAAG,GAAG,OAAO,SAAS,MAAM;AAC/E,SAAO,EAAE,GAAG,KAAK,MAAM,CAAC,GAAG,GAAG,KAAK,MAAM,CAAC,GAAG,OAAO,KAAK,MAAM,KAAK,GAAG,QAAQ,KAAK,MAAM,MAAM,EAAE;AACpG;AAWO,IAAM,qBAAyC;AAAA,EACpD,OAAO;AAAA,EACP,cAAc;AAAA,EACd,MAAM;AACR;AAgCO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,eAAe,EAAE,GAAG,KAAK,GAAG,KAAK,MAAM,EAAE;AAAA,EACzC;AAAA,EACA,UAAU;AAAA,EACV,WAAW;AAAA,EACX,QAAQ;AAAA,EACR;AACF,GAAsB;AACpB,QAAM,SAAS,EAAE,GAAG,oBAAoB,GAAG,MAAM;AACjD,QAAM,IAAI,aAAa;AACvB,QAAM,QAAc,aAAuB,IAAI;AAC/C,QAAM,OAAa,aAAyD,IAAI;AAChF,QAAM,CAAC,OAAO,QAAQ,IAAU,eAAS,YAAY;AACrD,QAAM,CAAC,QAAQ,SAAS,IAAU,eAAmD,IAAI;AACzF,QAAM,QAAQ,aAAa;AAC3B,QAAM,KAAW,YAAM;AACvB,QAAM,OAAO,eAAe;AAC5B,QAAM,cAAc,OAAO,KAAK,QAAQ,KAAK,SAAS;AAGtD,QAAM,YAAY,cAAc,SAAU,cAAc,SAAU,MAAM,OAAO,MAAM;AACrF,QAAM,aAAa,cAAc,SAAS,MAAO,SAAS,cAAe,OAAO,MAAM;AAEtF,QAAM,QAAQ,KAAK;AACnB,QAAM,QAAQ,KAAK;AAEnB,QAAM,MAAM,CAAC,SAAoB;AAC/B,UAAM,OAAO,MAAM,KAAK,MAAM,GAAG,OAAO;AACxC,UAAM,QAAQ,cAAc,SAAU,cAAc,SAAU,MAAM,OAAO;AAC3E,UAAM,QAAQ,cAAc,SAAS,MAAO,SAAS,cAAe,OAAO;AAC3E,UAAM,QAAQ,EAAE,GAAG,MAAM,KAAK,GAAG,KAAK,MAAM,IAAI,KAAK,IAAI,GAAG,GAAG,MAAM,KAAK,GAAG,KAAK,MAAM,IAAI,KAAK,IAAI,GAAG,KAAK;AAC7G,QAAI,CAAC,UAAW,UAAS,KAAK;AAC9B,oBAAgB,KAAK;AAAA,EACvB;AAEA,QAAM,IAAI,MAAM,MAAM,GAAG,OAAO,IAAI,KAAK;AACzC,QAAM,IAAI,MAAM,MAAM,GAAG,OAAO,IAAI,KAAK;AAEzC,QAAM,MAAM,CAAC,MAAc,GAAG,KAAK,MAAM,IAAI,GAAG,IAAI,GAAG;AACvD,QAAM,QAA6B;AAAA,IACjC,OAAO,IAAI,QAAQ;AAAA,IACnB,QAAQ,IAAI,SAAS;AAAA,IACrB,MAAM,IAAI,KAAK,IAAI,QAAQ;AAAA,IAC3B,KAAK,IAAI,KAAK,IAAI,SAAS;AAAA,EAC7B;AAEA,SACE,qBAAC,SAAI,WAAW,EAAE,KAAK,EAAE,UAAU,CAAC,GAClC;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,UAAU;AAAA,QACV,cAAY,OAAO;AAAA,QACnB,oBAAkB,GAAG,EAAE;AAAA,QACvB,WAAW,EAAE,MAAM;AAAA,QACnB,OAAO,EAAE,aAAa,OAAO,MAAM,EAAE;AAAA,QACrC,eAAe,CAAC,UAAU;AACxB,gBAAM,SAAS,oBAAoB,MAAM,SAAS;AAClD,eAAK,UAAU,EAAE,GAAG,MAAM,SAAS,GAAG,MAAM,SAAS,MAAM,EAAE,GAAG,GAAG,MAAM,MAAM,KAAK,EAAE;AAAA,QACxF;AAAA,QACA,eAAe,CAAC,UAAU;AACxB,gBAAM,QAAQ,KAAK;AACnB,gBAAM,MAAM,MAAM,SAAS,sBAAsB;AACjD,cAAI,CAAC,SAAS,CAAC,KAAK,MAAO;AAC3B,cAAI;AAAA,YACF,GAAG,MAAM,KAAK,KAAK,MAAM,UAAU,MAAM,MAAO,IAAI,QAAQ,WAAY;AAAA,YACxE,GAAG,MAAM,KAAK,KAAK,MAAM,UAAU,MAAM,MAAO,IAAI,SAAS,YAAa;AAAA,YAC1E,MAAM,MAAM;AAAA,UACd,CAAC;AAAA,QACH;AAAA,QACA,aAAa,MAAM;AACjB,eAAK,UAAU;AAAA,QACjB;AAAA,QACA,SAAS,CAAC,UAAU;AAClB,cAAI,CAAC,MAAM,WAAW,CAAC,MAAM,QAAS;AACtC,gBAAM,eAAe;AACrB,cAAI,EAAE,GAAG,GAAG,MAAM,MAAM,OAAO,MAAM,SAAS,KAAK,CAAC;AAAA,QACtD;AAAA,QACA,WAAW,CAAC,UAAU;AACpB,gBAAM,QAAQ,MAAM,WAAW,MAAM;AACrC,gBAAM,QAAmC;AAAA,YACvC,WAAW,EAAE,GAAG,IAAI,OAAO,GAAG,MAAM,MAAM,KAAK;AAAA,YAC/C,YAAY,EAAE,GAAG,IAAI,OAAO,GAAG,MAAM,MAAM,KAAK;AAAA,YAChD,SAAS,EAAE,GAAG,GAAG,IAAI,OAAO,MAAM,MAAM,KAAK;AAAA,YAC7C,WAAW,EAAE,GAAG,GAAG,IAAI,OAAO,MAAM,MAAM,KAAK;AAAA,YAC/C,KAAK,EAAE,GAAG,GAAG,MAAM,MAAM,OAAO,IAAI;AAAA,YACpC,KAAK,EAAE,GAAG,GAAG,MAAM,MAAM,OAAO,IAAI;AAAA,YACpC,KAAK,EAAE,GAAG,GAAG,MAAM,MAAM,OAAO,IAAI;AAAA,UACtC;AACA,gBAAM,OAAO,MAAM,MAAM,GAAG;AAC5B,cAAI,CAAC,KAAM;AACX,gBAAM,eAAe;AACrB,cAAI,IAAI;AAAA,QACV;AAAA,QAEC;AAAA,qBACO,mBAAa,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,SAAS,MAAM,UAAU,CAAC,GAAG,OAAO,EAAE,GAAG,SAAS,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,IACxI,MACF;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,WAAW;AAAA,cACX,WAAW,EAAE,MAAM;AAAA,cACnB;AAAA,cAEA,KAAK,CAAC,QAAQ;AACZ,oBAAI,KAAK,YAAY,IAAI,eAAe,EAAG,WAAU,CAAC,QAAS,KAAK,UAAU,IAAI,gBAAgB,IAAI,WAAW,IAAI,gBAAgB,MAAM,EAAE,OAAO,IAAI,cAAc,QAAQ,IAAI,cAAc,CAAE;AAAA,cACpM;AAAA,cACA,QAAQ,CAAC,UAAU,UAAU,EAAE,OAAO,MAAM,cAAc,cAAc,QAAQ,MAAM,cAAc,cAAc,CAAC;AAAA;AAAA,UACrH,IACE;AAAA,UACJ,oBAAC,UAAK,WAAW,QAAQ,EAAE,MAAM,IAAI,EAAE,KAAK,GAAG,eAAW,MAAC;AAAA,UAC3D,oBAAC,UAAK,IAAI,GAAG,EAAE,SAAS,WAAU,WAC/B,iBAAO,cACV;AAAA;AAAA;AAAA,IACF;AAAA,IACC,YACC;AAAA,MAAC;AAAA;AAAA,QACC,OAAO,OAAO;AAAA,QACd,KAAK;AAAA,QACL,KAAK;AAAA,QACL,MAAM;AAAA,QACN,OAAO,MAAM;AAAA,QACb,eAAe,CAAC,SAAS,IAAI,EAAE,GAAG,GAAG,KAAK,CAAC;AAAA,QAC3C,aAAa,CAAC,MAAM,GAAG,KAAK,MAAM,IAAI,GAAG,CAAC;AAAA;AAAA,IAC5C;AAAA,KAEJ;AAEJ;","names":[]}
package/dist/index.d.ts CHANGED
@@ -56,14 +56,9 @@ declare function ProductLogo({ id, ...props }: MarkProps & {
56
56
  }): React.JSX.Element;
57
57
 
58
58
  /**
59
- * The Wu-sama mark — the founder's byline mark, for the person and not the company (the company keeps
60
- * `KrizakaLogo`). Oussama's nickname is Wu-sama: a W of five straight legs in gold inside the Krizaka hexagon, split
61
- * at its top and bottom vertices, a small lit node at the middle summit. Still: no orbit, no pulse. The hexagon and the
62
- * node read the host's text role, so they are ink in both themes; the gold is one mid-tone amber that reads on both.
63
- *
64
- * **Original mark, inspired by, not derived from**: it nods to the culture its name comes from but is built from the
65
- * Krizaka hexagon's own measures (see `wu-sama-geometry.ts` and its tests), never from the Wu-Tang Clan's registered
66
- * logo — no bat-wing W, no curve, no yellow-on-black copy. Below 48 px it crops to the emblem.
59
+ * The Wu-sama mark — the founder's byline mark, for the person and not the company (the company keeps `KrizakaLogo`).
60
+ * Oussama's nickname is Wu-sama: it shares Krizaka's hexagonal shield contour and color palette (ink shield, open
61
+ * vertex with lit blue node), with the W rendered in Krizaka blue at the center. Below 48 px it crops to the emblem.
67
62
  */
68
63
  declare function WuSamaMark({ size, title, className }: Omit<MarkProps, "animated"> & {
69
64
  animated?: boolean;