@krizaka/ui 3.11.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/avatar.d.ts +51 -2
- package/dist/avatar.js +91 -4
- package/dist/avatar.js.map +1 -1
- package/dist/chat.js +1 -1
- package/dist/chunk-2JFARUKC.js +83 -0
- package/dist/chunk-2JFARUKC.js.map +1 -0
- package/dist/{chunk-OT6HX3PN.js → chunk-COJ3IDE2.js} +5 -2
- package/dist/chunk-COJ3IDE2.js.map +1 -0
- package/dist/{chunk-HG65YAV4.js → chunk-GR5JNFGQ.js} +110 -32
- package/dist/chunk-GR5JNFGQ.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +3 -1
- package/dist/map.js +30 -0
- package/dist/map.js.map +1 -1
- package/dist/match-dialog.js +1 -1
- package/dist/native/map.js +30 -0
- package/dist/native/map.js.map +1 -1
- package/dist/native.d.ts +42 -3
- package/dist/native.js +559 -448
- package/dist/native.js.map +1 -1
- package/dist/nav-bar.d.ts +1 -1
- package/dist/nav-bar.js +2 -2
- package/dist/showcase.js +1 -1
- package/dist/showcase.js.map +1 -1
- package/dist/site-footer.d.ts +1 -1
- package/dist/site-footer.js +7 -7
- package/dist/site-footer.js.map +1 -1
- package/dist/{sites-CgJrxejx.d.ts → sites-ClGou6ou.d.ts} +1 -1
- package/dist/sites.d.ts +2 -2
- package/dist/sites.js +3 -0
- package/dist/sites.js.map +1 -1
- package/dist/vertical-video.js +1 -1
- package/package.json +8 -7
- package/registry/avatar.json +122 -3
- package/registry/examples/avatar/default.d.ts +4 -0
- package/registry/examples/avatar/default.js +12 -0
- package/registry/examples/avatar/default.tsx +13 -0
- package/registry/index.json +5 -5
- package/registry/map.json +1 -1
- package/registry/nav-bar.json +10 -2
- package/registry/showcase.json +1 -1
- package/registry/site-footer.json +11 -3
- package/registry/sites.json +1 -1
- package/dist/chunk-CURW36FI.js +0 -47
- package/dist/chunk-CURW36FI.js.map +0 -1
- package/dist/chunk-HG65YAV4.js.map +0 -1
- package/dist/chunk-OT6HX3PN.js.map +0 -1
package/registry/avatar.json
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
],
|
|
24
24
|
"bestPractices": [
|
|
25
25
|
"Always pass a `fallback` (initials) on the web: images fail, and the layout should not jump.",
|
|
26
|
+
"A profile can lack a photo: pass `defaultAvatar={{ brand, seed: user.id }}` and the person gets the product's own default picture (a silhouette on the brand's gradient, one of six tones, always the same for the same id) instead of an empty circle. It also replaces a photo that fails to load. `fallbackSrc` is the same for your own image; `kind: \"plain\"` is the gradient alone, for initials.",
|
|
27
|
+
"The default pictures are generated from the code (`defaultAvatarSvg`) and served by the Krizaka CDN (`defaultAvatarUrl`, 256 and 512 px WebP, SVG): a product never draws its own.",
|
|
26
28
|
"Use one size per list: `sm` in dense lists, `md` by default, `lg`/`xl` on a profile.",
|
|
27
29
|
"In a group, set `max` so a long list ends with `+n` instead of overflowing."
|
|
28
30
|
],
|
|
@@ -53,6 +55,13 @@
|
|
|
53
55
|
"description": "The image's text alternative (the person's name); empty when a visible name sits next to the avatar.",
|
|
54
56
|
"required": false
|
|
55
57
|
},
|
|
58
|
+
{
|
|
59
|
+
"name": "defaultAvatar",
|
|
60
|
+
"type": "DefaultAvatar",
|
|
61
|
+
"default": null,
|
|
62
|
+
"description": "The Krizaka default picture when the person has no photo (or it fails): their brand's gradient and a silhouette,\none of six tones from `seed` — `defaultAvatar={{ brand: \"orochia\", seed: user.id }}`. `fallbackSrc` wins over it.",
|
|
63
|
+
"required": false
|
|
64
|
+
},
|
|
56
65
|
{
|
|
57
66
|
"name": "fallback",
|
|
58
67
|
"type": "ReactNode",
|
|
@@ -60,6 +69,13 @@
|
|
|
60
69
|
"description": "Shown while the image loads, when it fails or when there is none: initials, an icon.",
|
|
61
70
|
"required": false
|
|
62
71
|
},
|
|
72
|
+
{
|
|
73
|
+
"name": "fallbackSrc",
|
|
74
|
+
"type": "string",
|
|
75
|
+
"default": null,
|
|
76
|
+
"description": "The picture to show instead when `src` is missing or fails to load (an image URL). The `fallback` still covers its own loading.",
|
|
77
|
+
"required": false
|
|
78
|
+
},
|
|
63
79
|
{
|
|
64
80
|
"name": "size",
|
|
65
81
|
"type": "AvatarSize",
|
|
@@ -107,6 +123,13 @@
|
|
|
107
123
|
"description": "The image's text alternative (the person's name); empty when a visible name sits next to the avatar.",
|
|
108
124
|
"required": false
|
|
109
125
|
},
|
|
126
|
+
{
|
|
127
|
+
"name": "defaultAvatar",
|
|
128
|
+
"type": "DefaultAvatar",
|
|
129
|
+
"default": null,
|
|
130
|
+
"description": "The Krizaka default picture when the person has no photo (or it fails): their brand's gradient and a silhouette,\none of six tones from `seed` — `defaultAvatar={{ brand: \"orochia\", seed: user.id }}`. `fallbackSrc` wins over it.",
|
|
131
|
+
"required": false
|
|
132
|
+
},
|
|
110
133
|
{
|
|
111
134
|
"name": "fallback",
|
|
112
135
|
"type": "ReactNode",
|
|
@@ -114,6 +137,13 @@
|
|
|
114
137
|
"description": "Shown while the image loads, when it fails or when there is none: initials, an icon.",
|
|
115
138
|
"required": false
|
|
116
139
|
},
|
|
140
|
+
{
|
|
141
|
+
"name": "fallbackSrc",
|
|
142
|
+
"type": "string",
|
|
143
|
+
"default": null,
|
|
144
|
+
"description": "The picture to show instead when `src` is missing or fails to load (an image URL). The `fallback` still covers its own loading.",
|
|
145
|
+
"required": false
|
|
146
|
+
},
|
|
117
147
|
{
|
|
118
148
|
"name": "size",
|
|
119
149
|
"type": "AvatarSize",
|
|
@@ -129,6 +159,16 @@
|
|
|
129
159
|
"required": false
|
|
130
160
|
}
|
|
131
161
|
]
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"component": "DEFAULT_AVATAR_BASE",
|
|
165
|
+
"description": "Where the set lives. `v1` is the set's version: a changed picture is a new version folder, never an overwrite.",
|
|
166
|
+
"props": []
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"component": "DEFAULT_AVATAR_TONES",
|
|
170
|
+
"description": "How many tones each brand has: a person always gets the same one from their seed, so a list is not one colour.",
|
|
171
|
+
"props": []
|
|
132
172
|
}
|
|
133
173
|
],
|
|
134
174
|
"examples": [
|
|
@@ -140,6 +180,14 @@
|
|
|
140
180
|
"module": "@krizaka/ui/registry/examples/avatar/with-image",
|
|
141
181
|
"code": "import { Avatar } from \"@krizaka/ui/avatar\";\n\n// A portrait: the image when it loads, the initials while it loads or when it fails.\nexport default function AvatarWithImage() {\n return <Avatar src=\"https://krizaka-assets.b-cdn.net/site/people/ui/ada-256.31a5ce640c.webp\" alt=\"Ada Okafor\" fallback=\"AO\" />;\n}\n"
|
|
142
182
|
},
|
|
183
|
+
{
|
|
184
|
+
"name": "default",
|
|
185
|
+
"title": "Default pictures",
|
|
186
|
+
"description": "No photo: `defaultAvatar={{ brand, seed }}` — the brand's gradient and a silhouette, one of six tones per person.",
|
|
187
|
+
"path": "examples/avatar/default.tsx",
|
|
188
|
+
"module": "@krizaka/ui/registry/examples/avatar/default",
|
|
189
|
+
"code": "import { Avatar } from \"@krizaka/ui/avatar\";\n\n// No photo: the brand's default picture, one of six tones from the person's id — the same one on every screen.\nexport default function AvatarDefault() {\n const people = [\"ada\", \"camille\", \"noor\", \"tomas\", \"ines\", \"kofi\"];\n return (\n <div className=\"flex items-center gap-3\">\n {people.map((id) => (\n <Avatar key={id} defaultAvatar={{ brand: \"orochia\", seed: id }} alt={id} fallback={id.slice(0, 2)} size=\"lg\" />\n ))}\n </div>\n );\n}\n"
|
|
190
|
+
},
|
|
143
191
|
{
|
|
144
192
|
"name": "fallback",
|
|
145
193
|
"title": "Fallback",
|
|
@@ -172,6 +220,7 @@
|
|
|
172
220
|
"differences": [
|
|
173
221
|
"The fallback is the initial of `alt` when `fallback` is not given.",
|
|
174
222
|
"`size` also takes a number of points.",
|
|
223
|
+
"`defaultAvatar` and `fallbackSrc` work as on the web (the default pictures are WebP, drawn by `Image`); `defaultAvatarUrl` is exported from `@krizaka/ui/native`.",
|
|
175
224
|
"`src` is a URI string (an `Image` source), not a URL object.",
|
|
176
225
|
"An SVG `src` (`….svg`, `data:image/svg+xml,…`, or any URI with `svg`) is drawn by react-native-svg — `Image` cannot.",
|
|
177
226
|
"`presence` adds a dot (online, away, busy, offline) and `ring` a story ring (new, seen, live): mobile only for now."
|
|
@@ -188,6 +237,13 @@
|
|
|
188
237
|
"description": "The person's name: the image's accessible name and the source of the default initial.",
|
|
189
238
|
"required": false
|
|
190
239
|
},
|
|
240
|
+
{
|
|
241
|
+
"name": "defaultAvatar",
|
|
242
|
+
"type": "DefaultAvatar",
|
|
243
|
+
"default": null,
|
|
244
|
+
"description": "The Krizaka default picture (the brand's gradient and a silhouette, one of six tones from `seed`) when there is no photo; `fallbackSrc` wins over it.",
|
|
245
|
+
"required": false
|
|
246
|
+
},
|
|
191
247
|
{
|
|
192
248
|
"name": "fallback",
|
|
193
249
|
"type": "ReactNode",
|
|
@@ -195,6 +251,13 @@
|
|
|
195
251
|
"description": "Shown while there is no image or when it fails: default, the first letter of `alt`.",
|
|
196
252
|
"required": false
|
|
197
253
|
},
|
|
254
|
+
{
|
|
255
|
+
"name": "fallbackSrc",
|
|
256
|
+
"type": "string",
|
|
257
|
+
"default": null,
|
|
258
|
+
"description": "The picture to show when `src` is missing or fails to load (an image URI); `fallback` still covers the case of no picture at all.",
|
|
259
|
+
"required": false
|
|
260
|
+
},
|
|
198
261
|
{
|
|
199
262
|
"name": "presence",
|
|
200
263
|
"type": "\"online\" | \"away\" | \"busy\" | \"offline\"",
|
|
@@ -270,6 +333,13 @@
|
|
|
270
333
|
"description": "The person's name: the image's accessible name and the source of the default initial.",
|
|
271
334
|
"required": false
|
|
272
335
|
},
|
|
336
|
+
{
|
|
337
|
+
"name": "defaultAvatar",
|
|
338
|
+
"type": "DefaultAvatar",
|
|
339
|
+
"default": null,
|
|
340
|
+
"description": "The Krizaka default picture (the brand's gradient and a silhouette, one of six tones from `seed`) when there is no photo; `fallbackSrc` wins over it.",
|
|
341
|
+
"required": false
|
|
342
|
+
},
|
|
273
343
|
{
|
|
274
344
|
"name": "fallback",
|
|
275
345
|
"type": "ReactNode",
|
|
@@ -277,6 +347,13 @@
|
|
|
277
347
|
"description": "Shown while there is no image or when it fails: default, the first letter of `alt`.",
|
|
278
348
|
"required": false
|
|
279
349
|
},
|
|
350
|
+
{
|
|
351
|
+
"name": "fallbackSrc",
|
|
352
|
+
"type": "string",
|
|
353
|
+
"default": null,
|
|
354
|
+
"description": "The picture to show when `src` is missing or fails to load (an image URI); `fallback` still covers the case of no picture at all.",
|
|
355
|
+
"required": false
|
|
356
|
+
},
|
|
280
357
|
{
|
|
281
358
|
"name": "presence",
|
|
282
359
|
"type": "\"online\" | \"away\" | \"busy\" | \"offline\"",
|
|
@@ -364,15 +441,19 @@
|
|
|
364
441
|
"files": [
|
|
365
442
|
{
|
|
366
443
|
"path": "avatar/avatar.tsx",
|
|
367
|
-
"content": "// Client: Radix tracks the image's loading state and shows the fallback until it has loaded (or when it fails).\nimport { Avatar as AvatarPrimitive } from \"radix-ui\";\nimport * as React from \"react\";\nimport { tv, type VariantProps } from \"tailwind-variants\";\n\nconst avatar = tv({\n slots: {\n root: \"relative inline-flex shrink-0 overflow-hidden rounded-full bg-surface-3 align-middle\",\n image: \"h-full w-full object-cover\",\n fallback: \"flex h-full w-full items-center justify-center font-semibold uppercase text-fg-secondary\",\n group: \"flex items-center -space-x-2 [&>*]:ring-2 [&>*]:ring-surface-0\",\n },\n variants: {\n size: {\n xs: { root: \"h-5 w-5 text-[9px]\" },\n sm: { root: \"h-8 w-8 text-xs\" },\n md: { root: \"h-10 w-10 text-sm\" },\n lg: { root: \"h-14 w-14 text-base\" },\n xl: { root: \"h-20 w-20 text-lg\" },\n },\n },\n defaultVariants: { size: \"md\" },\n});\n\nexport type AvatarSize = NonNullable<VariantProps<typeof avatar>[\"size\"]>;\n\nexport type AvatarProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n /** The image's URL; `null` or nothing shows the `fallback`. */\n src?: string | null;\n /** The image's text alternative (the person's name); empty when a visible name sits next to the avatar. */\n alt?: string;\n /** Shown while the image loads, when it fails or when there is none: initials, an icon. */\n fallback?: React.ReactNode;\n /**\n * xs · sm · md · lg · xl.\n * @default \"md\"\n */\n size?: AvatarSize;\n};\n\nexport function AvatarRoot({ src, alt = \"\", fallback, size, className, ...props }: AvatarProps) {\n const s = avatar({ size });\n return (\n <AvatarPrimitive.Root data-size={size ?? \"md\"} className={s.root({ className })} {...props}>\n {
|
|
444
|
+
"content": "// Client: Radix tracks the image's loading state and shows the fallback until it has loaded (or when it fails).\nimport { Avatar as AvatarPrimitive } from \"radix-ui\";\nimport * as React from \"react\";\nimport { tv, type VariantProps } from \"tailwind-variants\";\n\nimport { type DefaultAvatar, defaultAvatarUrl } from \"./default-avatar\";\n\nconst avatar = tv({\n slots: {\n root: \"relative inline-flex shrink-0 overflow-hidden rounded-full bg-surface-3 align-middle\",\n image: \"h-full w-full object-cover\",\n fallback: \"flex h-full w-full items-center justify-center font-semibold uppercase text-fg-secondary\",\n group: \"flex items-center -space-x-2 [&>*]:ring-2 [&>*]:ring-surface-0\",\n },\n variants: {\n size: {\n xs: { root: \"h-5 w-5 text-[9px]\" },\n sm: { root: \"h-8 w-8 text-xs\" },\n md: { root: \"h-10 w-10 text-sm\" },\n lg: { root: \"h-14 w-14 text-base\" },\n xl: { root: \"h-20 w-20 text-lg\" },\n },\n },\n defaultVariants: { size: \"md\" },\n});\n\nexport type AvatarSize = NonNullable<VariantProps<typeof avatar>[\"size\"]>;\n\nexport type AvatarProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n /** The image's URL; `null` or nothing shows the `fallback`. */\n src?: string | null;\n /** The image's text alternative (the person's name); empty when a visible name sits next to the avatar. */\n alt?: string;\n /** Shown while the image loads, when it fails or when there is none: initials, an icon. */\n fallback?: React.ReactNode;\n /** The picture to show instead when `src` is missing or fails to load (an image URL). The `fallback` still covers its own loading. */\n fallbackSrc?: string;\n /**\n * The Krizaka default picture when the person has no photo (or it fails): their brand's gradient and a silhouette,\n * one of six tones from `seed` — `defaultAvatar={{ brand: \"orochia\", seed: user.id }}`. `fallbackSrc` wins over it.\n */\n defaultAvatar?: DefaultAvatar;\n /**\n * xs · sm · md · lg · xl.\n * @default \"md\"\n */\n size?: AvatarSize;\n};\n\nexport function AvatarRoot({ src, alt = \"\", fallback, fallbackSrc, defaultAvatar, size, className, ...props }: AvatarProps) {\n const s = avatar({ size });\n const [failed, setFailed] = React.useState<string | null>(null);\n // 512 px for the big sizes, 256 for the others: sharp on a 2x screen either way.\n const backup = fallbackSrc ?? (defaultAvatar ? defaultAvatarUrl(defaultAvatar, { size: size === \"xl\" || size === \"lg\" ? 512 : 256 }) : undefined);\n const shown = src && failed !== src ? src : backup;\n return (\n <AvatarPrimitive.Root data-size={size ?? \"md\"} className={s.root({ className })} {...props}>\n {shown && (\n <AvatarPrimitive.Image\n src={shown}\n alt={alt}\n className={s.image()}\n onLoadingStatusChange={(status) => {\n if (status === \"error\" && shown === src) setFailed(src);\n }}\n />\n )}\n <AvatarPrimitive.Fallback delayMs={shown ? 300 : 0} className={s.fallback()}>\n {fallback}\n </AvatarPrimitive.Fallback>\n </AvatarPrimitive.Root>\n );\n}\n\nexport type AvatarGroupProps = React.ComponentProps<\"div\"> & {\n /** How many avatars are shown; the rest becomes a `+n` avatar. */\n max?: number;\n /** The size of the `+n` avatar (match the avatars of the group). */\n size?: AvatarSize;\n};\n\n/** Overlapping avatars; past `max`, a `+n` avatar counts the rest. */\nexport function AvatarGroup({ max, size, className, children, ...props }: AvatarGroupProps) {\n const items = React.Children.toArray(children);\n const shown = max === undefined ? items : items.slice(0, max);\n const rest = items.length - shown.length;\n return (\n <div className={avatar().group({ className })} {...props}>\n {shown}\n {rest > 0 && <AvatarRoot size={size} fallback={`+${rest}`} />}\n </div>\n );\n}\n\n/** `Avatar` (an image with a fallback) and `Avatar.Group`. */\nexport const Avatar = Object.assign(AvatarRoot, { Group: AvatarGroup });\n"
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
"path": "avatar/default-avatar.ts",
|
|
448
|
+
"content": "// The default profile pictures of the Krizaka family: what a product shows when a person has no photo.\n// They are generated from this file (tools/export-brand.mjs renders them to SVG and WebP and uploads them to Bunny,\n// `people/defaults/v1/<brand>/…`), so the pictures and the URLs that name them cannot drift apart.\n\nexport type DefaultAvatarBrand = \"krizaka\" | \"orazaka\" | \"orochia\" | \"yasakia\" | \"yororis\" | \"yomizaka\";\n/** `person`: a neutral silhouette. `plain`: the brand's gradient alone, ready for initials. */\nexport type DefaultAvatarKind = \"person\" | \"plain\";\nexport type DefaultAvatarSize = 256 | 512;\n\nexport const DEFAULT_AVATAR_BRANDS: readonly DefaultAvatarBrand[] = [\"krizaka\", \"orazaka\", \"orochia\", \"yasakia\", \"yororis\", \"yomizaka\"];\nexport const DEFAULT_AVATAR_KINDS: readonly DefaultAvatarKind[] = [\"person\", \"plain\"];\nexport const DEFAULT_AVATAR_SIZES: readonly DefaultAvatarSize[] = [256, 512];\n/** How many tones each brand has: a person always gets the same one from their seed, so a list is not one colour. */\nexport const DEFAULT_AVATAR_TONES = 6;\n\n/** Where the set lives. `v1` is the set's version: a changed picture is a new version folder, never an overwrite. */\nexport const DEFAULT_AVATAR_BASE = \"https://krizaka-assets.b-cdn.net/people/defaults/v1\";\n\nexport interface DefaultAvatar {\n /** The brand of the product showing the avatar: its gradient, its hues. */\n brand: DefaultAvatarBrand;\n /** Any stable text or number of the person (their id): the same seed gives the same tone everywhere. */\n seed?: string | number;\n /** Force a tone, 1 to 6, instead of deriving it from `seed`. */\n tone?: number;\n /**\n * `person` (default): a silhouette. `plain`: the gradient alone.\n * @default \"person\"\n */\n kind?: DefaultAvatarKind;\n}\n\n/** The mark gradient of each brand (BRAND.md, `$extensions[\"com.krizaka\"].mark`) as [hue, saturation %, lightness %] and the hue spread of its tones. */\nconst BRANDS: Record<DefaultAvatarBrand, { from: [number, number, number]; to: [number, number, number]; spread: number }> = {\n krizaka: { from: [217, 92, 53], to: [217, 92, 68], spread: 14 },\n orazaka: { from: [26, 90.5, 37.1], to: [26, 92, 45], spread: 7 },\n orochia: { from: [262.1, 83.3, 57.8], to: [328.6, 85.5, 70.2], spread: 12 },\n yasakia: { from: [156, 92, 32], to: [178, 90, 32], spread: 12 },\n yororis: { from: [34, 46, 44], to: [104, 28, 42], spread: 10 },\n yomizaka: { from: [232, 50, 55], to: [248, 50, 58], spread: 10 },\n};\n\n/** White on top of a stop must stay visible: a stop is darkened until the contrast reaches this (WCAG 1.4.11, non-text). */\nconst MIN_CONTRAST = 3.2;\nconst OFFSETS = [0, -1, 1, -2, 2, 3] as const;\n\nfunction hslToHex([h, s, l]: [number, number, number]): string {\n const sat = s / 100;\n const light = l / 100;\n const k = (n: number) => (n + (((h % 360) + 360) % 360) / 30) % 12;\n const a = sat * Math.min(light, 1 - light);\n const channel = (n: number) => Math.round(255 * (light - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))));\n return `#${[0, 8, 4].map((n) => channel(n).toString(16).padStart(2, \"0\")).join(\"\")}`;\n}\n\nconst channelLuminance = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\nconst whiteContrast = (hex: string) => {\n const [r, g, b] = [1, 3, 5].map((i) => channelLuminance(Number.parseInt(hex.slice(i, i + 2), 16) / 255));\n return 1.05 / (0.2126 * r! + 0.7152 * g! + 0.0722 * b! + 0.05);\n};\n/** The stop, darkened one point of lightness at a time until white reads on it. */\nfunction readable([h, s, l]: [number, number, number]): string {\n let hex = hslToHex([h, s, l]);\n for (let light = l; whiteContrast(hex) < MIN_CONTRAST && light > 10; light -= 1) hex = hslToHex([h, s, light]);\n return hex;\n}\n\n/** The two stops (hex) of tone `tone` (1 to 6) of a brand. */\nexport function defaultAvatarTone(brand: DefaultAvatarBrand, tone: number): readonly [string, string] {\n const b = BRANDS[brand];\n const o = OFFSETS[(clampTone(tone) - 1) % OFFSETS.length] * b.spread;\n return [readable([b.from[0] + o, b.from[1], b.from[2]]), readable([b.to[0] + o, b.to[1], b.to[2]])];\n}\n\nconst clampTone = (tone: number) => Math.min(DEFAULT_AVATAR_TONES, Math.max(1, Math.round(tone)));\n\n/** 1 to 6 from a seed (FNV-1a): stable across renders, devices and products. */\nexport function defaultAvatarToneOf(seed: string | number | undefined): number {\n if (seed === undefined) return 1;\n let h = 0x811c9dc5;\n for (const ch of String(seed)) {\n h ^= ch.codePointAt(0) ?? 0;\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return (h % DEFAULT_AVATAR_TONES) + 1;\n}\n\nconst resolveTone = (a: DefaultAvatar) => (a.tone === undefined ? defaultAvatarToneOf(a.seed) : clampTone(a.tone));\n\n/** The file name of a picture in the set: `person-3` + 256 → `person-3-256.webp`; `svg` has no size. */\nexport function defaultAvatarFile(kind: DefaultAvatarKind, tone: number, size: DefaultAvatarSize | \"svg\"): string {\n return size === \"svg\" ? `${kind}-${tone}.svg` : `${kind}-${tone}-${size}.webp`;\n}\n\n/**\n * The URL of a default avatar: `defaultAvatarUrl({ brand: \"orochia\", seed: user.id })`.\n * @param size 256 for lists, 512 for a profile; `\"svg\"` for the vector.\n * @param base Another host or version of the set (a self-hosted copy); the default is the Krizaka CDN.\n */\nexport function defaultAvatarUrl(avatar: DefaultAvatar, { size = 256, base = DEFAULT_AVATAR_BASE }: { size?: DefaultAvatarSize | \"svg\"; base?: string } = {}): string {\n return `${base.replace(/\\/+$/, \"\")}/${avatar.brand}/${defaultAvatarFile(avatar.kind ?? \"person\", resolveTone(avatar), size)}`;\n}\n\n/** The SVG source of a default avatar, 512 × 512 and square (the Avatar clips it round). */\nexport function defaultAvatarSvg(avatar: DefaultAvatar): string {\n const [from, to] = defaultAvatarTone(avatar.brand, resolveTone(avatar));\n const person =\n (avatar.kind ?? \"person\") === \"person\"\n ? `<circle cx=\"256\" cy=\"200\" r=\"86\" fill=\"#fff\" fill-opacity=\".92\"/><path d=\"M84 512c0-108 78-182 172-182s172 74 172 182z\" fill=\"#fff\" fill-opacity=\".92\"/>`\n : \"\";\n return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\" width=\"512\" height=\"512\"><defs><linearGradient id=\"g\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stop-color=\"${from}\"/><stop offset=\"1\" stop-color=\"${to}\"/></linearGradient><radialGradient id=\"l\" cx=\".28\" cy=\".18\" r=\".85\"><stop offset=\"0\" stop-color=\"#fff\" stop-opacity=\".24\"/><stop offset=\"1\" stop-color=\"#fff\" stop-opacity=\"0\"/></radialGradient></defs><rect width=\"512\" height=\"512\" fill=\"url(#g)\"/><rect width=\"512\" height=\"512\" fill=\"url(#l)\"/>${person}</svg>`;\n}\n"
|
|
368
449
|
},
|
|
369
450
|
{
|
|
370
451
|
"path": "avatar/index.ts",
|
|
371
|
-
"content": "/** @krizaka/ui/avatar — `Avatar`, `Avatar.Group
|
|
452
|
+
"content": "/** @krizaka/ui/avatar — `Avatar`, `Avatar.Group`, and the default pictures (`defaultAvatarUrl`…). Client (\"use client\"). */\nexport { Avatar, AvatarGroup, type AvatarGroupProps, type AvatarProps, AvatarRoot, type AvatarSize } from \"./avatar\";\nexport {\n DEFAULT_AVATAR_BASE,\n DEFAULT_AVATAR_BRANDS,\n DEFAULT_AVATAR_KINDS,\n DEFAULT_AVATAR_SIZES,\n DEFAULT_AVATAR_TONES,\n type DefaultAvatar,\n type DefaultAvatarBrand,\n defaultAvatarFile,\n type DefaultAvatarKind,\n type DefaultAvatarSize,\n defaultAvatarSvg,\n defaultAvatarTone,\n defaultAvatarToneOf,\n defaultAvatarUrl,\n} from \"./default-avatar\";\n"
|
|
372
453
|
},
|
|
373
454
|
{
|
|
374
455
|
"path": "avatar/meta.ts",
|
|
375
|
-
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Avatar\",\n summary: \"A person or an account: their picture, or initials while it loads, when it fails or when there is none.\",\n status: \"stable\",\n category: \"data-display\",\n platforms: \"both\",\n whenToUse: [\n \"Next to a name: comments, creators, members, the signed-in account in a header.\",\n \"`Avatar.Group` to show who takes part (followers, collaborators) in little space.\",\n ],\n whenNotToUse: [\n { when: \"For a product, a video or a place: use an image in a card.\", use: \"card\" },\n { when: \"As the only way to say who someone is: show their name too, or give the avatar an `alt`.\" },\n ],\n bestPractices: [\n \"Always pass a `fallback` (initials) on the web: images fail, and the layout should not jump.\",\n \"Use one size per list: `sm` in dense lists, `md` by default, `lg`/`xl` on a profile.\",\n \"In a group, set `max` so a long list ends with `+n` instead of overflowing.\",\n ],\n accessibility: {\n keyboard: [],\n notes: [\n \"With `alt`, the picture is an image named by it; without `alt`, it is decorative — put the name in the text next to it.\",\n \"The fallback initials are shown, not announced, when an `alt` names the person.\",\n ],\n },\n related: [\"card\", \"badge\", \"skeleton\"],\n web: {\n imports: [\"Avatar\"],\n examples: [\n { name: \"with-image\", title: \"With an image\", description: \"A picture named by `alt`, with initials as `fallback`.\" },\n { name: \"fallback\", title: \"Fallback\", description: \"No image: the initials.\" },\n { name: \"sizes\", title: \"Sizes\", description: \"`xs`, `sm`, `md` (default), `lg`, `xl`.\" },\n { name: \"group\", title: \"Group\", description: \"`Avatar.Group` with `max`: the others become `+n`.\" },\n ],\n },\n native: {\n imports: [\"Avatar\"],\n differences: [\n \"The fallback is the initial of `alt` when `fallback` is not given.\",\n \"`size` also takes a number of points.\",\n \"`src` is a URI string (an `Image` source), not a URL object.\",\n \"An SVG `src` (`….svg`, `data:image/svg+xml,…`, or any URI with `svg`) is drawn by react-native-svg — `Image` cannot.\",\n \"`presence` adds a dot (online, away, busy, offline) and `ring` a story ring (new, seen, live): mobile only for now.\",\n ],\n examples: [\n { name: \"sizes\", title: \"Sizes\", description: \"Without an image: the initial of `alt`, from `xs` to `xl`.\" },\n { name: \"group\", title: \"Group\", description: \"`Avatar.Group max={3}`: the rest becomes `+n`.\" },\n { name: \"presence\", title: \"Presence and story rings\", description: \"A presence dot, a story ring (new or seen), said with the name.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
|
|
456
|
+
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Avatar\",\n summary: \"A person or an account: their picture, or initials while it loads, when it fails or when there is none.\",\n status: \"stable\",\n category: \"data-display\",\n platforms: \"both\",\n whenToUse: [\n \"Next to a name: comments, creators, members, the signed-in account in a header.\",\n \"`Avatar.Group` to show who takes part (followers, collaborators) in little space.\",\n ],\n whenNotToUse: [\n { when: \"For a product, a video or a place: use an image in a card.\", use: \"card\" },\n { when: \"As the only way to say who someone is: show their name too, or give the avatar an `alt`.\" },\n ],\n bestPractices: [\n \"Always pass a `fallback` (initials) on the web: images fail, and the layout should not jump.\",\n \"A profile can lack a photo: pass `defaultAvatar={{ brand, seed: user.id }}` and the person gets the product's own default picture (a silhouette on the brand's gradient, one of six tones, always the same for the same id) instead of an empty circle. It also replaces a photo that fails to load. `fallbackSrc` is the same for your own image; `kind: \\\"plain\\\"` is the gradient alone, for initials.\",\n \"The default pictures are generated from the code (`defaultAvatarSvg`) and served by the Krizaka CDN (`defaultAvatarUrl`, 256 and 512 px WebP, SVG): a product never draws its own.\",\n \"Use one size per list: `sm` in dense lists, `md` by default, `lg`/`xl` on a profile.\",\n \"In a group, set `max` so a long list ends with `+n` instead of overflowing.\",\n ],\n accessibility: {\n keyboard: [],\n notes: [\n \"With `alt`, the picture is an image named by it; without `alt`, it is decorative — put the name in the text next to it.\",\n \"The fallback initials are shown, not announced, when an `alt` names the person.\",\n ],\n },\n related: [\"card\", \"badge\", \"skeleton\"],\n web: {\n imports: [\"Avatar\"],\n examples: [\n { name: \"with-image\", title: \"With an image\", description: \"A picture named by `alt`, with initials as `fallback`.\" },\n { name: \"default\", title: \"Default pictures\", description: \"No photo: `defaultAvatar={{ brand, seed }}` — the brand's gradient and a silhouette, one of six tones per person.\" },\n { name: \"fallback\", title: \"Fallback\", description: \"No image: the initials.\" },\n { name: \"sizes\", title: \"Sizes\", description: \"`xs`, `sm`, `md` (default), `lg`, `xl`.\" },\n { name: \"group\", title: \"Group\", description: \"`Avatar.Group` with `max`: the others become `+n`.\" },\n ],\n },\n native: {\n imports: [\"Avatar\"],\n differences: [\n \"The fallback is the initial of `alt` when `fallback` is not given.\",\n \"`size` also takes a number of points.\",\n \"`defaultAvatar` and `fallbackSrc` work as on the web (the default pictures are WebP, drawn by `Image`); `defaultAvatarUrl` is exported from `@krizaka/ui/native`.\",\n \"`src` is a URI string (an `Image` source), not a URL object.\",\n \"An SVG `src` (`….svg`, `data:image/svg+xml,…`, or any URI with `svg`) is drawn by react-native-svg — `Image` cannot.\",\n \"`presence` adds a dot (online, away, busy, offline) and `ring` a story ring (new, seen, live): mobile only for now.\",\n ],\n examples: [\n { name: \"sizes\", title: \"Sizes\", description: \"Without an image: the initial of `alt`, from `xs` to `xl`.\" },\n { name: \"group\", title: \"Group\", description: \"`Avatar.Group max={3}`: the rest becomes `+n`.\" },\n { name: \"presence\", title: \"Presence and story rings\", description: \"A presence dot, a story ring (new or seen), said with the name.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
|
|
376
457
|
},
|
|
377
458
|
{
|
|
378
459
|
"path": "meta.ts",
|
|
@@ -400,6 +481,13 @@
|
|
|
400
481
|
"description": "The image's text alternative (the person's name); empty when a visible name sits next to the avatar.",
|
|
401
482
|
"required": false
|
|
402
483
|
},
|
|
484
|
+
{
|
|
485
|
+
"name": "defaultAvatar",
|
|
486
|
+
"type": "DefaultAvatar",
|
|
487
|
+
"default": null,
|
|
488
|
+
"description": "The Krizaka default picture when the person has no photo (or it fails): their brand's gradient and a silhouette,\none of six tones from `seed` — `defaultAvatar={{ brand: \"orochia\", seed: user.id }}`. `fallbackSrc` wins over it.",
|
|
489
|
+
"required": false
|
|
490
|
+
},
|
|
403
491
|
{
|
|
404
492
|
"name": "fallback",
|
|
405
493
|
"type": "ReactNode",
|
|
@@ -407,6 +495,13 @@
|
|
|
407
495
|
"description": "Shown while the image loads, when it fails or when there is none: initials, an icon.",
|
|
408
496
|
"required": false
|
|
409
497
|
},
|
|
498
|
+
{
|
|
499
|
+
"name": "fallbackSrc",
|
|
500
|
+
"type": "string",
|
|
501
|
+
"default": null,
|
|
502
|
+
"description": "The picture to show instead when `src` is missing or fails to load (an image URL). The `fallback` still covers its own loading.",
|
|
503
|
+
"required": false
|
|
504
|
+
},
|
|
410
505
|
{
|
|
411
506
|
"name": "size",
|
|
412
507
|
"type": "AvatarSize",
|
|
@@ -454,6 +549,13 @@
|
|
|
454
549
|
"description": "The image's text alternative (the person's name); empty when a visible name sits next to the avatar.",
|
|
455
550
|
"required": false
|
|
456
551
|
},
|
|
552
|
+
{
|
|
553
|
+
"name": "defaultAvatar",
|
|
554
|
+
"type": "DefaultAvatar",
|
|
555
|
+
"default": null,
|
|
556
|
+
"description": "The Krizaka default picture when the person has no photo (or it fails): their brand's gradient and a silhouette,\none of six tones from `seed` — `defaultAvatar={{ brand: \"orochia\", seed: user.id }}`. `fallbackSrc` wins over it.",
|
|
557
|
+
"required": false
|
|
558
|
+
},
|
|
457
559
|
{
|
|
458
560
|
"name": "fallback",
|
|
459
561
|
"type": "ReactNode",
|
|
@@ -461,6 +563,13 @@
|
|
|
461
563
|
"description": "Shown while the image loads, when it fails or when there is none: initials, an icon.",
|
|
462
564
|
"required": false
|
|
463
565
|
},
|
|
566
|
+
{
|
|
567
|
+
"name": "fallbackSrc",
|
|
568
|
+
"type": "string",
|
|
569
|
+
"default": null,
|
|
570
|
+
"description": "The picture to show instead when `src` is missing or fails to load (an image URL). The `fallback` still covers its own loading.",
|
|
571
|
+
"required": false
|
|
572
|
+
},
|
|
464
573
|
{
|
|
465
574
|
"name": "size",
|
|
466
575
|
"type": "AvatarSize",
|
|
@@ -476,6 +585,16 @@
|
|
|
476
585
|
"required": false
|
|
477
586
|
}
|
|
478
587
|
]
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
"component": "DEFAULT_AVATAR_BASE",
|
|
591
|
+
"description": "Where the set lives. `v1` is the set's version: a changed picture is a new version folder, never an overwrite.",
|
|
592
|
+
"props": []
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
"component": "DEFAULT_AVATAR_TONES",
|
|
596
|
+
"description": "How many tones each brand has: a person always gets the same one from their seed, so a list is not one colour.",
|
|
597
|
+
"props": []
|
|
479
598
|
}
|
|
480
599
|
]
|
|
481
600
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// registry/examples/avatar/default.tsx
|
|
4
|
+
import { Avatar } from "@krizaka/ui/avatar";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
function AvatarDefault() {
|
|
7
|
+
const people = ["ada", "camille", "noor", "tomas", "ines", "kofi"];
|
|
8
|
+
return /* @__PURE__ */ jsx("div", { className: "flex items-center gap-3", children: people.map((id) => /* @__PURE__ */ jsx(Avatar, { defaultAvatar: { brand: "orochia", seed: id }, alt: id, fallback: id.slice(0, 2), size: "lg" }, id)) });
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
AvatarDefault as default
|
|
12
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Avatar } from "@krizaka/ui/avatar";
|
|
2
|
+
|
|
3
|
+
// No photo: the brand's default picture, one of six tones from the person's id — the same one on every screen.
|
|
4
|
+
export default function AvatarDefault() {
|
|
5
|
+
const people = ["ada", "camille", "noor", "tomas", "ines", "kofi"];
|
|
6
|
+
return (
|
|
7
|
+
<div className="flex items-center gap-3">
|
|
8
|
+
{people.map((id) => (
|
|
9
|
+
<Avatar key={id} defaultAvatar={{ brand: "orochia", seed: id }} alt={id} fallback={id.slice(0, 2)} size="lg" />
|
|
10
|
+
))}
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
package/registry/index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
4
4
|
"templatePurposes": {
|
|
5
5
|
"convince": {
|
|
6
6
|
"title": "Convince",
|
|
@@ -600,7 +600,7 @@
|
|
|
600
600
|
"category": "data-display",
|
|
601
601
|
"platforms": "both",
|
|
602
602
|
"examples": {
|
|
603
|
-
"web":
|
|
603
|
+
"web": 5,
|
|
604
604
|
"native": 3
|
|
605
605
|
},
|
|
606
606
|
"dependencies": [
|
|
@@ -1731,7 +1731,7 @@
|
|
|
1731
1731
|
"native": 2
|
|
1732
1732
|
},
|
|
1733
1733
|
"dependencies": [
|
|
1734
|
-
"@krizaka/tokens@^3.
|
|
1734
|
+
"@krizaka/tokens@^3.13.0",
|
|
1735
1735
|
"@protomaps/basemaps@5.7.2",
|
|
1736
1736
|
"tailwind-variants@^3.3.1"
|
|
1737
1737
|
],
|
|
@@ -1939,7 +1939,7 @@
|
|
|
1939
1939
|
"native": 0
|
|
1940
1940
|
},
|
|
1941
1941
|
"dependencies": [
|
|
1942
|
-
"@krizaka/tokens@^3.
|
|
1942
|
+
"@krizaka/tokens@^3.13.0",
|
|
1943
1943
|
"radix-ui@^1.7.0",
|
|
1944
1944
|
"tailwind-variants@^3.3.1"
|
|
1945
1945
|
],
|
|
@@ -2455,7 +2455,7 @@
|
|
|
2455
2455
|
"native": 0
|
|
2456
2456
|
},
|
|
2457
2457
|
"dependencies": [
|
|
2458
|
-
"@krizaka/tokens@^3.
|
|
2458
|
+
"@krizaka/tokens@^3.13.0",
|
|
2459
2459
|
"radix-ui@^1.7.0",
|
|
2460
2460
|
"tailwind-variants@^3.3.1"
|
|
2461
2461
|
],
|
package/registry/map.json
CHANGED
package/registry/nav-bar.json
CHANGED
|
@@ -414,12 +414,16 @@
|
|
|
414
414
|
},
|
|
415
415
|
{
|
|
416
416
|
"path": "marks/ProductLogo.tsx",
|
|
417
|
-
"content": "import { KrizakaLogo } from \"./KrizakaLogo\";\nimport { OrazakaLogo } from \"./OrazakaLogo\";\nimport { OrochiaLogo } from \"./OrochiaLogo\";\nimport type { MarkProps } from \"./shared\";\nimport { YasakiaLogo } from \"./YasakiaLogo\";\nimport { YororisLogo } from \"./YororisLogo\";\n\nexport type BrandId = \"krizaka\" | \"orazaka\" | \"orochia\" | \"yasakia\" | \"yororis\";\n\n/** The mark of a Krizaka brand by id — for navigation, cards and lists driven by data. */\nexport function ProductLogo({ id, ...props }: MarkProps & { /** The brand: `krizaka`, `orazaka`, `orochia`, `yasakia` or `
|
|
417
|
+
"content": "import { KrizakaLogo } from \"./KrizakaLogo\";\nimport { OrazakaLogo } from \"./OrazakaLogo\";\nimport { OrochiaLogo } from \"./OrochiaLogo\";\nimport type { MarkProps } from \"./shared\";\nimport { YasakiaLogo } from \"./YasakiaLogo\";\nimport { YomizakaLogo } from \"./YomizakaLogo\";\nimport { YororisLogo } from \"./YororisLogo\";\n\nexport type BrandId = \"krizaka\" | \"orazaka\" | \"orochia\" | \"yasakia\" | \"yororis\" | \"yomizaka\";\n\n/** The mark of a Krizaka brand by id — for navigation, cards and lists driven by data. */\nexport function ProductLogo({ id, ...props }: MarkProps & { /** The brand: `krizaka`, `orazaka`, `orochia`, `yasakia`, `yororis` or `yomizaka` (`yazakia`, deprecated: the old name of `yasakia`). */ id: BrandId | \"yazakia\" }) {\n if (id === \"krizaka\") return <KrizakaLogo {...props} />;\n if (id === \"orazaka\") return <OrazakaLogo {...props} />;\n if (id === \"yasakia\" || id === \"yazakia\") return <YasakiaLogo {...props} />;\n if (id === \"yororis\") return <YororisLogo {...props} />;\n if (id === \"yomizaka\") return <YomizakaLogo {...props} />;\n return <OrochiaLogo {...props} />;\n}\n"
|
|
418
418
|
},
|
|
419
419
|
{
|
|
420
420
|
"path": "marks/YasakiaLogo.tsx",
|
|
421
421
|
"content": "import { a11y, type MarkProps, neutral, useIds, withAlpha } from \"./shared\";\nimport { YASAKIA as G } from \"./yasakia-geometry\";\n\nconst CSS = `\n.yzm-orbit { transform-origin: 200px 200px; animation: yzm-orbit 30s linear infinite; }\n.yzm-core { transform-box: fill-box; transform-origin: center; animation: yzm-core 3.2s ease-in-out infinite; }\n@keyframes yzm-orbit { to { transform: rotate(360deg); } }\n@keyframes yzm-core {\n 0%, 100% { filter: drop-shadow(0 0 6px ${withAlpha(G.pulse.color, G.pulse.low)}); opacity: .9; }\n 50% { filter: drop-shadow(0 0 14px ${withAlpha(G.pulse.color, G.pulse.high)}); opacity: 1; }\n}\n@media (prefers-reduced-motion: reduce) { .yzm-orbit, .yzm-core { animation: none; } }\n`;\n\n/**\n * The Yasakia mark: two voices — two solid speech bubbles that overlap, the left a shade deeper — in the Yasakia\n * gradient (emerald → turquoise, the brand's mark gradient of @krizaka/tokens, the same in dark and light). Where they\n * overlap, what the two share, is the bright core: a lens lit from above that breathes. The pair, two nodes on the\n * dashed orbit, turns slowly around it — the family's grammar (orbit, ring, bold emblem, bright core). Below 48 px it\n * crops to the emblem.\n */\nexport function YasakiaLogo({ size = 32, animated = true, title, className }: MarkProps) {\n const id = useIds(\"yzm\", [\"gradient\", \"light\", \"glow\"] as const);\n const fill = `url(#${id.gradient})`;\n return (\n <svg width={size} height={size} viewBox={size < 48 ? G.croppedViewBox : G.viewBox} fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" className={className} {...a11y(title)}>\n <style>{CSS}</style>\n <defs>\n <linearGradient id={id.gradient} gradientUnits=\"userSpaceOnUse\" x1={G.gradient.x1} y1={G.gradient.y1} x2={G.gradient.x2} y2={G.gradient.y2}>\n {G.gradient.stops.map(([offset, color]) => (\n <stop key={offset} offset={offset} stopColor={color} />\n ))}\n </linearGradient>\n <linearGradient id={id.light} gradientUnits=\"userSpaceOnUse\" x1=\"0\" y1={G.lens.y1} x2=\"0\" y2={G.lens.y2}>\n <stop offset=\"0%\" stopColor={G.highlight.color} stopOpacity={G.highlight.from} />\n <stop offset=\"100%\" stopColor={G.highlight.color} stopOpacity={G.highlight.to} />\n </linearGradient>\n <radialGradient id={id.glow} cx=\"50%\" cy=\"50%\" r=\"50%\">\n <stop offset=\"0%\" stopColor={G.glow.color} stopOpacity={G.glow.opacity} />\n <stop offset=\"100%\" stopColor={G.glow.color} stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n {size >= 48 ? <circle cx={G.center} cy={G.center} r=\"190\" fill={`url(#${id.glow})`} /> : null /* cropped: the halo would show its square */}\n <circle cx={G.center} cy={G.center} r={G.ring.r} stroke={neutral(\"--kz-border-default\", 30)} strokeWidth={G.ring.width} opacity=\".35\" />\n <g className={animated ? \"yzm-orbit\" : undefined}>\n <circle cx={G.center} cy={G.center} r={G.orbit.r} stroke={neutral(\"--kz-border-strong\", 40)} strokeWidth={G.orbit.width} strokeDasharray={G.orbit.dash} opacity=\".6\" />\n {G.pair.at.map(([cx, cy]) => (\n <circle key={cx} cx={cx} cy={cy} r={G.pair.r} fill={fill} />\n ))}\n </g>\n {G.voices.map((v, i) => (\n <g key={v.body}>\n <path d={v.body} fill={fill} />\n <path d={v.tail} fill={fill} />\n {i === 0 ? <path d={`${v.body} ${v.tail}`} fill={G.shade.color} opacity={G.shade.opacity} /> : null}\n </g>\n ))}\n <g className={animated ? \"yzm-core\" : undefined}>\n <path d={G.lens.d} fill={fill} />\n <path d={G.lens.d} fill={`url(#${id.light})`} />\n </g>\n </svg>\n );\n}\n\n/** @deprecated Yazakia was renamed Yasakia (2026-10): use `YasakiaLogo`. Kept for one minor version. */\nexport const YazakiaLogo = YasakiaLogo;\n"
|
|
422
422
|
},
|
|
423
|
+
{
|
|
424
|
+
"path": "marks/YomizakaLogo.tsx",
|
|
425
|
+
"content": "import { a11y, type MarkProps, neutral, useIds, withAlpha } from \"./shared\";\nimport { YOMIZAKA as G } from \"./yomizaka-geometry\";\n\nconst CSS = `\n.ymm-orbit { transform-origin: 200px 200px; animation: ymm-orbit 32s linear infinite; }\n.ymm-scan { animation: ymm-scan 3.6s cubic-bezier(.45,0,.55,1) infinite; }\n.ymm-lens { transform-box: fill-box; transform-origin: center; animation: ymm-lens 3.6s ease-in-out infinite; }\n@keyframes ymm-orbit { to { transform: rotate(360deg); } }\n@keyframes ymm-scan { 0% { transform: translateY(0); opacity: 0; } 12%, 80% { opacity: 1; } 100% { transform: translateY(${G.scan.travel}px); opacity: 0; } }\n@keyframes ymm-lens {\n 0%, 100% { filter: drop-shadow(0 0 6px ${withAlpha(G.pulse.color, G.pulse.low)}); }\n 50% { filter: drop-shadow(0 0 16px ${withAlpha(G.pulse.color, G.pulse.high)}); }\n}\n@media (prefers-reduced-motion: reduce) { .ymm-orbit, .ymm-scan, .ymm-lens { animation: none; } .ymm-scan { opacity: 0; } }\n`;\n\n/**\n * The Yomizaka mark, \"the reading\": a filled page with the Krizaka cut at its corner, three lines of text in light, and\n * a lens — the eye that reads — resting on its lower half, its handle running out of the page. Page and lens in the\n * Yomizaka gradient (ink → indigo, the brand's mark gradient of @krizaka/tokens, the same in dark and light); the\n * lens' core is a pale indigo light that breathes; a line of light scans the page from top to bottom; one node, a\n * page, rides the dashed orbit. The family's grammar (orbit, ring, bold emblem, bright core). Below 48 px it crops to\n * the emblem.\n */\nexport function YomizakaLogo({ size = 32, animated = true, title, className }: MarkProps) {\n const id = useIds(\"ymm\", [\"gradient\", \"core\", \"glow\", \"page\"] as const);\n const fill = `url(#${id.gradient})`;\n return (\n <svg width={size} height={size} viewBox={size < 48 ? G.croppedViewBox : G.viewBox} fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" className={className} {...a11y(title)}>\n <style>{CSS}</style>\n <defs>\n <linearGradient id={id.gradient} gradientUnits=\"userSpaceOnUse\" x1={G.gradient.x1} y1={G.gradient.y1} x2={G.gradient.x2} y2={G.gradient.y2}>\n {G.gradient.stops.map(([offset, color]) => (\n <stop key={offset} offset={offset} stopColor={color} />\n ))}\n </linearGradient>\n <linearGradient id={id.core} x1=\"50%\" y1=\"100%\" x2=\"50%\" y2=\"0%\">\n {G.coreGradient.stops.map(([offset, color]) => (\n <stop key={offset} offset={offset} stopColor={color} />\n ))}\n </linearGradient>\n <radialGradient id={id.glow} cx=\"50%\" cy=\"50%\" r=\"50%\">\n <stop offset=\"0%\" stopColor={G.glow.color} stopOpacity={G.glow.opacity} />\n <stop offset=\"100%\" stopColor={G.glow.color} stopOpacity=\"0\" />\n </radialGradient>\n <clipPath id={id.page}>\n <path d={G.page} />\n </clipPath>\n </defs>\n {size >= 48 ? <circle cx={G.center} cy={G.center} r=\"190\" fill={`url(#${id.glow})`} /> : null /* cropped: the halo would show its square */}\n <circle cx={G.center} cy={G.center} r={G.ring.r} stroke={neutral(\"--kz-border-default\", 30)} strokeWidth={G.ring.width} opacity=\".35\" />\n <g className={animated ? \"ymm-orbit\" : undefined}>\n <circle cx={G.center} cy={G.center} r={G.orbit.r} stroke={neutral(\"--kz-border-strong\", 40)} strokeWidth={G.orbit.width} strokeDasharray={G.orbit.dash} opacity=\".6\" />\n <circle cx={G.node.at[0]} cy={G.node.at[1]} r={G.node.r} fill={fill} />\n </g>\n <path d={G.page} fill={fill} />\n <path d={G.lines} stroke={G.line.color} strokeOpacity={G.line.opacity} strokeWidth={G.line.width} strokeLinecap=\"round\" />\n {animated ? (\n <g clipPath={`url(#${id.page})`}>\n <path className=\"ymm-scan\" d={G.scan.d} stroke={G.scan.color} strokeOpacity={G.scan.opacity} strokeWidth={G.scan.width} strokeLinecap=\"round\" />\n </g>\n ) : null}\n <path d={G.handle} stroke={fill} strokeWidth={G.handleWidth} strokeLinecap=\"round\" />\n <g className={animated ? \"ymm-lens\" : undefined}>\n <circle cx={G.lens.cx} cy={G.lens.cy} r={G.lens.r} stroke={fill} strokeWidth={G.lens.width} fill={`url(#${id.core})`} />\n <circle cx={G.lens.glint.cx} cy={G.lens.glint.cy} r={G.lens.glint.r} fill={G.highlight} />\n </g>\n </svg>\n );\n}\n"
|
|
426
|
+
},
|
|
423
427
|
{
|
|
424
428
|
"path": "marks/YororisLogo.tsx",
|
|
425
429
|
"content": "import { a11y, type MarkProps, neutral, useIds, withAlpha } from \"./shared\";\nimport { YORORIS as G } from \"./yororis-geometry\";\n\nconst CSS = `\n.yrm-orbit { transform-origin: 200px 200px; animation: yrm-orbit 36s linear infinite; }\n.yrm-vein { animation: yrm-vein 4.8s linear infinite; }\n.yrm-core { transform-box: fill-box; transform-origin: center; animation: yrm-core 4.2s ease-in-out infinite; }\n@keyframes yrm-orbit { to { transform: rotate(360deg); } }\n@keyframes yrm-vein { to { stroke-dashoffset: 64; } }\n@keyframes yrm-core {\n 0%, 100% { filter: drop-shadow(0 0 6px ${withAlpha(G.pulse.color, G.pulse.low)}); opacity: .9; }\n 50% { filter: drop-shadow(0 0 16px ${withAlpha(G.pulse.color, G.pulse.high)}); opacity: 1; }\n}\n@media (prefers-reduced-motion: reduce) { .yrm-orbit, .yrm-vein, .yrm-core { animation: none; } }\n`;\n\n/**\n * The Yororis mark, \"the hold\": a \"Y\" whose arms are two filled leaves — open hands — rising from one stem and cupping\n * a warm core that breathes: held, given room, listened to. Leaves in the Yororis gradient (sand → sage, the brand's\n * mark gradient of @krizaka/tokens, the same in dark and light), a vein of light along each, the core in a warm sand\n * light; the moment, one node on the dashed orbit, comes round slowly. The family's grammar (orbit, ring, bold emblem,\n * bright core) with the slowest breath of the family. Below 48 px it crops to the emblem.\n */\nexport function YororisLogo({ size = 32, animated = true, title, className }: MarkProps) {\n const id = useIds(\"yrm\", [\"gradient\", \"core\", \"glow\"] as const);\n const fill = `url(#${id.gradient})`;\n return (\n <svg width={size} height={size} viewBox={size < 48 ? G.croppedViewBox : G.viewBox} fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" className={className} {...a11y(title)}>\n <style>{CSS}</style>\n <defs>\n <linearGradient id={id.gradient} gradientUnits=\"userSpaceOnUse\" x1={G.gradient.x1} y1={G.gradient.y1} x2={G.gradient.x2} y2={G.gradient.y2}>\n {G.gradient.stops.map(([offset, color]) => (\n <stop key={offset} offset={offset} stopColor={color} />\n ))}\n </linearGradient>\n <linearGradient id={id.core} x1=\"50%\" y1=\"100%\" x2=\"50%\" y2=\"0%\">\n {G.coreGradient.stops.map(([offset, color]) => (\n <stop key={offset} offset={offset} stopColor={color} />\n ))}\n </linearGradient>\n <radialGradient id={id.glow} cx=\"50%\" cy=\"50%\" r=\"50%\">\n <stop offset=\"0%\" stopColor={G.glow.color} stopOpacity={G.glow.opacity} />\n <stop offset=\"100%\" stopColor={G.glow.color} stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n {size >= 48 ? <circle cx={G.center} cy={G.center} r=\"190\" fill={`url(#${id.glow})`} /> : null /* cropped: the halo would show its square */}\n <circle cx={G.center} cy={G.center} r={G.ring.r} stroke={neutral(\"--kz-border-default\", 30)} strokeWidth={G.ring.width} opacity=\".35\" />\n <g className={animated ? \"yrm-orbit\" : undefined}>\n <circle cx={G.center} cy={G.center} r={G.orbit.r} stroke={neutral(\"--kz-border-strong\", 40)} strokeWidth={G.orbit.width} strokeDasharray={G.orbit.dash} opacity=\".6\" />\n <circle cx={G.moment.at[0]} cy={G.moment.at[1]} r={G.moment.r} fill={fill} />\n </g>\n <path d={G.stem} stroke={fill} strokeWidth={G.stroke} strokeLinecap=\"round\" />\n {G.leaves.map((d) => (\n <path key={d} d={d} fill={fill} />\n ))}\n <path className={animated ? \"yrm-vein\" : undefined} d={G.veins} stroke={G.vein.color} strokeOpacity={G.vein.opacity} strokeWidth={G.vein.width} strokeLinecap=\"round\" strokeDasharray={G.vein.dash} />\n <g className={animated ? \"yrm-core\" : undefined}>\n <circle cx={G.core.cx} cy={G.core.cy} r={G.core.r} fill={`url(#${id.core})`} />\n <circle cx={G.core.cx} cy={G.core.cy} r={G.core.inner} fill={G.highlight} />\n </g>\n </svg>\n );\n}\n"
|
|
@@ -444,6 +448,10 @@
|
|
|
444
448
|
"path": "marks/yasakia-geometry.ts",
|
|
445
449
|
"content": "import { marks } from \"@krizaka/tokens/native\";\n\n/**\n * The Yasakia mark's geometry — drawn by the web mark (YasakiaLogo) and the native one (YasakiaMark). The family's\n * grammar (packages/tokens/BRAND.md, \"Marks\"): 400×400 view box, dashed orbit and ring, a bold emblem in the brand\n * gradient, a bright core; below 48 px the view box crops to the emblem.\n *\n * The emblem is **two voices**: two solid speech bubbles, mirrored, that overlap. Where they overlap — what the two\n * people share once they have talked — is the bright core, a lens lit from above that breathes. The left voice sits a\n * shade deeper than the right, so the pair reads as two, with depth, at 16 px as at 128 px. A thin gap (the page shows\n * through) separates each bubble from the lens: the paths are cut here, no mask, so web and native draw the same thing.\n * Two nodes, the pair, ride the dashed orbit. The gradient is the brand's mark gradient (`marks.yasakia` of\n * @krizaka/tokens): emerald → turquoise, mid-tones that read ≥ 3:1 on the page surfaces of both themes (tested in\n * @krizaka/tokens), so one mark serves dark and light.\n */\n/** The bright end of the gradient (turquoise): the halo and the core's glow. */\nconst turquoise = marks.yasakia[marks.yasakia.length - 1]![1];\n\n/*\n * How the paths are drawn (literal, so the native bundle carries no geometry code): each bubble is a circle of radius 80\n * centred at y 184, 36 on either side of x = 200 (164 and 236); they overlap in the lens (x = 200, y 112.6 → 255.4).\n * Each bubble is cut by the other's circle grown by the gap (r 87): the cut runs from (191.9, 109) to (191.9, 259) on\n * the left, mirrored on the right. Each tail is a curved point from the bubble's lower outer side, down and out to\n * (88, 294) — mirrored (312, 294).\n */\nexport const YASAKIA = {\n viewBox: \"0 0 400 400\",\n croppedViewBox: \"80 80 240 240\",\n center: 200,\n /** The two voices, left then right: each a bubble (cut around the lens) and its tail. */\n voices: [\n { body: \"M191.9 109 A80 80 0 1 0 191.9 259 A87 87 0 0 1 191.9 109 Z\", tail: \"M98 230 Q104 268 88 294 Q122 288 144 262 L130 200 Z\" },\n { body: \"M208.1 109 A80 80 0 1 1 208.1 259 A87 87 0 0 0 208.1 109 Z\", tail: \"M302 230 Q296 268 312 294 Q278 288 256 262 L270 200 Z\" },\n ] as const,\n /** The left voice, a shade deeper: the depth between the two. */\n shade: { color: \"#000\", opacity: 0.22 },\n /** Where the bubbles overlap: the core, lit from above. */\n lens: { d: \"M200 112.6 A80 80 0 0 1 200 255.4 A80 80 0 0 1 200 112.6 Z\", y1: 112.6, y2: 255.4 },\n gradient: { x1: 110, y1: 300, x2: 290, y2: 100, stops: marks.yasakia },\n /** The light on the lens: bright at the top, fading down. */\n highlight: { color: \"#f0fdfa\", from: 0.75, to: 0.2 },\n /** The pair: two nodes on the orbit, opposite each other, that turn with it. */\n pair: { r: 8, at: [[40, 200], [360, 200]] as const },\n glow: { color: turquoise, opacity: 0.16 },\n /** The core's breathing shadow (web), in the mark's turquoise. */\n pulse: { color: turquoise, low: 0.3, high: 0.6 },\n orbit: { r: 160, dash: \"8 8\", width: 1.5 },\n ring: { r: 134, width: 1 },\n} as const;\n"
|
|
446
450
|
},
|
|
451
|
+
{
|
|
452
|
+
"path": "marks/yomizaka-geometry.ts",
|
|
453
|
+
"content": "import { marks } from \"@krizaka/tokens/native\";\n\n/**\n * The Yomizaka mark's geometry — drawn by the web mark (YomizakaLogo) and the native one (YomizakaMark). The family's\n * grammar (packages/tokens/BRAND.md, \"Marks\"): 400×400 view box, dashed orbit and ring, a bold filled emblem in the\n * brand gradient, a bright core; below 48 px the view box crops to the emblem.\n *\n * The emblem is **the reading**: a filled page whose upper-right corner wears the Krizaka cut, three lines of text in\n * light, and a lens — the eye that reads, the owl's — resting on its lower half, its handle running out of the page.\n * The lens is the bright core: it breathes. A thin gap (the page shows through) separates the page from the lens: the\n * page is cut here, no mask, so web and native draw the same thing. On the web a line of light scans the page from top\n * to bottom (reading); one node, a page, rides the dashed orbit. The gradient is the brand's mark gradient\n * (`marks.yomizaka` of @krizaka/tokens): ink → indigo, mid-tones that read ≥ 3:1 on the page surfaces of both themes\n * (tested in @krizaka/tokens), so one mark serves dark and light.\n */\n/** The indigo end of the gradient: the halo. */\nconst indigo = marks.yomizaka[marks.yomizaka.length - 1]![1];\n\n/*\n * How the page is drawn (literal, so the native bundle carries no geometry code): a page x 128 → 264, y 92 → 300, its\n * upper-right corner cut 36 (228, 92) → (264, 128). The lens is centred at (240, 232); the page is cut by the lens'\n * circle grown by the gap (r 52), which crosses the right edge at y 232 ± 46.13 — the large arc runs inside the page.\n */\nexport const YOMIZAKA = {\n viewBox: \"0 0 400 400\",\n croppedViewBox: \"80 80 240 240\",\n center: 200,\n /** The page, cut at its corner (the Krizaka chamfer) and around the lens. */\n page: \"M128 92 H228 L264 128 V185.87 A52 52 0 1 0 264 278.13 V300 H128 Z\",\n /** Three lines of text and the start of a fourth, in light: they wrap around the lens. */\n lines: \"M156 140 H226 M156 170 H236 M156 200 H184 M156 230 H176\",\n line: { color: \"#f4f5ff\", opacity: 0.6, width: 10 },\n /** The lens: a ring in the brand gradient, its pale core lit from above, a glint; the handle leaves the page. */\n lens: { cx: 240, cy: 232, r: 40, width: 12, core: 28, glint: { cx: 230, cy: 222, r: 8 } },\n handle: \"M276 268 L304 296\",\n handleWidth: 20,\n gradient: { x1: 128, y1: 300, x2: 304, y2: 92, stops: marks.yomizaka },\n /** The lens' core: a pale indigo light, brighter at the top. */\n coreGradient: { stops: [[\"0%\", \"#c9ceff\"], [\"100%\", \"#f4f5ff\"]] as const },\n highlight: \"#ffffff\",\n /** The reading (web): a line of light that scans the page from top to bottom, clipped to the page. */\n scan: { d: \"M136 100 H236\", travel: 192, color: \"#f4f5ff\", opacity: 0.7, width: 3 },\n /** A page: one node on the orbit, that turns with it. */\n node: { r: 9, at: [200, 40] as const },\n glow: { color: indigo, opacity: 0.18 },\n /** The lens' breathing shadow (web), in the indigo. */\n pulse: { color: indigo, low: 0.3, high: 0.6 },\n orbit: { r: 160, dash: \"8 8\", width: 1.5 },\n ring: { r: 134, width: 1 },\n} as const;\n"
|
|
454
|
+
},
|
|
447
455
|
{
|
|
448
456
|
"path": "marks/yororis-geometry.ts",
|
|
449
457
|
"content": "import { marks } from \"@krizaka/tokens/native\";\n\n/**\n * The Yororis mark's geometry — drawn by the web mark (YororisLogo) and the native one (YororisMark). The family's\n * grammar (packages/tokens/BRAND.md, \"Marks\"): 400×400 view box, dashed orbit and ring, a bold filled emblem in the\n * brand gradient, a bright core; below 48 px the view box crops to the emblem.\n *\n * The emblem is \"the hold\": a \"Y\" whose two arms are two filled leaves — open hands — that rise from one stem and cup\n * a warm core. The core is not where the arms join, it rests *inside* them: held, given room, listened to. A vein of\n * light runs along each leaf (depth, like the Orochia serpent's scales). One node, the moment, rides the dashed orbit:\n * it comes round, then it goes (a presence that stops). The leaves wear the brand's mark gradient (`marks.yororis` of\n * @krizaka/tokens): sand → sage, mid-tones that read ≥ 3:1 on the page surfaces of both themes (tested in\n * @krizaka/tokens), so one mark serves dark and light; the core is a warm sand light that rises from the mark's sand.\n */\nconst sand = marks.yororis[0]![1];\n/** The calm end of the gradient (sage): the halo and the core's glow. */\nconst sage = marks.yororis[marks.yororis.length - 1]![1];\n\nexport const YORORIS = {\n viewBox: \"0 0 400 400\",\n croppedViewBox: \"80 80 240 240\",\n center: 200,\n /** The two leaves — open hands, mirrored around x = 200 — filled, rising from the stem. */\n leaves: [\n \"M200 262 C142 256 104 206 110 122 C138 130 154 152 160 180 C168 218 182 238 204 246 Z\",\n \"M200 262 C258 256 296 206 290 122 C262 130 246 152 240 180 C232 218 218 238 196 246 Z\",\n ],\n /** A vein of light along each leaf (dashed, it drifts slowly when animated). */\n veins: \"M122 142 C128 196 156 232 192 248 M278 142 C272 196 244 232 208 248\",\n vein: { color: \"#fbf6ea\", opacity: 0.45, width: 3, dash: \"2 14\" },\n /** The stem, from the junction down. */\n stem: \"M200 244 L200 312\",\n stroke: 30,\n gradient: { x1: 110, y1: 300, x2: 290, y2: 100, stops: marks.yororis },\n /** The core's warm light: from the mark's sand (below) to a light sand (above). */\n coreGradient: { stops: [[\"0%\", sand], [\"100%\", \"#e6c993\"]] as const },\n /** The core, held in the hollow of the leaves (not on the junction): the one who is listened to. It breathes. */\n core: { cx: 200, cy: 184, r: 32, inner: 11 },\n highlight: \"#fbf6ea\",\n /** The moment: one node on the orbit, that turns with it. */\n moment: { r: 9, at: [200, 40] as const },\n glow: { color: sage, opacity: 0.18 },\n /** The core's breathing shadow (web), in the core's warm sand. */\n pulse: { color: sand, low: 0.3, high: 0.6 },\n orbit: { r: 160, dash: \"8 8\", width: 1.5 },\n ring: { r: 134, width: 1 },\n} as const;\n"
|
|
@@ -470,7 +478,7 @@
|
|
|
470
478
|
}
|
|
471
479
|
],
|
|
472
480
|
"dependencies": [
|
|
473
|
-
"@krizaka/tokens@^3.
|
|
481
|
+
"@krizaka/tokens@^3.13.0",
|
|
474
482
|
"radix-ui@^1.7.0",
|
|
475
483
|
"tailwind-variants@^3.3.1"
|
|
476
484
|
],
|
package/registry/showcase.json
CHANGED
|
@@ -308,7 +308,7 @@
|
|
|
308
308
|
},
|
|
309
309
|
{
|
|
310
310
|
"path": "showcase/showcase.tsx",
|
|
311
|
-
"content": "// Client (state, timers' observers, keys). A slideshow that advances by itself — and shows it: a segmented progress bar\n// (one segment per slide, the current one fills over the interval, each one a button) with a counter, previous / next and a\n// visible pause button (WCAG 2.2.2). The time IS the CSS animation of the current segment (`.kz-showcase-fill`):\n// when it ends, the next slide comes — so what you see filling is exactly what decides, and pausing the animation pauses\n// the show. Its two rules travel with the component (a hoisted, deduplicated <style>): no stylesheet to import. It waits while the pointer is over it, while a keyboard focus is inside, while the tab is hidden and while it\n// is off screen; under reduced motion it never advances by itself. Slides are slots: any content.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\n\nconst FILL_CSS = \".kz-showcase-fill{transform-origin:left;animation:kz-showcase-fill 6s linear forwards}@keyframes kz-showcase-fill{from{transform:scaleX(0)}to{transform:scaleX(1)}}\";\nconst REDUCED = \"(prefers-reduced-motion: reduce)\";\nconst subscribeNothing = () => () => undefined;\nconst readReduced = () => Boolean(typeof window !== \"undefined\" && window.matchMedia?.(REDUCED).matches);\nfunction subscribeReduced(notify: () => void) {\n const query = window.matchMedia?.(REDUCED);\n query?.addEventListener?.(\"change\", notify);\n return () => query?.removeEventListener?.(\"change\", notify);\n}\nconst readHidden = () => document.visibilityState === \"hidden\";\nfunction subscribeVisibility(notify: () => void) {\n document.addEventListener(\"visibilitychange\", notify);\n return () => document.removeEventListener(\"visibilitychange\", notify);\n}\n\nexport interface ShowcaseLabels {\n /** The previous button's name. */\n previous: string;\n /** The next button's name. */\n next: string;\n /** The pause button's name while it plays. */\n pause: string;\n /** The same button's name while it is paused. */\n play: string;\n /** A progress segment's name: `(3, 17) => \"Go to image 3\"`. */\n goTo: (n: number, total: number) => string;\n /** The position in words, beside the title: `(1, 17) => \"Image 1 of 17\"`. */\n counter: (n: number, total: number) => string;\n /** The slide's accessible name: `(1, 17) => \"1 of 17\"`. */\n slide: (n: number, total: number) => string;\n}\n\n/** English defaults — pass your own translated `labels`. */\nexport const showcaseLabels: ShowcaseLabels = {\n previous: \"Previous slide\",\n next: \"Next slide\",\n pause: \"Pause the slideshow\",\n play: \"Play the slideshow\",\n goTo: (n) => `Go to slide ${n}`,\n counter: (n, total) => `Slide ${n} of ${total}`,\n slide: (n, total) => `${n} of ${total}`,\n};\n\nexport interface ShowcaseSlideState {\n index: number;\n /** The slide in view. */\n active: boolean;\n}\n\nexport interface ShowcaseProps<T> extends Omit<React.ComponentProps<\"section\">, \"children\" | \"onChange\"> {\n /** The slideshow's accessible name (“Prompt to image”), passed translated. */\n label: string;\n /** The slides, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** One slide's content. Only the slide in view and its neighbours are mounted (the rest stay empty): give slides the same size. */\n renderItem: (item: T, state: ShowcaseSlideState) => React.ReactNode;\n /** A slide's title, shown before the counter (“Golden-hour editorial”). */\n getTitle?: (item: T) => string;\n /** Milliseconds a slide stays (the time the segment takes to fill). @default 6000 */\n interval?: number;\n /** Advances by itself. Never under `prefers-reduced-motion`. @default true */\n autoplay?: boolean;\n /** `fade` crossfades; `slide` also moves the slides a few pixels. @default \"fade\" */\n transition?: \"fade\" | \"slide\";\n /** After the last slide, go back to the first. @default true */\n loop?: boolean;\n /** The slide in view (controlled). */\n index?: number;\n /** The slide shown first. @default 0 */\n defaultIndex?: number;\n /** Called when the slide in view changes — `user` is true when the visitor asked for it. */\n onIndexChange?: (index: number, user: boolean) => void;\n /** The words of the controls, translated. */\n labels?: ShowcaseLabels;\n}\n\n/** An auto-advancing slideshow with a visible, clickable progress bar, a counter, prev / next and a pause button. */\nexport function Showcase<T>({\n label,\n items,\n getId,\n renderItem,\n getTitle,\n interval = 6000,\n autoplay = true,\n transition = \"fade\",\n loop = true,\n index: controlled,\n defaultIndex = 0,\n onIndexChange,\n labels = showcaseLabels,\n className,\n onKeyDown,\n onFocus,\n onBlur,\n onPointerEnter,\n onPointerLeave,\n ...props\n}: ShowcaseProps<T>) {\n const total = items.length;\n const root = React.useRef<HTMLElement>(null);\n const [inner, setInner] = React.useState(defaultIndex);\n const current = Math.min(Math.max(controlled ?? inner, 0), Math.max(total - 1, 0));\n const mounted = React.useSyncExternalStore(subscribeNothing, () => true, () => false);\n const reduced = React.useSyncExternalStore(subscribeReduced, readReduced, () => false);\n const hidden = React.useSyncExternalStore(subscribeVisibility, readHidden, () => false);\n const [paused, setPaused] = React.useState(false);\n const [hover, setHover] = React.useState(false);\n const [focus, setFocus] = React.useState(false);\n const [inView, setInView] = React.useState(true);\n const [announce, setAnnounce] = React.useState(false);\n\n React.useEffect(() => {\n const host = root.current;\n if (!host || typeof IntersectionObserver === \"undefined\") return;\n const observer = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)), { threshold: 0.2 });\n observer.observe(host);\n return () => observer.disconnect();\n }, []);\n\n const move = (to: number, user: boolean) => {\n if (total === 0) return;\n const next = loop ? (to + total) % total : Math.min(Math.max(to, 0), total - 1);\n if (next === current) return;\n setAnnounce(user);\n if (controlled === undefined) setInner(next);\n onIndexChange?.(next, user);\n };\n\n const playing = autoplay && mounted && !reduced && total > 1;\n const running = playing && !paused && !hover && !focus && !hidden && inView;\n const lastStop = !loop && current === total - 1;\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n onKeyDown?.(event);\n if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;\n const target = event.target as HTMLElement;\n if (target.closest(\"input, textarea, select, [contenteditable='true']\")) return;\n const to = event.key === \"ArrowRight\" ? current + 1 : event.key === \"ArrowLeft\" ? current - 1 : event.key === \"Home\" ? 0 : event.key === \"End\" ? total - 1 : null;\n if (to === null) return;\n event.preventDefault();\n move(to, true);\n };\n\n const item = items[current];\n const title = item !== undefined ? getTitle?.(item) : undefined;\n const button = \"inline-flex h-9 w-9 items-center justify-center rounded-full border border-border-default bg-surface-1 text-fg-secondary transition-colors hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40\";\n\n return (\n <section\n ref={root}\n aria-roledescription=\"carousel\"\n aria-label={label}\n data-running={running ? \"\" : undefined}\n className={cn(\"flex flex-col gap-3\", className)}\n onKeyDown={handleKeyDown}\n onPointerEnter={(e) => {\n onPointerEnter?.(e);\n if (e.pointerType === \"mouse\") setHover(true);\n }}\n onPointerLeave={(e) => {\n onPointerLeave?.(e);\n setHover(false);\n }}\n onFocus={(e) => {\n onFocus?.(e);\n // A mouse click on a button is not a reason to stop: only a keyboard focus is.\n let visible = true;\n try {\n visible = e.target.matches(\":focus-visible\");\n } catch {\n /* a stand-in without :focus-visible */\n }\n if (visible) setFocus(true);\n }}\n onBlur={(e) => {\n onBlur?.(e);\n if (!e.currentTarget.contains(e.relatedTarget)) setFocus(false);\n }}\n {...props}\n >\n <style href=\"kz-showcase\" precedence=\"medium\">\n {FILL_CSS}\n </style>\n <div aria-live={announce ? \"polite\" : \"off\"} className=\"grid\">\n {items.map((slide, i) => {\n const active = i === current;\n const gap = Math.abs(i - current);\n const near = gap <= 1 || (loop && total - gap <= 1);\n const side = i < current ? \"-translate-x-4\" : \"translate-x-4\";\n return (\n <div\n key={getId(slide)}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={labels.slide(i + 1, total)}\n aria-hidden={active ? undefined : true}\n inert={active ? undefined : true}\n data-active={active ? \"\" : undefined}\n className={cn(\n \"col-start-1 row-start-1 min-w-0 transition-[opacity,translate,visibility] duration-500 motion-reduce:transition-none\",\n active ? \"visible opacity-100\" : \"invisible opacity-0\",\n transition === \"slide\" && (active ? \"translate-x-0\" : side),\n )}\n >\n {near ? renderItem(slide, { index: i, active }) : null}\n </div>\n );\n })}\n </div>\n\n <div className=\"flex gap-1\">\n {items.map((slide, i) => (\n <button\n key={getId(slide)}\n type=\"button\"\n aria-label={labels.goTo(i + 1, total)}\n aria-current={i === current ? \"true\" : undefined}\n tabIndex={i === current ? 0 : -1}\n onClick={() => move(i, true)}\n className=\"group/seg relative flex h-6 min-w-0 flex-1 cursor-pointer items-center rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span className=\"relative block h-1 w-full overflow-hidden rounded-full bg-surface-3 transition-[height] group-hover/seg:h-1.5\">\n {i < current || (i === current && (!playing || lastStop)) ? <span className=\"absolute inset-0 bg-accent\" /> : null}\n {i === current && playing && !lastStop ? (\n <span\n key={`${current}`}\n data-segment-fill\n className=\"kz-showcase-fill absolute inset-0 bg-accent\"\n style={{ animationDuration: `${interval}ms`, animationPlayState: running ? \"running\" : \"paused\" }}\n onAnimationEnd={() => running && move(current + 1, false)}\n />\n ) : null}\n </span>\n </button>\n ))}\n </div>\n\n <div className=\"flex items-center gap-3\">\n <p className=\"m-0 min-w-0 flex-1 truncate text-sm text-fg-secondary\">\n {title ? <span className=\"font-medium text-fg\">{title}</span> : null}\n {title ? <span aria-hidden> · </span> : null}\n <span>{labels.counter(current + 1, total)}</span>\n </p>\n <div className=\"flex items-center gap-1.5\">\n <button type=\"button\" aria-label={labels.previous} disabled={!loop && current === 0} onClick={() => move(current - 1, true)} className={button}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d=\"M15 5l-7 7 7 7\" />\n </svg>\n </button>\n {playing ? (\n <button type=\"button\" aria-label={paused ? labels.play : labels.pause} onClick={() => setPaused(!paused)} className={button}>\n <svg viewBox=\"0 0 24 24\" className=\"h-3.5 w-3.5\" fill=\"currentColor\" aria-hidden>\n {paused ? <path d=\"M8 5.5v13l10-6.5Z\" /> : <path d=\"M7 5h3.5v14H7zM13.5 5H17v14h-3.5z\" />}\n </svg>\n </button>\n ) : null}\n <button type=\"button\" aria-label={labels.next} disabled={!loop && current === total - 1} onClick={() => move(current + 1, true)} className={button}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </button>\n </div>\n </div>\n </section>\n );\n}\n\n/**\n * The typed reveal of a text, kept minimal: the visible prefix grows at `speed` characters per second while `active`;\n * under reduced motion (or when not active) it is the whole text at once. Render the full text for assistive technology\n * (`sr-only`) and this one `aria-hidden`.\n */\nexport function useTypedText(text: string, { active = true, speed = 45 }: { active?: boolean; speed?: number } = {}) {\n const [typed, setTyped] = React.useState({ of: \"\", count: 0 });\n const reduced = React.useSyncExternalStore(subscribeReduced, readReduced, () => false);\n React.useEffect(() => {\n if (!active || reduced) return;\n const id = window.setInterval(() => {\n setTyped((state) => {\n const count = state.of === text ? state.count + 1 : 1;\n if (count >= text.length) window.clearInterval(id);\n return { of: text, count };\n });\n }, Math.max(10, 1000 / speed));\n return () => window.clearInterval(id);\n }, [text, active, reduced, speed]);\n const count = typed.of === text ? typed.count : 0;\n const done = !active || reduced || count >= text.length;\n return { text: done ? text : text.slice(0, count), done };\n}\n"
|
|
311
|
+
"content": "// Client (state, timers' observers, keys). A slideshow that advances by itself — and shows it: a segmented progress bar\n// (one segment per slide, the current one fills over the interval, each one a button) with a counter, previous / next and a\n// visible pause button (WCAG 2.2.2). The time IS the CSS animation of the current segment (`.kz-showcase-fill`):\n// when it ends, the next slide comes — so what you see filling is exactly what decides, and pausing the animation pauses\n// the show. Its two rules travel with the component (a hoisted, deduplicated <style>): no stylesheet to import. It waits while the pointer is over it, while a keyboard focus is inside, while the tab is hidden and while it\n// is off screen; under reduced motion it never advances by itself. Slides are slots: any content.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\n\nconst FILL_CSS = \".kz-showcase-fill{transform-origin:left;animation:kz-showcase-fill 6s linear forwards}@keyframes kz-showcase-fill{from{transform:scaleX(0)}to{transform:scaleX(1)}}\";\nconst REDUCED = \"(prefers-reduced-motion: reduce)\";\nconst subscribeNothing = () => () => undefined;\nconst readReduced = () => Boolean(typeof window !== \"undefined\" && window.matchMedia?.(REDUCED).matches);\nfunction subscribeReduced(notify: () => void) {\n const query = window.matchMedia?.(REDUCED);\n query?.addEventListener?.(\"change\", notify);\n return () => query?.removeEventListener?.(\"change\", notify);\n}\nconst readHidden = () => document.visibilityState === \"hidden\";\nfunction subscribeVisibility(notify: () => void) {\n document.addEventListener(\"visibilitychange\", notify);\n return () => document.removeEventListener(\"visibilitychange\", notify);\n}\n\nexport interface ShowcaseLabels {\n /** The previous button's name. */\n previous: string;\n /** The next button's name. */\n next: string;\n /** The pause button's name while it plays. */\n pause: string;\n /** The same button's name while it is paused. */\n play: string;\n /** A progress segment's name: `(3, 17) => \"Go to image 3\"`. */\n goTo: (n: number, total: number) => string;\n /** The position in words, beside the title: `(1, 17) => \"Image 1 of 17\"`. */\n counter: (n: number, total: number) => string;\n /** The slide's accessible name: `(1, 17) => \"1 of 17\"`. */\n slide: (n: number, total: number) => string;\n}\n\n/** English defaults — pass your own translated `labels`. */\nexport const showcaseLabels: ShowcaseLabels = {\n previous: \"Previous slide\",\n next: \"Next slide\",\n pause: \"Pause the slideshow\",\n play: \"Play the slideshow\",\n goTo: (n) => `Go to slide ${n}`,\n counter: (n, total) => `Slide ${n} of ${total}`,\n slide: (n, total) => `${n} of ${total}`,\n};\n\nexport interface ShowcaseSlideState {\n index: number;\n /** The slide in view. */\n active: boolean;\n}\n\nexport interface ShowcaseProps<T> extends Omit<React.ComponentProps<\"section\">, \"children\" | \"onChange\"> {\n /** The slideshow's accessible name (“Prompt to image”), passed translated. */\n label: string;\n /** The slides, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** One slide's content. Only the slide in view and its neighbours are mounted (the rest stay empty): give slides the same size. */\n renderItem: (item: T, state: ShowcaseSlideState) => React.ReactNode;\n /** A slide's title, shown before the counter (“Golden-hour editorial”). */\n getTitle?: (item: T) => string;\n /** Milliseconds a slide stays (the time the segment takes to fill). @default 6000 */\n interval?: number;\n /** Advances by itself. Never under `prefers-reduced-motion`. @default true */\n autoplay?: boolean;\n /** `fade` crossfades; `slide` also moves the slides a few pixels. @default \"fade\" */\n transition?: \"fade\" | \"slide\";\n /** After the last slide, go back to the first. @default true */\n loop?: boolean;\n /** The slide in view (controlled). */\n index?: number;\n /** The slide shown first. @default 0 */\n defaultIndex?: number;\n /** Called when the slide in view changes — `user` is true when the visitor asked for it. */\n onIndexChange?: (index: number, user: boolean) => void;\n /** The words of the controls, translated. */\n labels?: ShowcaseLabels;\n}\n\n/** An auto-advancing slideshow with a visible, clickable progress bar, a counter, prev / next and a pause button. */\nexport function Showcase<T>({\n label,\n items,\n getId,\n renderItem,\n getTitle,\n interval = 6000,\n autoplay = true,\n transition = \"fade\",\n loop = true,\n index: controlled,\n defaultIndex = 0,\n onIndexChange,\n labels = showcaseLabels,\n className,\n onKeyDown,\n onFocus,\n onBlur,\n onPointerEnter,\n onPointerLeave,\n ...props\n}: ShowcaseProps<T>) {\n const total = items.length;\n const root = React.useRef<HTMLElement>(null);\n const [inner, setInner] = React.useState(defaultIndex);\n const current = Math.min(Math.max(controlled ?? inner, 0), Math.max(total - 1, 0));\n const mounted = React.useSyncExternalStore(subscribeNothing, () => true, () => false);\n const reduced = React.useSyncExternalStore(subscribeReduced, readReduced, () => false);\n const hidden = React.useSyncExternalStore(subscribeVisibility, readHidden, () => false);\n const [paused, setPaused] = React.useState(false);\n const [hover, setHover] = React.useState(false);\n const [focus, setFocus] = React.useState(false);\n const [inView, setInView] = React.useState(true);\n const [announce, setAnnounce] = React.useState(false);\n\n React.useEffect(() => {\n const host = root.current;\n if (!host || typeof IntersectionObserver === \"undefined\") return;\n const observer = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)), { threshold: 0.2 });\n observer.observe(host);\n return () => observer.disconnect();\n }, []);\n\n const move = (to: number, user: boolean) => {\n if (total === 0) return;\n const next = loop ? (to + total) % total : Math.min(Math.max(to, 0), total - 1);\n if (next === current) return;\n setAnnounce(user);\n if (controlled === undefined) setInner(next);\n onIndexChange?.(next, user);\n };\n\n const playing = autoplay && mounted && !reduced && total > 1;\n const running = playing && !paused && !hover && !focus && !hidden && inView;\n const lastStop = !loop && current === total - 1;\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n onKeyDown?.(event);\n if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;\n const target = event.target as HTMLElement;\n if (target.closest(\"input, textarea, select, [contenteditable='true']\")) return;\n const to = event.key === \"ArrowRight\" ? current + 1 : event.key === \"ArrowLeft\" ? current - 1 : event.key === \"Home\" ? 0 : event.key === \"End\" ? total - 1 : null;\n if (to === null) return;\n event.preventDefault();\n move(to, true);\n };\n\n const item = items[current];\n const title = item !== undefined ? getTitle?.(item) : undefined;\n const button = \"inline-flex h-9 w-9 items-center justify-center rounded-full border border-border-default bg-surface-1 text-fg-secondary transition-colors hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40\";\n\n return (\n <section\n ref={root}\n aria-roledescription=\"carousel\"\n aria-label={label}\n data-running={running ? \"\" : undefined}\n className={cn(\"flex flex-col gap-3\", className)}\n onKeyDown={handleKeyDown}\n onPointerEnter={(e) => {\n onPointerEnter?.(e);\n if (e.pointerType === \"mouse\") setHover(true);\n }}\n onPointerLeave={(e) => {\n onPointerLeave?.(e);\n setHover(false);\n }}\n onFocus={(e) => {\n onFocus?.(e);\n // A mouse click on a button is not a reason to stop: only a keyboard focus is.\n let visible = true;\n try {\n visible = e.target.matches(\":focus-visible\");\n } catch {\n /* a stand-in without :focus-visible */\n }\n if (visible) setFocus(true);\n }}\n onBlur={(e) => {\n onBlur?.(e);\n if (!e.currentTarget.contains(e.relatedTarget)) setFocus(false);\n }}\n {...props}\n >\n <style href=\"kz-showcase\" precedence=\"medium\">\n {FILL_CSS}\n </style>\n <div aria-live={announce ? \"polite\" : \"off\"} className=\"grid grid-cols-[minmax(0,1fr)]\">\n {items.map((slide, i) => {\n const active = i === current;\n const gap = Math.abs(i - current);\n const near = gap <= 1 || (loop && total - gap <= 1);\n const side = i < current ? \"-translate-x-4\" : \"translate-x-4\";\n return (\n <div\n key={getId(slide)}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={labels.slide(i + 1, total)}\n aria-hidden={active ? undefined : true}\n inert={active ? undefined : true}\n data-active={active ? \"\" : undefined}\n className={cn(\n \"col-start-1 row-start-1 min-w-0 transition-[opacity,translate,visibility] duration-500 motion-reduce:transition-none\",\n active ? \"visible opacity-100\" : \"invisible opacity-0\",\n transition === \"slide\" && (active ? \"translate-x-0\" : side),\n )}\n >\n {near ? renderItem(slide, { index: i, active }) : null}\n </div>\n );\n })}\n </div>\n\n <div className=\"flex gap-1\">\n {items.map((slide, i) => (\n <button\n key={getId(slide)}\n type=\"button\"\n aria-label={labels.goTo(i + 1, total)}\n aria-current={i === current ? \"true\" : undefined}\n tabIndex={i === current ? 0 : -1}\n onClick={() => move(i, true)}\n className=\"group/seg relative flex h-6 min-w-0 flex-1 cursor-pointer items-center rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span className=\"relative block h-1 w-full overflow-hidden rounded-full bg-surface-3 transition-[height] group-hover/seg:h-1.5\">\n {i < current || (i === current && (!playing || lastStop)) ? <span className=\"absolute inset-0 bg-accent\" /> : null}\n {i === current && playing && !lastStop ? (\n <span\n key={`${current}`}\n data-segment-fill\n className=\"kz-showcase-fill absolute inset-0 bg-accent\"\n style={{ animationDuration: `${interval}ms`, animationPlayState: running ? \"running\" : \"paused\" }}\n onAnimationEnd={() => running && move(current + 1, false)}\n />\n ) : null}\n </span>\n </button>\n ))}\n </div>\n\n <div className=\"flex items-center gap-3\">\n <p className=\"m-0 min-w-0 flex-1 truncate text-sm text-fg-secondary\">\n {title ? <span className=\"font-medium text-fg\">{title}</span> : null}\n {title ? <span aria-hidden> · </span> : null}\n <span>{labels.counter(current + 1, total)}</span>\n </p>\n <div className=\"flex items-center gap-1.5\">\n <button type=\"button\" aria-label={labels.previous} disabled={!loop && current === 0} onClick={() => move(current - 1, true)} className={button}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d=\"M15 5l-7 7 7 7\" />\n </svg>\n </button>\n {playing ? (\n <button type=\"button\" aria-label={paused ? labels.play : labels.pause} onClick={() => setPaused(!paused)} className={button}>\n <svg viewBox=\"0 0 24 24\" className=\"h-3.5 w-3.5\" fill=\"currentColor\" aria-hidden>\n {paused ? <path d=\"M8 5.5v13l10-6.5Z\" /> : <path d=\"M7 5h3.5v14H7zM13.5 5H17v14h-3.5z\" />}\n </svg>\n </button>\n ) : null}\n <button type=\"button\" aria-label={labels.next} disabled={!loop && current === total - 1} onClick={() => move(current + 1, true)} className={button}>\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </button>\n </div>\n </div>\n </section>\n );\n}\n\n/**\n * The typed reveal of a text, kept minimal: the visible prefix grows at `speed` characters per second while `active`;\n * under reduced motion (or when not active) it is the whole text at once. Render the full text for assistive technology\n * (`sr-only`) and this one `aria-hidden`.\n */\nexport function useTypedText(text: string, { active = true, speed = 45 }: { active?: boolean; speed?: number } = {}) {\n const [typed, setTyped] = React.useState({ of: \"\", count: 0 });\n const reduced = React.useSyncExternalStore(subscribeReduced, readReduced, () => false);\n React.useEffect(() => {\n if (!active || reduced) return;\n const id = window.setInterval(() => {\n setTyped((state) => {\n const count = state.of === text ? state.count + 1 : 1;\n if (count >= text.length) window.clearInterval(id);\n return { of: text, count };\n });\n }, Math.max(10, 1000 / speed));\n return () => window.clearInterval(id);\n }, [text, active, reduced, speed]);\n const count = typed.of === text ? typed.count : 0;\n const done = !active || reduced || count >= text.length;\n return { text: done ? text : text.slice(0, count), done };\n}\n"
|
|
312
312
|
}
|
|
313
313
|
],
|
|
314
314
|
"dependencies": [],
|