@krizaka/ui 3.7.0 → 3.8.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 -4
- package/dist/{chunk-JZPVUJIX.js → chunk-UB2BI3AW.js} +1 -1
- package/dist/{chunk-JZPVUJIX.js.map → chunk-UB2BI3AW.js.map} +1 -1
- package/dist/{geometry-CbOU4rtL.d.ts → geometry-B2E8oFhg.d.ts} +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +26 -24
- package/dist/index.js.map +1 -1
- package/dist/map.js +1 -1
- package/dist/map.js.map +1 -1
- package/dist/mascot-quote.d.ts +1 -1
- package/dist/mascot-quote.js.map +1 -1
- package/dist/mascot-state.d.ts +1 -1
- package/dist/mascot-state.js +1 -1
- package/dist/mascots.d.ts +2 -2
- package/dist/mascots.js +1 -1
- package/dist/native/map.js +1 -1
- package/dist/native/map.js.map +1 -1
- package/dist/native.d.ts +9 -7
- package/dist/native.js +26 -24
- package/dist/native.js.map +1 -1
- package/dist/status-page.d.ts +1 -1
- package/dist/status-page.js.map +1 -1
- package/dist/vertical-video.js +2 -2
- package/dist/vertical-video.js.map +1 -1
- package/package.json +5 -5
- package/registry/examples/landing-page/krizaka.js +1 -1
- package/registry/examples/landing-page/krizaka.tsx +1 -1
- package/registry/examples/mascots/flock.tsx +1 -1
- package/registry/examples/profile-card/match.js +1 -1
- package/registry/examples/profile-card/match.tsx +1 -1
- package/registry/examples/status-page/building.js +1 -1
- package/registry/examples/status-page/building.tsx +1 -1
- package/registry/index.json +3 -3
- package/registry/landing-page.json +2 -2
- package/registry/loader.json +3 -3
- package/registry/map.json +1 -1
- package/registry/mascots.json +4 -4
- package/registry/product-page.json +2 -2
- package/registry/profile-card.json +1 -1
- package/registry/status-page.json +6 -6
- package/registry/vertical-video.json +2 -2
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"name": "title",
|
|
66
66
|
"type": "ReactNode",
|
|
67
67
|
"default": null,
|
|
68
|
-
"description": "The page's `h1` (`Status`, `
|
|
68
|
+
"description": "The page's `h1` (`Status`, `Yasakia is on its way`).",
|
|
69
69
|
"required": true
|
|
70
70
|
},
|
|
71
71
|
{
|
|
@@ -142,13 +142,13 @@
|
|
|
142
142
|
{
|
|
143
143
|
"name": "building",
|
|
144
144
|
"title": "Under construction",
|
|
145
|
-
"description": "
|
|
145
|
+
"description": "Yasakia on its way: progress and how to hear about it.",
|
|
146
146
|
"template": true,
|
|
147
147
|
"purpose": "recover",
|
|
148
148
|
"why": "A visitor learns honestly what is coming, how far it is and how to hear about it.",
|
|
149
149
|
"path": "examples/status-page/building.tsx",
|
|
150
150
|
"module": "@krizaka/ui/registry/examples/status-page/building",
|
|
151
|
-
"code": "import { Button } from \"@krizaka/ui/button\";\nimport { Mascot } from \"@krizaka/ui/mascots\";\nimport { Progress } from \"@krizaka/ui/progress\";\nimport { StatusPage } from \"@krizaka/ui/status-page\";\n\n// TEMPLATE — « under construction »: what is coming, said honestly — its state in words, its progress, how to hear\n// about it — the duck that builds beside it. Never a fake screen.\nexport default function StatusPageBuilding() {\n return (\n <StatusPage\n eyebrow=\"
|
|
151
|
+
"code": "import { Button } from \"@krizaka/ui/button\";\nimport { Mascot } from \"@krizaka/ui/mascots\";\nimport { Progress } from \"@krizaka/ui/progress\";\nimport { StatusPage } from \"@krizaka/ui/status-page\";\n\n// TEMPLATE — « under construction »: what is coming, said honestly — its state in words, its progress, how to hear\n// about it — the duck that builds beside it. Never a fake screen.\nexport default function StatusPageBuilding() {\n return (\n <StatusPage\n eyebrow=\"Yasakia\"\n title=\"On its way\"\n lead=\"Meet by talking first. The first build reaches testers this winter.\"\n aside={<Mascot bird=\"duck\" mood=\"thinking\" size={112} />}\n state=\"building\"\n stateLabel=\"In construction\"\n checked=\"Updated this week\"\n >\n <div className=\"flex max-w-md flex-col gap-4\">\n <Progress value={40} label=\"First build\" showLabel valueText=\"4 of 10 milestones\" />\n <Button variant=\"primary\" shape=\"cut\" className=\"w-fit\">Tell me when it is ready</Button>\n </div>\n </StatusPage>\n );\n}\n"
|
|
152
152
|
},
|
|
153
153
|
{
|
|
154
154
|
"name": "maintenance",
|
|
@@ -175,11 +175,11 @@
|
|
|
175
175
|
},
|
|
176
176
|
{
|
|
177
177
|
"path": "status-page/meta.ts",
|
|
178
|
-
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Status page\",\n summary: \"Where things stand: the overall state in words and colour, each service on its line, the incidents — or, for what is not built yet, its progress and how to hear about it.\",\n status: \"beta\",\n category: \"templates\",\n platforms: \"web\",\n whenToUse: [\"A service status page.\", \"An « under construction » page that says honestly what is coming.\"],\n whenNotToUse: [\n { when: \"For what changed over time.\", use: \"changelog-page\" },\n { when: \"For a page that does not exist.\", use: \"error-page\" },\n ],\n bestPractices: [\"Always say the state in words; the colour only repeats it.\", \"Under construction: show progress and a way to follow, never a fake screen.\", \"Words and content arrive as props; the motion is the platform's (`[data-reveal]`, still under reduced motion).\"],\n accessibility: {\n keyboard: [],\n notes: [\"The overall state is a `status` region.\", \"Each service says its state in words beside its dot.\"],\n },\n related: [\"changelog-page\", \"progress\", \"error-page\"],\n web: {\n imports: [\"StatusPage\"],\n examples: [\n { name: \"operational\", title: \"Status\", description: \"Three services, one slower than usual.\", template: true, purpose: \"recover\", why: \"A person checks in one look whether the service works and which part is slow.\" },\n { name: \"building\", title: \"Under construction\", description: \"
|
|
178
|
+
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Status page\",\n summary: \"Where things stand: the overall state in words and colour, each service on its line, the incidents — or, for what is not built yet, its progress and how to hear about it.\",\n status: \"beta\",\n category: \"templates\",\n platforms: \"web\",\n whenToUse: [\"A service status page.\", \"An « under construction » page that says honestly what is coming.\"],\n whenNotToUse: [\n { when: \"For what changed over time.\", use: \"changelog-page\" },\n { when: \"For a page that does not exist.\", use: \"error-page\" },\n ],\n bestPractices: [\"Always say the state in words; the colour only repeats it.\", \"Under construction: show progress and a way to follow, never a fake screen.\", \"Words and content arrive as props; the motion is the platform's (`[data-reveal]`, still under reduced motion).\"],\n accessibility: {\n keyboard: [],\n notes: [\"The overall state is a `status` region.\", \"Each service says its state in words beside its dot.\"],\n },\n related: [\"changelog-page\", \"progress\", \"error-page\"],\n web: {\n imports: [\"StatusPage\"],\n examples: [\n { name: \"operational\", title: \"Status\", description: \"Three services, one slower than usual.\", template: true, purpose: \"recover\", why: \"A person checks in one look whether the service works and which part is slow.\" },\n { name: \"building\", title: \"Under construction\", description: \"Yasakia on its way: progress and how to hear about it.\", template: true, purpose: \"recover\", why: \"A visitor learns honestly what is coming, how far it is and how to hear about it.\" },\n { name: \"maintenance\", title: \"Planned maintenance\", description: \"What is paused until when, and what still works.\", template: true, purpose: \"recover\", why: \"A person learns what is down, until when, and what they can still do.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
|
|
179
179
|
},
|
|
180
180
|
{
|
|
181
181
|
"path": "status-page/status-page.tsx",
|
|
182
|
-
"content": "// Server-safe. Where things stand (blocks.css `.kz-tpl-state`, `.kz-tpl-services`): the overall state in words and\n// colour, each service on its line, the incidents under them — or, for something not built yet (`state=\"building\"`),\n// what is coming, its progress and how to hear about it, the bird beside it. A state is always said in words.\nimport { Page, Section } from \"@krizaka/ui/page\";\nimport type { LinkComponent } from \"@krizaka/ui/page-hero\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\nimport type * as React from \"react\";\n\nexport type ServiceState = \"operational\" | \"degraded\" | \"outage\" | \"building\";\n\nexport type StatusPageProps = Omit<React.HTMLAttributes<HTMLElement>, \"title\"> & {\n /** The way back. */\n back?: { href: string; label: string };\n /** A mono line above the title. */\n eyebrow?: React.ReactNode;\n /** The page's `h1` (`Status`, `
|
|
182
|
+
"content": "// Server-safe. Where things stand (blocks.css `.kz-tpl-state`, `.kz-tpl-services`): the overall state in words and\n// colour, each service on its line, the incidents under them — or, for something not built yet (`state=\"building\"`),\n// what is coming, its progress and how to hear about it, the bird beside it. A state is always said in words.\nimport { Page, Section } from \"@krizaka/ui/page\";\nimport type { LinkComponent } from \"@krizaka/ui/page-hero\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\nimport type * as React from \"react\";\n\nexport type ServiceState = \"operational\" | \"degraded\" | \"outage\" | \"building\";\n\nexport type StatusPageProps = Omit<React.HTMLAttributes<HTMLElement>, \"title\"> & {\n /** The way back. */\n back?: { href: string; label: string };\n /** A mono line above the title. */\n eyebrow?: React.ReactNode;\n /** The page's `h1` (`Status`, `Yasakia is on its way`). */\n title: React.ReactNode;\n /** One sentence under it. */\n lead?: React.ReactNode;\n /** Beside the hero: a bird (the pigeon that delivers, the duck that builds). */\n aside?: React.ReactNode;\n /** The overall state. */\n state: ServiceState;\n /** The overall state, in words (`All systems operational`). */\n stateLabel: React.ReactNode;\n /** When it was checked (`Checked 2 min ago`). */\n checked?: React.ReactNode;\n /** The services and their states, in words: `{ id, name, state, label }`. */\n services?: readonly { id: string; name: React.ReactNode; state: ServiceState; label: React.ReactNode }[];\n /** The services list's accessible name (`Services`). */\n servicesLabel?: string;\n /** Under the services: the incidents (a ChangelogPage's entries, a list), or for `building` the progress and the way to follow. */\n children?: React.ReactNode;\n /** The app's link component (Next's `Link`). */\n linkAs?: LinkComponent;\n};\n\n/** A status page — or an « under construction » one: the overall state, each service, then incidents or progress. */\nexport function StatusPage({ back, eyebrow, title, lead, aside, state, stateLabel, checked, services, servicesLabel = \"Services\", linkAs, children, ...props }: StatusPageProps) {\n return (\n <div {...props}>\n <PageHero back={back} linkAs={linkAs} eyebrow={eyebrow} title={title} lead={lead} aside={aside} backdrop=\"plain\" />\n <Page className=\"kz-tpl-longform\">\n <div className=\"kz-tpl-state\" data-state={state} role=\"status\">\n <span className=\"kz-tpl-dot\" aria-hidden />\n <strong>{stateLabel}</strong>\n {checked && <span className=\"kz-tpl-count\">{checked}</span>}\n </div>\n {services && services.length > 0 && (\n <ul className=\"kz-tpl-services\" aria-label={servicesLabel}>\n {services.map((s) => (\n <li key={s.id}>\n <span>{s.name}</span>\n <small>\n <span className=\"kz-tpl-dot\" data-state={s.state} aria-hidden />\n {s.label}\n </small>\n </li>\n ))}\n </ul>\n )}\n </Page>\n {children && <Section spacing=\"sm\">{children}</Section>}\n </div>\n );\n}\n"
|
|
183
183
|
}
|
|
184
184
|
],
|
|
185
185
|
"dependencies": [],
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"name": "title",
|
|
215
215
|
"type": "ReactNode",
|
|
216
216
|
"default": null,
|
|
217
|
-
"description": "The page's `h1` (`Status`, `
|
|
217
|
+
"description": "The page's `h1` (`Status`, `Yasakia is on its way`).",
|
|
218
218
|
"required": true
|
|
219
219
|
},
|
|
220
220
|
{
|
|
@@ -603,11 +603,11 @@
|
|
|
603
603
|
},
|
|
604
604
|
{
|
|
605
605
|
"path": "vertical-video/short-feed.tsx",
|
|
606
|
-
"content": "// Client (an observer, keys). A vertical feed of short videos: one slide per screen, CSS scroll-snap, the slide in view\n// plays and the others pause, the next one preloads, only the slides near the one in view are mounted (the rest keep their\n// height). ↑/↓ (and PageUp/PageDown) go to the previous / next, with the focus following.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\n\nexport interface ShortFeedProps<T> extends Omit<React.ComponentProps<\"section\">, \"children\"> {\n /** The feed's accessible name (\"Videos\"), passed translated. */\n label: string;\n /** The videos, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** One slide: `active` is true for the one in view, `preload` for the next, `near` false for the ones kept empty. */\n renderItem: (item: T, state: { active: boolean; preload: \"none\" | \"metadata\" | \"auto\"; index: number }) => React.ReactNode;\n /** The slides mounted around the one in view (before and after). Default 1; the others keep their height empty. */\n window?: number;\n /** Called when the slide in view changes. */\n onActiveChange?: (index: number, item: T) => void;\n}\n\n/** A vertical snap-scrolling feed that plays the video in view, preloads the next and pauses the rest. */\nexport function ShortFeed<T>({ label, items, getId, renderItem, window: around = 1, onActiveChange, className, onKeyDown, ...props }: ShortFeedProps<T>) {\n const root = React.useRef<HTMLElement>(null);\n const [active, setActive] = React.useState(0);\n const slides = React.useRef<(HTMLDivElement | null)[]>([]);\n\n React.useEffect(() => {\n const host = root.current;\n if (!host || typeof IntersectionObserver === \"undefined\") return;\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting || entry.intersectionRatio < 0.6) continue;\n const index = Number((entry.target as HTMLElement).dataset.index);\n setActive(index);\n }\n },\n { root: host, threshold: [0.6] },\n );\n for (const slide of slides.current) if (slide) observer.observe(slide);\n return () => observer.disconnect();\n }, [items.length]);\n\n const first = React.useRef(true);\n React.useEffect(() => {\n if (first.current) {\n first.current = false;\n return;\n }\n const item = items[active];\n if (item !== undefined) onActiveChange?.(active, item);\n // Only a change of the slide in view is an event.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active]);\n\n const go = (index: number) => {\n const target = slides.current[Math.max(0, Math.min(items.length - 1, index))];\n if (!target) return;\n target.scrollIntoView({ behavior: window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\", block: \"start\" });\n target.querySelector<HTMLElement>(\"article\")?.focus({ preventScroll: true });\n };\n\n return (\n <section\n ref={root}\n aria-label={label}\n aria-roledescription=\"carousel\"\n className={cn(\"h-dvh snap-y snap-mandatory overflow-y-auto overscroll-contain bg-surface-0 [scrollbar-width:none]\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"ArrowDown\" || event.key === \"PageDown\") {\n event.preventDefault();\n go(active + 1);\n } else if (event.key === \"ArrowUp\" || event.key === \"PageUp\") {\n event.preventDefault();\n go(active - 1);\n }\n }}\n {...props}\n >\n {items.map((item, index) => {\n const near = Math.abs(index - active) <= around;\n return (\n <div\n key={getId(item)}\n ref={(el) => {\n slides.current[index] = el;\n }}\n data-index={index}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${index + 1} / ${items.length}`}\n className=\"mx-auto flex h-
|
|
606
|
+
"content": "// Client (an observer, keys). A vertical feed of short videos: one slide per screen, CSS scroll-snap, the slide in view\n// plays and the others pause, the next one preloads, only the slides near the one in view are mounted (the rest keep their\n// height). ↑/↓ (and PageUp/PageDown) go to the previous / next, with the focus following.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\n\nexport interface ShortFeedProps<T> extends Omit<React.ComponentProps<\"section\">, \"children\"> {\n /** The feed's accessible name (\"Videos\"), passed translated. */\n label: string;\n /** The videos, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** One slide: `active` is true for the one in view, `preload` for the next, `near` false for the ones kept empty. */\n renderItem: (item: T, state: { active: boolean; preload: \"none\" | \"metadata\" | \"auto\"; index: number }) => React.ReactNode;\n /** The slides mounted around the one in view (before and after). Default 1; the others keep their height empty. */\n window?: number;\n /** Called when the slide in view changes. */\n onActiveChange?: (index: number, item: T) => void;\n}\n\n/** A vertical snap-scrolling feed that plays the video in view, preloads the next and pauses the rest. */\nexport function ShortFeed<T>({ label, items, getId, renderItem, window: around = 1, onActiveChange, className, onKeyDown, ...props }: ShortFeedProps<T>) {\n const root = React.useRef<HTMLElement>(null);\n const [active, setActive] = React.useState(0);\n const slides = React.useRef<(HTMLDivElement | null)[]>([]);\n\n React.useEffect(() => {\n const host = root.current;\n if (!host || typeof IntersectionObserver === \"undefined\") return;\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting || entry.intersectionRatio < 0.6) continue;\n const index = Number((entry.target as HTMLElement).dataset.index);\n setActive(index);\n }\n },\n { root: host, threshold: [0.6] },\n );\n for (const slide of slides.current) if (slide) observer.observe(slide);\n return () => observer.disconnect();\n }, [items.length]);\n\n const first = React.useRef(true);\n React.useEffect(() => {\n if (first.current) {\n first.current = false;\n return;\n }\n const item = items[active];\n if (item !== undefined) onActiveChange?.(active, item);\n // Only a change of the slide in view is an event.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active]);\n\n const go = (index: number) => {\n const target = slides.current[Math.max(0, Math.min(items.length - 1, index))];\n if (!target) return;\n target.scrollIntoView({ behavior: window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\", block: \"start\" });\n target.querySelector<HTMLElement>(\"article\")?.focus({ preventScroll: true });\n };\n\n return (\n <section\n ref={root}\n aria-label={label}\n aria-roledescription=\"carousel\"\n className={cn(\"h-dvh snap-y snap-mandatory overflow-y-auto overscroll-contain bg-surface-0 [scrollbar-width:none]\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"ArrowDown\" || event.key === \"PageDown\") {\n event.preventDefault();\n go(active + 1);\n } else if (event.key === \"ArrowUp\" || event.key === \"PageUp\") {\n event.preventDefault();\n go(active - 1);\n }\n }}\n {...props}\n >\n {items.map((item, index) => {\n const near = Math.abs(index - active) <= around;\n return (\n <div\n key={getId(item)}\n ref={(el) => {\n slides.current[index] = el;\n }}\n data-index={index}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${index + 1} / ${items.length}`}\n className=\"mx-auto flex h-full max-w-[calc(100dvh*9/16)] snap-start snap-always items-center\"\n >\n {near ? renderItem(item, { active: index === active, preload: index === active + 1 ? \"auto\" : index === active ? \"auto\" : \"none\", index }) : null}\n </div>\n );\n })}\n </section>\n );\n}\n"
|
|
607
607
|
},
|
|
608
608
|
{
|
|
609
609
|
"path": "vertical-video/vertical-video.tsx",
|
|
610
|
-
"content": "// Client (a video element, an observer, keys). A 9:16 clip filling its frame, with the action rail of a short-video feed on\n// the right (avatar + follow, like, comment, share, a slot for tip / unlock, more) and the caption block bottom-left. Every\n// word is a prop; counts are formatted with @krizaka/intl; everything over the picture reads on the invariant `scrim` /\n// `fg-on-media` tokens, so it is legible in both themes. Under reduced motion nothing autoplays: the poster and a play button.\nimport { formatCompact } from \"@krizaka/intl\";\nimport * as React from \"react\";\n\nimport { Avatar } from \"../avatar/avatar\";\nimport { IconButton } from \"../button/button\";\nimport { cn } from \"../cn\";\nimport { VerifiedBadge } from \"../verified-badge/verified-badge\";\n\nexport interface VerticalVideoLabels {\n /** \"Play\" / \"Pause\" — the tap target over the picture. */\n play: string;\n pause: string;\n /** \"Like\" / \"Unlike\" (the button's name; the state is `aria-pressed`). */\n like: string;\n comment: string;\n share: string;\n more: string;\n /** \"Follow Elena\" — `{name}` is replaced by the creator's name. */\n follow: string;\n /** \"Following Elena\". */\n following: string;\n /** \"More\" / \"Less\" on a long caption. */\n expand: string;\n collapse: string;\n /** The progress bar's name (\"Progress\"). */\n progress: string;\n /** The rail's name (\"Actions\"). */\n actions: string;\n}\n\nexport const verticalVideoLabels: VerticalVideoLabels = {\n play: \"Play\",\n pause: \"Pause\",\n like: \"Like\",\n comment: \"Comments\",\n share: \"Share\",\n more: \"More\",\n follow: \"Follow {name}\",\n following: \"Following {name}\",\n expand: \"More\",\n collapse: \"Less\",\n progress: \"Progress\",\n actions: \"Actions\",\n};\n\nexport interface VerticalVideoCreator {\n name: string;\n /** Their portrait. */\n avatarSrc?: string;\n /** The seal's words (\"Verified\"): shows the seal beside the name. */\n verifiedLabel?: string;\n}\n\nexport interface VerticalVideoProps extends Omit<React.ComponentProps<\"article\">, \"title\" | \"children\"> {\n /** The video's accessible name (\"Video: Night shoot, part 2\"), passed translated. */\n title: string;\n /** The clip — absent, the poster alone shows (MP4 or WebM; an HLS address only in Safari — use the player of `@krizaka/ui/video-player` for HLS). */\n src?: string;\n /** The picture until the first frame, and under reduced motion. */\n poster?: string;\n /** Plays while `true`; a `ShortFeed` passes it for the slide in view. Default `true`. */\n active?: boolean;\n /** The next slide's: `auto` loads it ahead, `none` waits. Default `metadata`. */\n preload?: \"none\" | \"metadata\" | \"auto\";\n /** Plays again at the end. Default `true`. */\n loop?: boolean;\n /** Starts muted (autoplay needs it). Default `true`. */\n muted?: boolean;\n /** Who made it: name, portrait, the seal's words. */\n creator: VerticalVideoCreator;\n /** A pill beside the name: \"AI-GENERATED\" — said on the picture, never only in a page's footnote. */\n disclosure?: string;\n /** The caption: collapsed to two lines, expandable when long. */\n caption?: string;\n /** Tags under the caption (without `#`). */\n tags?: readonly string[];\n /** The sound or credit line (\"Original sound — Elena\"). */\n credit?: string;\n /** The like count under its button, compact (`12.4K`) in `locale`. */\n likes?: number;\n /** The comment count under its button. */\n comments?: number;\n /** The share count under its button. */\n shares?: number;\n /** Whether the viewer likes it: `aria-pressed` on the like button. */\n liked?: boolean;\n /** Whether the viewer follows the creator: the \"+\" on the avatar becomes a check. */\n following?: boolean;\n /** The viewer likes or unlikes (the button, `L`, a double tap). */\n onLike?: (liked: boolean) => void;\n /** Opens the comments; without it the button still shows. */\n onComment?: () => void;\n /** Opens the share sheet. */\n onShare?: () => void;\n /** The viewer follows or unfollows; without it the avatar has no \"+\". */\n onFollow?: (following: boolean) => void;\n /** The \"more\" button's action (report, hide); without it the button is not drawn. */\n onMore?: () => void;\n /** The product's own action on the rail: a tip, an unlock — a button of yours (≥ 44 px, named). */\n tipSlot?: React.ReactNode;\n /** Top left: a chip over the picture (an auction's time left). */\n topStart?: React.ReactNode;\n /** Above the caption: a goal gauge, a challenge's progress. */\n aboveCaption?: React.ReactNode;\n /** The locale of the counts. Default `en`. */\n locale?: string;\n /** Every word, passed translated. */\n labels?: Partial<VerticalVideoLabels>;\n}\n\nconst glyph = (d: string) => (\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d={d} />\n </svg>\n);\nconst HEART = \"M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z\";\nconst COMMENT = \"M4 5h16v11H9l-5 4z\";\nconst SHARE = \"M4 12v7h16v-7M12 3v12M7 8l5-5 5 5\";\nconst MORE = \"M5 12h.01M12 12h.01M19 12h.01\";\n\nconst useReducedMotion = () =>\n React.useSyncExternalStore(\n (notify) => {\n const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n query.addEventListener(\"change\", notify);\n return () => query.removeEventListener(\"change\", notify);\n },\n () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n () => false,\n );\n\n/** One short video: 9:16, the rail of actions on the right, the creator's caption bottom-left, a progress hairline. */\nexport function VerticalVideo({\n title,\n src,\n poster,\n active = true,\n preload = \"metadata\",\n loop = true,\n muted = true,\n creator,\n disclosure,\n caption,\n tags = [],\n credit,\n likes,\n comments,\n shares,\n liked = false,\n following = false,\n onLike,\n onComment,\n onShare,\n onFollow,\n onMore,\n tipSlot,\n topStart,\n aboveCaption,\n locale = \"en\",\n labels: custom,\n className,\n onKeyDown,\n ...props\n}: VerticalVideoProps) {\n const labels = { ...verticalVideoLabels, ...custom };\n const reduce = useReducedMotion();\n const video = React.useRef<HTMLVideoElement>(null);\n const [playing, setPlaying] = React.useState(false);\n const [paused, setPaused] = React.useState(false); // the viewer's pause: it holds until they play again\n const [progress, setProgress] = React.useState(0);\n const [open, setOpen] = React.useState(false);\n const [heart, setHeart] = React.useState(0);\n\n const shouldPlay = active && !paused && !reduce;\n React.useEffect(() => {\n const el = video.current;\n if (!el) return;\n if (shouldPlay) void el.play().catch(() => undefined);\n else el.pause();\n }, [shouldPlay, src]);\n\n const press = () => {\n const el = video.current;\n if (!el) return;\n if (el.paused) {\n setPaused(false);\n void el.play().catch(() => undefined);\n } else {\n setPaused(true);\n el.pause();\n }\n };\n const like = () => onLike?.(!liked);\n const doubleTap = () => {\n if (!liked) like();\n setHeart((n) => n + 1); // a heart blooms over the picture (still under reduced motion: it shows and goes)\n };\n const count = (n?: number) => (n === undefined ? undefined : formatCompact(n, { locale }));\n const name = creator.name;\n\n return (\n <article\n aria-label={title}\n tabIndex={0}\n data-playing={playing ? \"\" : undefined}\n className={cn(\"relative isolate aspect-[9/16] w-full overflow-hidden bg-surface-0 text-fg-on-media focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented || event.target !== event.currentTarget) return;\n if (event.key === \" \" || event.key.toLowerCase() === \"k\") {\n event.preventDefault();\n press();\n } else if (event.key.toLowerCase() === \"l\") like();\n }}\n {...props}\n >\n <video\n ref={video}\n src={src}\n poster={poster}\n muted={muted}\n loop={loop}\n playsInline\n preload={preload}\n className=\"absolute inset-0 -z-10 h-full w-full object-cover\"\n onPlay={() => setPlaying(true)}\n onPause={() => setPlaying(false)}\n onTimeUpdate={(event) => {\n const el = event.currentTarget;\n setProgress(el.duration ? el.currentTime / el.duration : 0);\n }}\n />\n {/* The tap target: a tap pauses or plays, a double tap likes. It is a button, so the keyboard has it too. */}\n <button type=\"button\" aria-label={playing ? labels.pause : labels.play} onClick={press} onDoubleClick={doubleTap} className=\"absolute inset-0 cursor-pointer bg-transparent focus-visible:outline-hidden\">\n {!playing && (\n <span aria-hidden className=\"absolute left-1/2 top-1/2 grid h-16 w-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-scrim backdrop-blur-md\">\n <svg viewBox=\"0 0 24 24\" className=\"h-7 w-7\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\" /></svg>\n </span>\n )}\n </button>\n {heart > 0 && (\n <span key={heart} aria-hidden className=\"pointer-events-none absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center text-accent motion-safe:animate-ping\">\n <svg viewBox=\"0 0 24 24\" className=\"h-20 w-20\" fill=\"currentColor\"><path d={HEART} /></svg>\n </span>\n )}\n <div aria-hidden className=\"pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-linear-to-t from-scrim to-transparent\" />\n {topStart && <div className=\"absolute left-3 top-3\">{topStart}</div>}\n\n <div role=\"group\" aria-label={labels.actions} className=\"absolute bottom-16 right-2 flex flex-col items-center gap-3\">\n <div className=\"relative\">\n <Avatar src={creator.avatarSrc} alt={name} fallback={name.slice(0, 1)} size=\"md\" className=\"ring-2 ring-white\" />\n {onFollow && (\n <IconButton\n label={format(following ? labels.following : labels.follow, name)}\n aria-pressed={following}\n size=\"icon\"\n variant={following ? \"secondary\" : \"primary\"}\n shape=\"pill\"\n onClick={() => onFollow(!following)}\n className=\"absolute -bottom-3 left-1/2 h-6 min-h-6 w-6 min-w-6 -translate-x-1/2 p-0\"\n >\n {glyph(following ? \"M5 12l5 5 9-10\" : \"M12 5v14M5 12h14\")}\n </IconButton>\n )}\n </div>\n <Rail label={labels.like} count={count(likes)} pressed={liked} onClick={like} tone={liked ? \"text-accent\" : undefined}>\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill={liked ? \"currentColor\" : \"none\"} stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden><path d={HEART} /></svg>\n </Rail>\n <Rail label={labels.comment} count={count(comments)} onClick={onComment}>{glyph(COMMENT)}</Rail>\n <Rail label={labels.share} count={count(shares)} onClick={onShare}>{glyph(SHARE)}</Rail>\n {tipSlot}\n {onMore && <Rail label={labels.more} onClick={onMore}>{glyph(MORE)}</Rail>}\n </div>\n\n <div className=\"absolute bottom-4 left-3 right-20 flex flex-col gap-1.5\">\n {aboveCaption}\n <p className=\"flex items-center gap-1.5 text-sm font-bold\">\n {name}\n {creator.verifiedLabel && <VerifiedBadge label={creator.verifiedLabel} onMedia size=\"sm\" />}\n {disclosure && <span className=\"rounded-full bg-scrim px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-fg-on-media backdrop-blur-md\">{disclosure}</span>}\n </p>\n {caption && (\n <p className={cn(\"text-sm leading-snug\", !open && \"line-clamp-2\")}>\n {caption}\n {tags.length > 0 && <span className=\"font-semibold\"> {tags.map((tag) => `#${tag}`).join(\" \")}</span>}\n </p>\n )}\n {caption && caption.length > 90 && (\n <button type=\"button\" aria-expanded={open} onClick={() => setOpen((v) => !v)} className=\"w-fit rounded text-xs font-semibold underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\">\n {open ? labels.collapse : labels.expand}\n </button>\n )}\n {credit && <p className=\"text-xs opacity-90\">{credit}</p>}\n </div>\n\n <div role=\"progressbar\" aria-label={labels.progress} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)} className=\"absolute inset-x-0 bottom-0 h-1 bg-scrim\">\n <div className=\"h-full bg-white\" style={{ width: `${progress * 100}%` }} />\n </div>\n </article>\n );\n}\n\n/** \"Follow {name}\" → \"Follow Elena\". */\nconst format = (message: string, name: string) => message.replace(\"{name}\", name);\n\nfunction Rail({ label, count, pressed, onClick, tone, children }: { label: string; count?: string | undefined; pressed?: boolean; onClick?: (() => void) | undefined; tone?: string | undefined; children: React.ReactNode }) {\n return (\n <div className=\"flex flex-col items-center gap-0.5\">\n <IconButton label={label} aria-pressed={pressed} variant=\"ghost\" shape=\"pill\" onClick={onClick} className={cn(\"h-11 min-h-11 w-11 min-w-11 bg-scrim text-fg-on-media backdrop-blur-md hover:bg-scrim\", tone)}>\n {children}\n </IconButton>\n {count !== undefined && <span className=\"text-xs font-semibold tabular-nums\">{count}</span>}\n </div>\n );\n}\n"
|
|
610
|
+
"content": "// Client (a video element, an observer, keys). A 9:16 clip filling its frame, with the action rail of a short-video feed on\n// the right (avatar + follow, like, comment, share, a slot for tip / unlock, more) and the caption block bottom-left. Every\n// word is a prop; counts are formatted with @krizaka/intl; everything over the picture reads on the invariant `scrim` /\n// `fg-on-media` tokens, so it is legible in both themes. Under reduced motion nothing autoplays: the poster and a play button.\nimport { formatCompact } from \"@krizaka/intl\";\nimport * as React from \"react\";\n\nimport { Avatar } from \"../avatar/avatar\";\nimport { IconButton } from \"../button/button\";\nimport { cn } from \"../cn\";\nimport { VerifiedBadge } from \"../verified-badge/verified-badge\";\n\nexport interface VerticalVideoLabels {\n /** \"Play\" / \"Pause\" — the tap target over the picture. */\n play: string;\n pause: string;\n /** \"Like\" / \"Unlike\" (the button's name; the state is `aria-pressed`). */\n like: string;\n comment: string;\n share: string;\n more: string;\n /** \"Follow Elena\" — `{name}` is replaced by the creator's name. */\n follow: string;\n /** \"Following Elena\". */\n following: string;\n /** \"More\" / \"Less\" on a long caption. */\n expand: string;\n collapse: string;\n /** The progress bar's name (\"Progress\"). */\n progress: string;\n /** The rail's name (\"Actions\"). */\n actions: string;\n}\n\nexport const verticalVideoLabels: VerticalVideoLabels = {\n play: \"Play\",\n pause: \"Pause\",\n like: \"Like\",\n comment: \"Comments\",\n share: \"Share\",\n more: \"More\",\n follow: \"Follow {name}\",\n following: \"Following {name}\",\n expand: \"More\",\n collapse: \"Less\",\n progress: \"Progress\",\n actions: \"Actions\",\n};\n\nexport interface VerticalVideoCreator {\n name: string;\n /** Their portrait. */\n avatarSrc?: string;\n /** The seal's words (\"Verified\"): shows the seal beside the name. */\n verifiedLabel?: string;\n}\n\nexport interface VerticalVideoProps extends Omit<React.ComponentProps<\"article\">, \"title\" | \"children\"> {\n /** The video's accessible name (\"Video: Night shoot, part 2\"), passed translated. */\n title: string;\n /** The clip — absent, the poster alone shows (MP4 or WebM; an HLS address only in Safari — use the player of `@krizaka/ui/video-player` for HLS). */\n src?: string;\n /** The picture until the first frame, and under reduced motion. */\n poster?: string;\n /** Plays while `true`; a `ShortFeed` passes it for the slide in view. Default `true`. */\n active?: boolean;\n /** The next slide's: `auto` loads it ahead, `none` waits. Default `metadata`. */\n preload?: \"none\" | \"metadata\" | \"auto\";\n /** Plays again at the end. Default `true`. */\n loop?: boolean;\n /** Starts muted (autoplay needs it). Default `true`. */\n muted?: boolean;\n /** Who made it: name, portrait, the seal's words. */\n creator: VerticalVideoCreator;\n /** A pill beside the name: \"AI-GENERATED\" — said on the picture, never only in a page's footnote. */\n disclosure?: string;\n /** The caption: collapsed to two lines, expandable when long. */\n caption?: string;\n /** Tags under the caption (without `#`). */\n tags?: readonly string[];\n /** The sound or credit line (\"Original sound — Elena\"). */\n credit?: string;\n /** The like count under its button, compact (`12.4K`) in `locale`. */\n likes?: number;\n /** The comment count under its button. */\n comments?: number;\n /** The share count under its button. */\n shares?: number;\n /** Whether the viewer likes it: `aria-pressed` on the like button. */\n liked?: boolean;\n /** Whether the viewer follows the creator: the \"+\" on the avatar becomes a check. */\n following?: boolean;\n /** The viewer likes or unlikes (the button, `L`, a double tap). */\n onLike?: (liked: boolean) => void;\n /** Opens the comments; without it the button still shows. */\n onComment?: () => void;\n /** Opens the share sheet. */\n onShare?: () => void;\n /** The viewer follows or unfollows; without it the avatar has no \"+\". */\n onFollow?: (following: boolean) => void;\n /** The \"more\" button's action (report, hide); without it the button is not drawn. */\n onMore?: () => void;\n /** The product's own action on the rail: a tip, an unlock — a button of yours (≥ 44 px, named). */\n tipSlot?: React.ReactNode;\n /** Top left: a chip over the picture (an auction's time left). */\n topStart?: React.ReactNode;\n /** Above the caption: a goal gauge, a challenge's progress. */\n aboveCaption?: React.ReactNode;\n /** The locale of the counts. Default `en`. */\n locale?: string;\n /** Every word, passed translated. */\n labels?: Partial<VerticalVideoLabels>;\n}\n\nconst glyph = (d: string) => (\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d={d} />\n </svg>\n);\nconst HEART = \"M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z\";\nconst COMMENT = \"M4 5h16v11H9l-5 4z\";\nconst SHARE = \"M4 12v7h16v-7M12 3v12M7 8l5-5 5 5\";\nconst MORE = \"M5 12h.01M12 12h.01M19 12h.01\";\n\nconst useReducedMotion = () =>\n React.useSyncExternalStore(\n (notify) => {\n const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n query.addEventListener(\"change\", notify);\n return () => query.removeEventListener(\"change\", notify);\n },\n () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n () => false,\n );\n\n/** One short video: 9:16, the rail of actions on the right, the creator's caption bottom-left, a progress hairline. */\nexport function VerticalVideo({\n title,\n src,\n poster,\n active = true,\n preload = \"metadata\",\n loop = true,\n muted = true,\n creator,\n disclosure,\n caption,\n tags = [],\n credit,\n likes,\n comments,\n shares,\n liked = false,\n following = false,\n onLike,\n onComment,\n onShare,\n onFollow,\n onMore,\n tipSlot,\n topStart,\n aboveCaption,\n locale = \"en\",\n labels: custom,\n className,\n onKeyDown,\n ...props\n}: VerticalVideoProps) {\n const labels = { ...verticalVideoLabels, ...custom };\n const reduce = useReducedMotion();\n const video = React.useRef<HTMLVideoElement>(null);\n const [playing, setPlaying] = React.useState(false);\n const [paused, setPaused] = React.useState(false); // the viewer's pause: it holds until they play again\n const [progress, setProgress] = React.useState(0);\n const [open, setOpen] = React.useState(false);\n const [heart, setHeart] = React.useState(0);\n\n const shouldPlay = active && !paused && !reduce;\n React.useEffect(() => {\n const el = video.current;\n if (!el) return;\n if (shouldPlay) void el.play().catch(() => undefined);\n else el.pause();\n }, [shouldPlay, src]);\n\n const press = () => {\n const el = video.current;\n if (!el) return;\n if (el.paused) {\n setPaused(false);\n void el.play().catch(() => undefined);\n } else {\n setPaused(true);\n el.pause();\n }\n };\n const like = () => onLike?.(!liked);\n const doubleTap = () => {\n if (!liked) like();\n setHeart((n) => n + 1); // a heart blooms over the picture (still under reduced motion: it shows and goes)\n };\n const count = (n?: number) => (n === undefined ? undefined : formatCompact(n, { locale }));\n const name = creator.name;\n\n return (\n <article\n aria-label={title}\n tabIndex={0}\n data-playing={playing ? \"\" : undefined}\n className={cn(\"relative isolate aspect-[9/16] w-full overflow-hidden bg-surface-0 text-fg-on-media focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented || event.target !== event.currentTarget) return;\n if (event.key === \" \" || event.key.toLowerCase() === \"k\") {\n event.preventDefault();\n press();\n } else if (event.key.toLowerCase() === \"l\") like();\n }}\n {...props}\n >\n <video\n ref={video}\n src={src}\n poster={poster}\n muted={muted}\n loop={loop}\n playsInline\n preload={preload}\n className=\"absolute inset-0 -z-10 h-full w-full object-cover\"\n onPlay={() => setPlaying(true)}\n onPause={() => setPlaying(false)}\n onTimeUpdate={(event) => {\n const el = event.currentTarget;\n setProgress(el.duration ? el.currentTime / el.duration : 0);\n }}\n />\n {/* The tap target: a tap pauses or plays, a double tap likes. It is a button, so the keyboard has it too. */}\n <button type=\"button\" aria-label={playing ? labels.pause : labels.play} onClick={press} onDoubleClick={doubleTap} className=\"absolute inset-0 cursor-pointer bg-transparent focus-visible:outline-hidden\">\n {!playing && (\n <span aria-hidden className=\"absolute left-1/2 top-1/2 grid h-16 w-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-scrim backdrop-blur-md\">\n <svg viewBox=\"0 0 24 24\" className=\"h-7 w-7\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\" /></svg>\n </span>\n )}\n </button>\n {heart > 0 && (\n <span key={heart} aria-hidden className=\"pointer-events-none absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center text-accent motion-safe:animate-ping\">\n <svg viewBox=\"0 0 24 24\" className=\"h-20 w-20\" fill=\"currentColor\"><path d={HEART} /></svg>\n </span>\n )}\n <div aria-hidden className=\"pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-linear-to-t from-scrim to-transparent\" />\n {topStart && <div className=\"absolute left-3 top-3\">{topStart}</div>}\n\n <div role=\"group\" aria-label={labels.actions} className=\"absolute bottom-16 right-2 flex flex-col items-center gap-3\">\n <div className=\"relative\">\n <Avatar src={creator.avatarSrc} alt={name} fallback={name.slice(0, 1)} size=\"md\" className=\"ring-2 ring-white\" />\n {onFollow && (\n <IconButton\n label={format(following ? labels.following : labels.follow, name)}\n aria-pressed={following}\n size=\"icon\"\n variant={following ? \"secondary\" : \"primary\"}\n shape=\"pill\"\n onClick={() => onFollow(!following)}\n className=\"absolute -bottom-3 left-1/2 h-6 min-h-6 w-6 min-w-6 -translate-x-1/2 p-0\"\n >\n {glyph(following ? \"M5 12l5 5 9-10\" : \"M12 5v14M5 12h14\")}\n </IconButton>\n )}\n </div>\n <Rail label={labels.like} count={count(likes)} pressed={liked} onClick={like} tone={liked ? \"text-accent\" : undefined}>\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill={liked ? \"currentColor\" : \"none\"} stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden><path d={HEART} /></svg>\n </Rail>\n <Rail label={labels.comment} count={count(comments)} onClick={onComment}>{glyph(COMMENT)}</Rail>\n <Rail label={labels.share} count={count(shares)} onClick={onShare}>{glyph(SHARE)}</Rail>\n {tipSlot}\n {onMore && <Rail label={labels.more} onClick={onMore}>{glyph(MORE)}</Rail>}\n </div>\n\n <div className=\"absolute bottom-4 left-3 right-20 flex flex-col gap-1.5\">\n {aboveCaption}\n <p className=\"flex items-center gap-1.5 text-sm font-bold\">\n {name}\n {creator.verifiedLabel && <VerifiedBadge label={creator.verifiedLabel} onMedia size=\"sm\" />}\n {disclosure && <span className=\"whitespace-nowrap rounded-full bg-scrim px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-fg-on-media backdrop-blur-md\">{disclosure}</span>}\n </p>\n {caption && (\n <p className={cn(\"text-sm leading-snug\", !open && \"line-clamp-2\")}>\n {caption}\n {tags.length > 0 && <span className=\"font-semibold\"> {tags.map((tag) => `#${tag}`).join(\" \")}</span>}\n </p>\n )}\n {caption && caption.length > 90 && (\n <button type=\"button\" aria-expanded={open} onClick={() => setOpen((v) => !v)} className=\"w-fit rounded text-xs font-semibold underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\">\n {open ? labels.collapse : labels.expand}\n </button>\n )}\n {credit && <p className=\"text-xs opacity-90\">{credit}</p>}\n </div>\n\n <div role=\"progressbar\" aria-label={labels.progress} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)} className=\"absolute inset-x-0 bottom-0 h-1 bg-scrim\">\n <div className=\"h-full bg-white\" style={{ width: `${progress * 100}%` }} />\n </div>\n </article>\n );\n}\n\n/** \"Follow {name}\" → \"Follow Elena\". */\nconst format = (message: string, name: string) => message.replace(\"{name}\", name);\n\nfunction Rail({ label, count, pressed, onClick, tone, children }: { label: string; count?: string | undefined; pressed?: boolean; onClick?: (() => void) | undefined; tone?: string | undefined; children: React.ReactNode }) {\n return (\n <div className=\"flex flex-col items-center gap-0.5\">\n <IconButton label={label} aria-pressed={pressed} variant=\"ghost\" shape=\"pill\" onClick={onClick} className={cn(\"h-11 min-h-11 w-11 min-w-11 bg-scrim text-fg-on-media backdrop-blur-md hover:bg-scrim\", tone)}>\n {children}\n </IconButton>\n {count !== undefined && <span className=\"text-xs font-semibold tabular-nums\">{count}</span>}\n </div>\n );\n}\n"
|
|
611
611
|
}
|
|
612
612
|
],
|
|
613
613
|
"dependencies": [
|