@krizaka/sovkora-design-system 0.0.0-stage → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/LICENSE +202 -0
  3. package/README.md +82 -2
  4. package/package.json +112 -3
  5. package/registry/badge.json +97 -0
  6. package/registry/button.json +140 -0
  7. package/registry/chat-showcase.json +113 -0
  8. package/registry/clip-player.json +120 -0
  9. package/registry/command-palette.json +116 -0
  10. package/registry/examples/badge/model-state.tsx +16 -0
  11. package/registry/examples/button/send.tsx +18 -0
  12. package/registry/examples/chat-showcase/french.tsx +20 -0
  13. package/registry/examples/chat-showcase/sovereign-answer.tsx +20 -0
  14. package/registry/examples/clip-player/tea-pour.tsx +27 -0
  15. package/registry/examples/command-palette/workspace.tsx +30 -0
  16. package/registry/examples/icon/engine.tsx +26 -0
  17. package/registry/examples/icon/in-a-button.tsx +12 -0
  18. package/registry/examples/run-facts/clip-run.tsx +15 -0
  19. package/registry/examples/sentinel-mini/sidebar.tsx +11 -0
  20. package/registry/examples/skeleton/answer-loading.tsx +11 -0
  21. package/registry/examples/video-result/assistant.tsx +36 -0
  22. package/registry/examples/video-result/tea-pour.tsx +40 -0
  23. package/registry/foundations.json +2067 -0
  24. package/registry/icon.json +109 -0
  25. package/registry/index.json +242 -0
  26. package/registry/run-facts.json +75 -0
  27. package/registry/sentinel-mini.json +81 -0
  28. package/registry/skeleton.json +116 -0
  29. package/registry/video-result.json +167 -0
  30. package/src/components/Badge.tsx +36 -0
  31. package/src/components/Button.tsx +26 -0
  32. package/src/components/ChatShowcase.tsx +248 -0
  33. package/src/components/ClipPlayer.tsx +223 -0
  34. package/src/components/CommandPalette.tsx +104 -0
  35. package/src/components/CommandPalette.types.ts +33 -0
  36. package/src/components/Input.tsx +14 -0
  37. package/src/components/RunFacts.tsx +36 -0
  38. package/src/components/SentinelMini.tsx +61 -0
  39. package/src/components/Skeleton.tsx +50 -0
  40. package/src/components/VideoResult.tsx +123 -0
  41. package/src/components/badge.meta.ts +34 -0
  42. package/src/components/button.meta.ts +35 -0
  43. package/src/components/chat-showcase.css.ts +19 -0
  44. package/src/components/chat-showcase.meta.ts +50 -0
  45. package/src/components/clip-player.meta.ts +48 -0
  46. package/src/components/command-palette.meta.ts +47 -0
  47. package/src/components/icon.meta.ts +45 -0
  48. package/src/components/run-facts.meta.ts +32 -0
  49. package/src/components/sentinel-mini.meta.ts +39 -0
  50. package/src/components/skeleton.meta.ts +42 -0
  51. package/src/components/video-result.meta.ts +53 -0
  52. package/src/icon.registry.tsx +737 -0
  53. package/src/icon.tsx +74 -0
  54. package/src/index.ts +43 -0
  55. package/src/meta.ts +76 -0
  56. package/src/theme.css +859 -0
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ /**
4
+ * @file ClipPlayer.tsx
5
+ * @description A short clip that is plainly a clip: a muted loop that plays in view, a badge, play/pause and sound
6
+ * buttons, and a click that opens it larger. Words are props; nothing is fetched.
7
+ */
8
+ import { MuteIcon, PauseIcon, PlayIcon, SoundIcon, VideoIcon } from "@krizaka/icons";
9
+ import { Button } from "@krizaka/ui/button";
10
+ import { cn } from "@krizaka/ui/cn";
11
+ import { Dialog } from "@krizaka/ui/dialog";
12
+ import * as React from "react";
13
+
14
+ /** The files of a clip: H.264 `mp4` (the fallback), `webm` (tried first) and the poster. */
15
+ export interface ClipSources {
16
+ mp4: string;
17
+ /** The VP9 file, tried first by the browsers that read it. */
18
+ webm?: string;
19
+ poster: string;
20
+ }
21
+
22
+ /** Every word of the player, translated by the app. */
23
+ export interface ClipPlayerLabels {
24
+ /** The badge that says it is a video, with its length: `Video · 5 s`. */
25
+ badge: string;
26
+ play: string;
27
+ pause: string;
28
+ mute: string;
29
+ /** The sound button while the clip is muted. */
30
+ unmute: string;
31
+ /** The name of the button that opens the clip larger (the whole frame is that button). */
32
+ enlarge: string;
33
+ /** The close button of the large view. */
34
+ close: string;
35
+ }
36
+
37
+ /** Props of {@link ClipPlayer}. */
38
+ export interface ClipPlayerProps {
39
+ /** The files. */
40
+ sources: ClipSources;
41
+ /** The accessible name of the video: what it shows, in a sentence. */
42
+ title: string;
43
+ /** The words. */
44
+ labels: ClipPlayerLabels;
45
+ /** The shape of the frame: a 16:9 one by default, a portrait one as tall as a phone allows. */
46
+ orientation?: "landscape" | "portrait";
47
+ /** Extra classes on the video, merged last. */
48
+ className?: string;
49
+ }
50
+
51
+ const REDUCED = "(prefers-reduced-motion: reduce)";
52
+ const readReducedMotion = () => (typeof window.matchMedia === "function" ? window.matchMedia(REDUCED).matches : false);
53
+ function subscribeReducedMotion(notify: () => void) {
54
+ const query = typeof window.matchMedia === "function" ? window.matchMedia(REDUCED) : null;
55
+ query?.addEventListener?.("change", notify);
56
+ return () => query?.removeEventListener?.("change", notify);
57
+ }
58
+
59
+ const control = "pointer-events-auto bg-surface-0/80 backdrop-blur";
60
+
61
+ /**
62
+ * A clip that is plainly a clip: muted loop that plays only while on screen (and pauses off screen and in a hidden
63
+ * tab), a badge, play/pause and sound buttons on hover and focus (always shown where the pointer cannot hover), and a
64
+ * click anywhere on the frame opens it larger in a dialog with the native controls. Under `prefers-reduced-motion` it
65
+ * never plays by itself: the poster and a play button. Plain files, `playsInline`, `preload="none"`.
66
+ *
67
+ * @param props - {@link ClipPlayerProps}
68
+ * @returns The player of a short clip.
69
+ */
70
+ export function ClipPlayer({
71
+ sources,
72
+ title,
73
+ labels,
74
+ orientation = "landscape",
75
+ className,
76
+ }: Readonly<ClipPlayerProps>) {
77
+ const ref = React.useRef<HTMLVideoElement>(null);
78
+ const userPaused = React.useRef(false);
79
+ const [playing, setPlaying] = React.useState(false);
80
+ const [muted, setMuted] = React.useState(true);
81
+ const [open, setOpen] = React.useState(false);
82
+ // On the server nothing plays (the preference is unknown); in the browser it is the visitor's.
83
+ const reduced = React.useSyncExternalStore(subscribeReducedMotion, readReducedMotion, () => true);
84
+
85
+ React.useEffect(() => {
86
+ const video = ref.current;
87
+ if (!video || reduced || typeof IntersectionObserver === "undefined") return;
88
+ let visible = false;
89
+ const sync = () => {
90
+ if (visible && !document.hidden && !userPaused.current && !open) void video.play?.()?.catch?.(() => undefined);
91
+ else video.pause?.();
92
+ };
93
+ const observer = new IntersectionObserver(
94
+ (entries) => {
95
+ visible = entries.some((entry) => entry.isIntersecting);
96
+ sync();
97
+ },
98
+ { threshold: 0.35 },
99
+ );
100
+ observer.observe(video);
101
+ document.addEventListener("visibilitychange", sync);
102
+ return () => {
103
+ observer.disconnect();
104
+ document.removeEventListener("visibilitychange", sync);
105
+ video.pause?.();
106
+ };
107
+ }, [reduced, open]);
108
+
109
+ const toggle = () => {
110
+ const video = ref.current;
111
+ if (!video) return;
112
+ if (video.paused) {
113
+ userPaused.current = false;
114
+ void video.play?.()?.catch?.(() => undefined);
115
+ } else {
116
+ userPaused.current = true;
117
+ video.pause?.();
118
+ }
119
+ };
120
+ const toggleSound = () => {
121
+ const video = ref.current;
122
+ if (!video) return;
123
+ video.muted = !video.muted;
124
+ setMuted(video.muted);
125
+ };
126
+ const frame = orientation === "portrait" ? "aspect-[9/16] max-h-[70vh] w-auto" : "aspect-[26/15] w-full";
127
+
128
+ return (
129
+ <>
130
+ <div className="group relative overflow-hidden" data-clip-player>
131
+ <video
132
+ ref={ref}
133
+ aria-label={title}
134
+ poster={sources.poster}
135
+ muted
136
+ loop
137
+ playsInline
138
+ preload="none"
139
+ onPlay={() => setPlaying(true)}
140
+ onPause={() => setPlaying(false)}
141
+ data-reduced-motion={reduced ? "true" : undefined}
142
+ className={cn("mx-auto block object-cover", frame, className)}
143
+ >
144
+ {sources.webm && <source src={sources.webm} type="video/webm" />}
145
+ <source src={sources.mp4} type="video/mp4" />
146
+ </video>
147
+ <button
148
+ type="button"
149
+ onClick={() => setOpen(true)}
150
+ aria-label={labels.enlarge}
151
+ className="absolute inset-0 cursor-zoom-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
152
+ />
153
+ <span
154
+ className={cn(
155
+ control,
156
+ "pointer-events-none absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full border border-border-default px-2.5 py-1 font-mono text-xs text-fg",
157
+ )}
158
+ data-clip-badge
159
+ >
160
+ <VideoIcon size={14} />
161
+ {labels.badge}
162
+ </span>
163
+ {reduced && !playing && (
164
+ <span className="pointer-events-none absolute inset-0 flex items-center justify-center">
165
+ <Button
166
+ type="button"
167
+ variant="primary"
168
+ size="lg"
169
+ shape="pill"
170
+ className="pointer-events-auto"
171
+ onClick={toggle}
172
+ aria-label={labels.play}
173
+ >
174
+ <PlayIcon size={20} />
175
+ </Button>
176
+ </span>
177
+ )}
178
+ <div className="pointer-events-none absolute bottom-3 right-3 flex gap-2 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100">
179
+ <Button
180
+ type="button"
181
+ variant="secondary"
182
+ size="sm"
183
+ className={control}
184
+ onClick={toggle}
185
+ aria-label={playing ? labels.pause : labels.play}
186
+ >
187
+ {playing ? <PauseIcon size={16} /> : <PlayIcon size={16} />}
188
+ </Button>
189
+ <Button
190
+ type="button"
191
+ variant="secondary"
192
+ size="sm"
193
+ className={control}
194
+ onClick={toggleSound}
195
+ aria-label={muted ? labels.unmute : labels.mute}
196
+ aria-pressed={!muted}
197
+ >
198
+ {muted ? <MuteIcon size={16} /> : <SoundIcon size={16} />}
199
+ </Button>
200
+ </div>
201
+ </div>
202
+ <Dialog.Root open={open} onOpenChange={setOpen}>
203
+ <Dialog.Content closeLabel={labels.close} className="max-w-5xl">
204
+ <Dialog.Header>
205
+ <Dialog.Title>{title}</Dialog.Title>
206
+ </Dialog.Header>
207
+ <video
208
+ aria-label={title}
209
+ poster={sources.poster}
210
+ controls
211
+ autoPlay
212
+ loop
213
+ playsInline
214
+ className="mx-auto block max-h-[75vh] w-full rounded-lg bg-surface-2"
215
+ >
216
+ {sources.webm && <source src={sources.webm} type="video/webm" />}
217
+ <source src={sources.mp4} type="video/mp4" />
218
+ </video>
219
+ </Dialog.Content>
220
+ </Dialog.Root>
221
+ </>
222
+ );
223
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { Command, CommandDialog } from "@krizaka/ui/command";
4
+ import { Kbd } from "@krizaka/ui/kbd";
5
+ import { useRouter } from "next/navigation";
6
+ import * as React from "react";
7
+
8
+ import { Icon } from "../icon";
9
+ import type { CommandPaletteItem, CommandPaletteLabels } from "./CommandPalette.types";
10
+
11
+ export type { CommandPaletteItem, CommandPaletteLabels } from "./CommandPalette.types";
12
+
13
+ /** Props of {@link CommandPalette}. */
14
+ export interface CommandPaletteProps {
15
+ /** The entries, grouped by `section` in their order. */
16
+ commands: CommandPaletteItem[];
17
+ /** The words, translated. */
18
+ labels: CommandPaletteLabels;
19
+ /** Where an entry's `href` goes. Default: the Next.js router (`router.push`). */
20
+ onNavigate?: (href: string) => void;
21
+ /** The letter that opens it with ⌘ / Ctrl. Default `k`. */
22
+ shortcut?: string;
23
+ }
24
+
25
+ /** The entries grouped by section, in the order they first appear. */
26
+ function bySection(commands: CommandPaletteItem[]) {
27
+ const groups = new Map<string, CommandPaletteItem[]>();
28
+ for (const command of commands) groups.set(command.section, [...(groups.get(command.section) ?? []), command]);
29
+ return [...groups];
30
+ }
31
+
32
+ /**
33
+ * The Sovkora ⌘K palette: a product composite on `CommandDialog` from `@krizaka/ui/command` (cmdk + the platform's
34
+ * dialog: the combobox and listbox roles, the arrows, Enter, the filtering, the focus trap, Escape). It adds what is
35
+ * Sovkora's: the shortcut, the registry icons, the sections and the footer of hints. Words and entries arrive as
36
+ * props (since 3.0 they are required: the 1.x English defaults are gone).
37
+ *
38
+ * @param props - {@link CommandPaletteProps}
39
+ * @returns The palette, closed until ⌘K / Ctrl+K.
40
+ */
41
+ export function CommandPalette({ commands, labels, onNavigate, shortcut = "k" }: Readonly<CommandPaletteProps>) {
42
+ const router = useRouter();
43
+ const [open, setOpen] = React.useState(false);
44
+
45
+ React.useEffect(() => {
46
+ const onKeyDown = (event: KeyboardEvent) => {
47
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === shortcut) {
48
+ event.preventDefault();
49
+ setOpen((previous) => !previous);
50
+ }
51
+ };
52
+ window.addEventListener("keydown", onKeyDown);
53
+ return () => window.removeEventListener("keydown", onKeyDown);
54
+ }, [shortcut]);
55
+
56
+ const run = (command: CommandPaletteItem) => {
57
+ setOpen(false);
58
+ if (command.href) (onNavigate ?? router.push)(command.href);
59
+ command.onSelect?.();
60
+ };
61
+
62
+ return (
63
+ <CommandDialog
64
+ open={open}
65
+ onOpenChange={setOpen}
66
+ label={labels.label}
67
+ footer={
68
+ <footer className="flex items-center gap-3 border-t border-border-subtle bg-surface-2 px-4 py-2 text-xs text-fg-secondary">
69
+ <span className="flex items-center gap-1">
70
+ <Kbd>↑↓</Kbd>
71
+ {labels.navigate}
72
+ </span>
73
+ <span className="flex items-center gap-1">
74
+ <Kbd>↵</Kbd>
75
+ {labels.open}
76
+ </span>
77
+ <span className="flex items-center gap-1">
78
+ <Kbd>esc</Kbd>
79
+ {labels.close}
80
+ </span>
81
+ </footer>
82
+ }
83
+ >
84
+ <Command.Input placeholder={labels.placeholder} />
85
+ <Command.List label={labels.results} emptyLabel={labels.empty}>
86
+ {bySection(commands).map(([section, items]) => (
87
+ <Command.Group key={section} heading={section}>
88
+ {items.map((command) => (
89
+ <Command.Item
90
+ key={command.id}
91
+ value={`${command.label} ${command.id}`}
92
+ keywords={command.keywords}
93
+ onSelect={() => run(command)}
94
+ >
95
+ <Icon name={command.icon} size={16} className="text-fg-secondary" />
96
+ <span className="flex-1">{command.label}</span>
97
+ </Command.Item>
98
+ ))}
99
+ </Command.Group>
100
+ ))}
101
+ </Command.List>
102
+ </CommandDialog>
103
+ );
104
+ }
@@ -0,0 +1,33 @@
1
+ import type { IconName } from "../icon";
2
+
3
+ /** One entry of the palette. */
4
+ export interface CommandPaletteItem {
5
+ /** Stable id (also the cmdk value, with the label). */
6
+ id: string;
7
+ /** What the person reads — passed translated. */
8
+ label: string;
9
+ icon: IconName;
10
+ /** Where it goes: handed to `onNavigate`. */
11
+ href?: string;
12
+ /** What it does, instead of (or after) navigating. */
13
+ onSelect?: () => void;
14
+ /** The group heading it sits under — passed translated. */
15
+ section: string;
16
+ /** More words the filter matches. */
17
+ keywords?: string[];
18
+ }
19
+
20
+ /** The words of the palette — passed translated. */
21
+ export interface CommandPaletteLabels {
22
+ /** The accessible name of the palette (the dialog and the combobox). */
23
+ label: string;
24
+ placeholder: string;
25
+ /** The accessible name of the list of results. */
26
+ results: string;
27
+ /** Shown when nothing matches. */
28
+ empty: string;
29
+ /** Footer hints. */
30
+ navigate: string;
31
+ open: string;
32
+ close: string;
33
+ }
@@ -0,0 +1,14 @@
1
+ import type * as React from "react";
2
+
3
+ /**
4
+ * @file Input.tsx
5
+ * @description The text field is the @krizaka/ui primitive (`@krizaka/ui/field`), re-exported with its siblings.
6
+ * `invalid` sets aria-invalid; pair it with a `Field.Error`. React 19: `ref` is a prop.
7
+ */
8
+ export { Field, Input, Select, Textarea } from "@krizaka/ui/field";
9
+
10
+ /** Props of `Input`: the native input attributes, plus `invalid`. */
11
+ export type InputProps = React.ComponentProps<"input"> & {
12
+ /** Marks the control invalid (aria-invalid, data-invalid). */
13
+ invalid?: boolean;
14
+ };
@@ -0,0 +1,36 @@
1
+ import { cn } from "@krizaka/ui/cn";
2
+
3
+ /** One fact of a run: `Seed` → `20261011`. */
4
+ export interface RunFact {
5
+ /** What it is, translated by the app (`Seed`). */
6
+ label: string;
7
+ /** Its value, as the run recorded it (`20261011`). */
8
+ value: string;
9
+ }
10
+
11
+ /** Props of {@link RunFacts}. */
12
+ export interface RunFactsProps {
13
+ /** The facts, in reading order. */
14
+ facts: readonly RunFact[];
15
+ /** Extra classes on the list, merged last. */
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * What a run recorded, as a description list in mono: label left, value right (a long id breaks instead of overflowing).
21
+ *
22
+ * @param props - {@link RunFactsProps}
23
+ * @returns A description list.
24
+ */
25
+ export function RunFacts({ facts, className }: Readonly<RunFactsProps>) {
26
+ return (
27
+ <dl className={cn("grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 font-mono text-xs", className)}>
28
+ {facts.map((fact) => (
29
+ <div key={fact.label} className="contents">
30
+ <dt className="text-fg-muted">{fact.label}</dt>
31
+ <dd className="text-fg [overflow-wrap:anywhere]">{fact.value}</dd>
32
+ </div>
33
+ ))}
34
+ </dl>
35
+ );
36
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @file SentinelMini.tsx
3
+ * @description Miniaturized Sentinel nucleus for sidebar/header branding.
4
+ * Lives in components/ui to avoid cross-boundary imports between
5
+ * layout components and feature modules.
6
+ */
7
+
8
+ interface SentinelMiniProps {
9
+ /** SVG size in pixels. Defaults to 24. */
10
+ size?: number;
11
+ /** Additional CSS classes. */
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Compact 24×24 Sentinel mascot with simplified core + single orbital ring.
17
+ *
18
+ * @param props - Sentinel mini configuration.
19
+ * @returns A compact SVG brand mark element.
20
+ */
21
+ export function SentinelMini({ size = 24, className = "" }: Readonly<SentinelMiniProps>) {
22
+ return (
23
+ <svg
24
+ width={size}
25
+ height={size}
26
+ viewBox="0 0 24 24"
27
+ fill="none"
28
+ xmlns="http://www.w3.org/2000/svg"
29
+ className={className}
30
+ aria-hidden="true"
31
+ >
32
+ {/* Outer orbital */}
33
+ <circle
34
+ cx="12"
35
+ cy="12"
36
+ r="10"
37
+ stroke="var(--kz-accent)"
38
+ strokeWidth="0.5"
39
+ strokeDasharray="3 5 2 4"
40
+ opacity="0.4"
41
+ />
42
+ {/* Hexagonal frame */}
43
+ <polygon
44
+ points="12,4 18.9,8 18.9,16 12,20 5.1,16 5.1,8"
45
+ stroke="var(--kz-accent)"
46
+ strokeWidth="0.6"
47
+ opacity="0.25"
48
+ fill="none"
49
+ />
50
+ {/* Core ring */}
51
+ <circle cx="12" cy="12" r="4" stroke="var(--kz-accent)" strokeWidth="1" opacity="0.6" fill="none" />
52
+ {/* Inner core */}
53
+ <circle cx="12" cy="12" r="2" fill="var(--kz-accent)" opacity="0.8" />
54
+ {/* Core highlight */}
55
+ <circle cx="12" cy="11" r="0.8" fill="white" opacity="0.3" />
56
+ {/* Energy nodes */}
57
+ <circle cx="22" cy="12" r="0.8" fill="var(--kz-accent)" opacity="0.5" />
58
+ <circle cx="12" cy="2" r="0.8" fill="var(--kz-accent)" opacity="0.5" />
59
+ </svg>
60
+ );
61
+ }
@@ -0,0 +1,50 @@
1
+ import { cn } from "@krizaka/ui/cn";
2
+ import { Skeleton as KzSkeleton, type SkeletonProps as KzSkeletonProps, skeletonVariants } from "@krizaka/ui/skeleton";
3
+
4
+ export { skeletonVariants };
5
+
6
+ /** Props of {@link Skeleton}: the @krizaka/ui skeleton, plus the 1.x shorthands. */
7
+ export type SkeletonProps = KzSkeletonProps & {
8
+ /** @deprecated Since 2.0 — use `shape` (same names). */
9
+ variant?: NonNullable<KzSkeletonProps["shape"]>;
10
+ /** @deprecated Since 2.0 — size it with a `className` (`w-32`) or `style`. */
11
+ width?: string;
12
+ /** @deprecated Since 2.0 — size it with a `className` (`h-4`) or `style`. */
13
+ height?: string;
14
+ };
15
+
16
+ /**
17
+ * The @krizaka/ui skeleton (`shape` text · circle · rect, pulsing, hidden from assistive technology). The 1.x
18
+ * `variant`, `width` and `height` are kept as deprecated shorthands; the default shape stays `rect`.
19
+ *
20
+ * @param props - {@link SkeletonProps}
21
+ * @returns A loading placeholder.
22
+ */
23
+ export function Skeleton({ variant, shape, width, height, style, ...props }: SkeletonProps) {
24
+ const sized = width || height ? { width, height, ...style } : style;
25
+ return <KzSkeleton shape={shape ?? variant ?? "rect"} style={sized} {...props} />;
26
+ }
27
+
28
+ /** Props of {@link SkeletonGroup}. */
29
+ export interface SkeletonGroupProps {
30
+ /** The number of text lines; the last one is shorter. Default 3. */
31
+ lines?: number;
32
+ /** Extra classes on the group (its width), merged last. */
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * A paragraph of text skeletons; the last line is shorter.
38
+ *
39
+ * @param props - `lines` (default 3) and `className`.
40
+ * @returns A group of text skeletons.
41
+ */
42
+ export function SkeletonGroup({ lines = 3, className }: Readonly<SkeletonGroupProps>) {
43
+ return (
44
+ <div className={cn("space-y-2.5", className)} aria-hidden="true">
45
+ {Array.from({ length: lines }, (_, i) => (
46
+ <Skeleton key={i} shape="text" className={i === lines - 1 ? "w-3/5" : undefined} />
47
+ ))}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ /**
4
+ * @file VideoResult.tsx
5
+ * @description What a video run of an Sovkora Studio leaves: the clip in its player, the exact prompt, what the run
6
+ * recorded (model, seed, frame, length, time, cost, run id), the command that makes it again and a download. Words are
7
+ * props; nothing is fetched.
8
+ */
9
+ import { Button } from "@krizaka/ui/button";
10
+ import { cn } from "@krizaka/ui/cn";
11
+ import { CodeBlock } from "@krizaka/ui/code-block";
12
+ import type * as React from "react";
13
+
14
+ import { ClipPlayer, type ClipPlayerLabels, type ClipSources } from "./ClipPlayer";
15
+ import { type RunFact,RunFacts } from "./RunFacts";
16
+
17
+ /** One fact of the run (see {@link RunFact}). */
18
+ export type VideoResultFact = RunFact;
19
+
20
+ /** Every word of the component, translated by the app. */
21
+ export interface VideoResultLabels extends ClipPlayerLabels {
22
+ /** The heading of the prompt. */
23
+ prompt: string;
24
+ /** The heading of the run's facts. */
25
+ facts: string;
26
+ /** The heading of the command. */
27
+ reproduce: string;
28
+ /** The copy button of the command. */
29
+ copy: string;
30
+ /** What the copy button says once the command is copied. */
31
+ copied: string;
32
+ /** The download button. */
33
+ download: string;
34
+ }
35
+
36
+ /** Props of {@link VideoResult}. */
37
+ export interface VideoResultProps {
38
+ /** The accessible name of the video: what it shows, in a sentence. */
39
+ title: string;
40
+ /** The files: H.264 `mp4` (always), `webm` when there is one, and the poster. */
41
+ sources: ClipSources;
42
+ /** The frame the clip was made in; a portrait one is drawn as tall as a phone allows, a landscape one 16:9. */
43
+ orientation?: "landscape" | "portrait";
44
+ /** The exact prompt that reached the model. */
45
+ prompt: string;
46
+ /** Replaces the plain prompt (the app may type it in, over its final size): the heading stays. */
47
+ promptNode?: React.ReactNode;
48
+ /** What the run recorded, in reading order (model, seed, frame, length, time, cost, run id). */
49
+ facts: readonly VideoResultFact[];
50
+ /** The CLI command that makes the clip again; without it there is no "Reproduce it". */
51
+ reproduce?: string;
52
+ /** One sentence above the command. */
53
+ reproduceLead?: string;
54
+ /** Where the file can be downloaded; without it there is no download button. */
55
+ downloadHref?: string;
56
+ /** The words. */
57
+ labels: VideoResultLabels;
58
+ /** Extra classes on the root, merged last. */
59
+ className?: string;
60
+ }
61
+
62
+ const heading = "font-mono text-xs font-semibold uppercase tracking-[0.14em] text-fg-accent";
63
+
64
+ /**
65
+ * The result of a video run: the player (a muted loop in view, badge, controls, opens larger), the prompt, the facts,
66
+ * "Reproduce it" and the download.
67
+ *
68
+ * @param props - {@link VideoResultProps}
69
+ * @returns The result card of a video run.
70
+ */
71
+ export function VideoResult({
72
+ title,
73
+ sources,
74
+ orientation = "landscape",
75
+ prompt,
76
+ promptNode,
77
+ facts,
78
+ reproduce,
79
+ reproduceLead,
80
+ downloadHref,
81
+ labels,
82
+ className,
83
+ }: Readonly<VideoResultProps>) {
84
+ return (
85
+ <article className={cn("space-y-5", className)} data-orientation={orientation}>
86
+ <figure className="overflow-hidden rounded-2xl border border-border-default bg-surface-2">
87
+ <ClipPlayer sources={sources} title={title} labels={labels} orientation={orientation} />
88
+ </figure>
89
+ <section aria-label={labels.prompt} className="space-y-2">
90
+ <h3 className={heading}>{labels.prompt}</h3>
91
+ {promptNode ?? (
92
+ <blockquote lang="en" className="text-sm leading-relaxed text-fg-secondary">
93
+ {prompt}
94
+ </blockquote>
95
+ )}
96
+ </section>
97
+ <section aria-label={labels.facts} className="space-y-2">
98
+ <h3 className={heading}>{labels.facts}</h3>
99
+ <RunFacts facts={facts} />
100
+ </section>
101
+ {reproduce && (
102
+ <section aria-label={labels.reproduce} className="space-y-2">
103
+ <h3 className={heading}>{labels.reproduce}</h3>
104
+ {reproduceLead && <p className="text-xs leading-relaxed text-fg-secondary">{reproduceLead}</p>}
105
+ <CodeBlock
106
+ code={reproduce}
107
+ language="bash"
108
+ file="Terminal"
109
+ copyLabel={labels.copy}
110
+ copiedLabel={labels.copied}
111
+ />
112
+ </section>
113
+ )}
114
+ {downloadHref && (
115
+ <Button asChild variant="secondary" size="md">
116
+ <a href={downloadHref} download>
117
+ {labels.download}
118
+ </a>
119
+ </Button>
120
+ )}
121
+ </article>
122
+ );
123
+ }
@@ -0,0 +1,34 @@
1
+ import type { ComponentMeta } from "../meta";
2
+
3
+ export const meta = {
4
+ title: "Badge",
5
+ summary: "The @krizaka/ui badge with the 1.x `variant` kept as a deprecated alias of `tone`.",
6
+ why: "Sovkora 1.x badges took `variant` (`default`, `success`…); the platform says `tone`. This adapter maps one onto the other so the apps keep working while they migrate.",
7
+ status: "stable",
8
+ category: "data-display",
9
+ platforms: "web",
10
+ builtOn: ["ui/badge"],
11
+ whenToUse: [
12
+ "In code already importing `Badge` from `@krizaka/sovkora-design-system`: a model's state, a job's status.",
13
+ ],
14
+ whenNotToUse: [{ when: "In new code: import `Badge` from `@krizaka/ui/badge` and write `tone`.", use: "ui/badge" }],
15
+ bestPractices: [
16
+ "Write `tone`, not `variant` (deprecated since 2.0; `default` → `neutral`).",
17
+ "One or two words: `Local`, `Running`, `Failed`.",
18
+ ],
19
+ accessibility: {
20
+ keyboard: [],
21
+ notes: ["The primitive's: a `<span>` read with its content; the colour is never the only signal."],
22
+ },
23
+ related: ["ui/badge"],
24
+ web: {
25
+ imports: ["Badge"],
26
+ examples: [
27
+ {
28
+ name: "model-state",
29
+ title: "A model's state",
30
+ description: "Local, loading, failed — the tones of the primitive.",
31
+ },
32
+ ],
33
+ },
34
+ } satisfies ComponentMeta;