@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.
- package/CHANGELOG.md +234 -0
- package/LICENSE +202 -0
- package/README.md +82 -2
- package/package.json +112 -3
- package/registry/badge.json +97 -0
- package/registry/button.json +140 -0
- package/registry/chat-showcase.json +113 -0
- package/registry/clip-player.json +120 -0
- package/registry/command-palette.json +116 -0
- package/registry/examples/badge/model-state.tsx +16 -0
- package/registry/examples/button/send.tsx +18 -0
- package/registry/examples/chat-showcase/french.tsx +20 -0
- package/registry/examples/chat-showcase/sovereign-answer.tsx +20 -0
- package/registry/examples/clip-player/tea-pour.tsx +27 -0
- package/registry/examples/command-palette/workspace.tsx +30 -0
- package/registry/examples/icon/engine.tsx +26 -0
- package/registry/examples/icon/in-a-button.tsx +12 -0
- package/registry/examples/run-facts/clip-run.tsx +15 -0
- package/registry/examples/sentinel-mini/sidebar.tsx +11 -0
- package/registry/examples/skeleton/answer-loading.tsx +11 -0
- package/registry/examples/video-result/assistant.tsx +36 -0
- package/registry/examples/video-result/tea-pour.tsx +40 -0
- package/registry/foundations.json +2067 -0
- package/registry/icon.json +109 -0
- package/registry/index.json +242 -0
- package/registry/run-facts.json +75 -0
- package/registry/sentinel-mini.json +81 -0
- package/registry/skeleton.json +116 -0
- package/registry/video-result.json +167 -0
- package/src/components/Badge.tsx +36 -0
- package/src/components/Button.tsx +26 -0
- package/src/components/ChatShowcase.tsx +248 -0
- package/src/components/ClipPlayer.tsx +223 -0
- package/src/components/CommandPalette.tsx +104 -0
- package/src/components/CommandPalette.types.ts +33 -0
- package/src/components/Input.tsx +14 -0
- package/src/components/RunFacts.tsx +36 -0
- package/src/components/SentinelMini.tsx +61 -0
- package/src/components/Skeleton.tsx +50 -0
- package/src/components/VideoResult.tsx +123 -0
- package/src/components/badge.meta.ts +34 -0
- package/src/components/button.meta.ts +35 -0
- package/src/components/chat-showcase.css.ts +19 -0
- package/src/components/chat-showcase.meta.ts +50 -0
- package/src/components/clip-player.meta.ts +48 -0
- package/src/components/command-palette.meta.ts +47 -0
- package/src/components/icon.meta.ts +45 -0
- package/src/components/run-facts.meta.ts +32 -0
- package/src/components/sentinel-mini.meta.ts +39 -0
- package/src/components/skeleton.meta.ts +42 -0
- package/src/components/video-result.meta.ts +53 -0
- package/src/icon.registry.tsx +737 -0
- package/src/icon.tsx +74 -0
- package/src/index.ts +43 -0
- package/src/meta.ts +76 -0
- package/src/theme.css +859 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "video-result",
|
|
3
|
+
"type": "composite",
|
|
4
|
+
"title": "Video result",
|
|
5
|
+
"summary": "What a video run of a Studio leaves: the clip in its player (a muted loop in view, badge, controls, opens larger), the exact prompt, what the run recorded, the command that makes it again, and a download.",
|
|
6
|
+
"description": "What a video run of a Studio leaves: the clip in its player (a muted loop in view, badge, controls, opens larger), the exact prompt, what the run recorded, the command that makes it again, and a download.",
|
|
7
|
+
"why": "Sovkora's promise is that a result is proven, not claimed: a generated clip comes with its prompt, its model, its seed, its frame, its length, its time, its cost and the id of the run that made it, and says how to make it again. @krizaka/ui has the player and the code block; this composite is the product's way of showing a run's result once, the same on every screen.",
|
|
8
|
+
"status": "beta",
|
|
9
|
+
"deprecated": null,
|
|
10
|
+
"category": "data-display",
|
|
11
|
+
"platforms": "web",
|
|
12
|
+
"builtOn": [
|
|
13
|
+
"ui/code-block",
|
|
14
|
+
"ui/button"
|
|
15
|
+
],
|
|
16
|
+
"whenToUse": [
|
|
17
|
+
"On the page of a finished run of a video Studio, in the console and in the gallery of a site.",
|
|
18
|
+
"Anywhere a generated clip must come with what proves where it came from."
|
|
19
|
+
],
|
|
20
|
+
"whenNotToUse": [
|
|
21
|
+
{
|
|
22
|
+
"when": "For a short looping accent with no words: a muted `<video>` that plays in view is enough."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"when": "For a clip the visitor must control in depth (subtitles, speed, chapters).",
|
|
26
|
+
"use": "ui/video-player"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"bestPractices": [
|
|
30
|
+
"Say the real source in the facts (`Made by DigitalOcean serverless inference · run by Sovkora`), never a flattering one.",
|
|
31
|
+
"Give the frame the clip was really made in (`orientation`), never crop a landscape clip into a portrait one.",
|
|
32
|
+
"Every word is a prop (`labels`, `facts`): the app translates them; the prompt stays as the model received it."
|
|
33
|
+
],
|
|
34
|
+
"accessibility": {
|
|
35
|
+
"keyboard": [
|
|
36
|
+
{
|
|
37
|
+
"keys": "Enter / Space",
|
|
38
|
+
"action": "Activates the focused button: open larger, play or pause, sound."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"keys": "Tab",
|
|
42
|
+
"action": "Moves through the player controls, the copy button and the download link."
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"notes": [
|
|
46
|
+
"The video is named (`title`); it is a muted loop that plays only in view, never under reduced motion (poster and a play button).",
|
|
47
|
+
"The facts are a description list; the command is a code block with a polite copy confirmation.",
|
|
48
|
+
"The prompt is marked `lang=\"en\"`: it is what the model received, whatever the page's language."
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"related": [
|
|
52
|
+
"clip-player",
|
|
53
|
+
"run-facts",
|
|
54
|
+
"ui/video-player",
|
|
55
|
+
"ui/code-block"
|
|
56
|
+
],
|
|
57
|
+
"web": {
|
|
58
|
+
"entry": "@krizaka/sovkora-design-system",
|
|
59
|
+
"import": "import { VideoResult } from \"@krizaka/sovkora-design-system\";",
|
|
60
|
+
"props": [
|
|
61
|
+
{
|
|
62
|
+
"component": "VideoResult",
|
|
63
|
+
"description": "The result of a video run: the player (a muted loop in view, badge, controls, opens larger), the prompt, the facts,\n\"Reproduce it\" and the download.",
|
|
64
|
+
"props": [
|
|
65
|
+
{
|
|
66
|
+
"name": "facts",
|
|
67
|
+
"type": "readonly RunFact[]",
|
|
68
|
+
"default": null,
|
|
69
|
+
"description": "What the run recorded, in reading order (model, seed, frame, length, time, cost, run id).",
|
|
70
|
+
"required": true
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "labels",
|
|
74
|
+
"type": "VideoResultLabels",
|
|
75
|
+
"default": null,
|
|
76
|
+
"description": "The words.",
|
|
77
|
+
"required": true
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "prompt",
|
|
81
|
+
"type": "string",
|
|
82
|
+
"default": null,
|
|
83
|
+
"description": "The exact prompt that reached the model.",
|
|
84
|
+
"required": true
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"name": "sources",
|
|
88
|
+
"type": "ClipSources",
|
|
89
|
+
"default": null,
|
|
90
|
+
"description": "The files: H.264 `mp4` (always), `webm` when there is one, and the poster.",
|
|
91
|
+
"required": true
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "title",
|
|
95
|
+
"type": "string",
|
|
96
|
+
"default": null,
|
|
97
|
+
"description": "The accessible name of the video: what it shows, in a sentence.",
|
|
98
|
+
"required": true
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "className",
|
|
102
|
+
"type": "string",
|
|
103
|
+
"default": null,
|
|
104
|
+
"description": "Extra classes on the root, merged last.",
|
|
105
|
+
"required": false
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "downloadHref",
|
|
109
|
+
"type": "string",
|
|
110
|
+
"default": null,
|
|
111
|
+
"description": "Where the file can be downloaded; without it there is no download button.",
|
|
112
|
+
"required": false
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "orientation",
|
|
116
|
+
"type": "\"landscape\" | \"portrait\"",
|
|
117
|
+
"default": "landscape",
|
|
118
|
+
"description": "The frame the clip was made in; a portrait one is drawn as tall as a phone allows, a landscape one 16:9.",
|
|
119
|
+
"required": false
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "promptNode",
|
|
123
|
+
"type": "ReactNode",
|
|
124
|
+
"default": null,
|
|
125
|
+
"description": "Replaces the plain prompt (the app may type it in, over its final size): the heading stays.",
|
|
126
|
+
"required": false
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "reproduce",
|
|
130
|
+
"type": "string",
|
|
131
|
+
"default": null,
|
|
132
|
+
"description": "The CLI command that makes the clip again; without it there is no \"Reproduce it\".",
|
|
133
|
+
"required": false
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"name": "reproduceLead",
|
|
137
|
+
"type": "string",
|
|
138
|
+
"default": null,
|
|
139
|
+
"description": "One sentence above the command.",
|
|
140
|
+
"required": false
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
"examples": [
|
|
146
|
+
{
|
|
147
|
+
"name": "tea-pour",
|
|
148
|
+
"title": "A landscape clip with its run",
|
|
149
|
+
"description": "Eric's tea-pour shot: the poster, the prompt, the facts of the run, the command and the download.",
|
|
150
|
+
"path": "examples/video-result/tea-pour.tsx",
|
|
151
|
+
"module": "@krizaka/sovkora-design-system/registry/examples/video-result/tea-pour",
|
|
152
|
+
"code": "import { VideoResult } from \"@krizaka/sovkora-design-system\";\n// Eric's tea-pour shot: the clip, its prompt, what the run recorded, how to make it again.\nexport default function TeaPour() {\n return (\n <VideoResult\n title=\"Mint tea pouring into an engraved glass in a Berber tent, steam in a shaft of golden light\"\n sources={{\n mp4: \"https://krizaka-assets.b-cdn.net/site/products/orazaka/gallery-video/tea-pour.c4138c8ce9.mp4\",\n webm: \"https://krizaka-assets.b-cdn.net/site/products/orazaka/gallery-video/tea-pour.b33e468733.webm\",\n poster: \"https://krizaka-assets.b-cdn.net/site/products/orazaka/gallery-video/tea-pour-poster.8a062e63c2.webp\",\n }}\n prompt=\"Extreme close-up, slow motion: a stream of amber mint tea pours from a silver teapot into a small engraved glass, steam curling through a shaft of golden-hour light inside a Berber tent.\"\n facts={[\n { label: \"Model\", value: \"Wan 2.2 A14B\" },\n { label: \"Run by\", value: \"Sovkora · run 25948442\" },\n { label: \"Seed\", value: \"20261011\" },\n { label: \"Frame\", value: \"832×480 · 16 fps · 5 s\" },\n ]}\n reproduce={\n 'orazaka video \"Extreme close-up, slow motion: a stream of amber mint tea pours…\" \\\\\\n --model wan2-2-t2v-a14b --aspect 16:9 --seed 20261011'\n }\n downloadHref=\"https://krizaka-assets.b-cdn.net/site/products/orazaka/gallery-video/tea-pour.c4138c8ce9.mp4\"\n labels={{\n badge: \"Video · 5 s\",\n play: \"Play\",\n pause: \"Pause\",\n mute: \"Mute\",\n unmute: \"Sound on\",\n enlarge: \"Open larger\",\n close: \"Close\",\n prompt: \"Prompt\",\n facts: \"The run\",\n reproduce: \"Reproduce it\",\n copy: \"Copy command\",\n copied: \"Command copied\",\n download: \"Download the clip\",\n }}\n />\n );\n}\n"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"name": "assistant",
|
|
156
|
+
"title": "A clip without its command",
|
|
157
|
+
"description": "The player, the prompt and the facts of the run; no command, no download.",
|
|
158
|
+
"path": "examples/video-result/assistant.tsx",
|
|
159
|
+
"module": "@krizaka/sovkora-design-system/registry/examples/video-result/assistant",
|
|
160
|
+
"code": "import { VideoResult } from \"@krizaka/sovkora-design-system\";\n\n// A clip without its command: the player, the prompt and the facts of the run, nothing else to copy.\nexport default function Assistant() {\n return (\n <VideoResult\n title=\"A night workshop where a holographic interface of orange light unfolds blueprints\"\n sources={{\n mp4: \"https://krizaka-assets.b-cdn.net/site/products/orazaka/gallery-video/assistant.8b571af009.mp4\",\n webm: \"https://krizaka-assets.b-cdn.net/site/products/orazaka/gallery-video/assistant.7311e0410e.webm\",\n poster: \"https://krizaka-assets.b-cdn.net/site/products/orazaka/gallery-video/assistant-poster.fc37abd8c7.webp\",\n }}\n prompt=\"A genius engineer's workshop at night, a floating holographic interface of orange light blooms in mid-air and organises blueprints of a machine.\"\n facts={[\n { label: \"Model\", value: \"Wan 2.2 A14B\" },\n { label: \"Seed\", value: \"20261011\" },\n { label: \"Frame\", value: \"832×480 · 16 fps · 5 s\" },\n ]}\n labels={{\n badge: \"Video · 5 s\",\n play: \"Play\",\n pause: \"Pause\",\n mute: \"Mute\",\n unmute: \"Sound on\",\n enlarge: \"Open larger\",\n close: \"Close\",\n prompt: \"Prompt\",\n facts: \"The run\",\n reproduce: \"Reproduce it\",\n copy: \"Copy command\",\n copied: \"Command copied\",\n download: \"Download the clip\",\n }}\n />\n );\n}\n"
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
"native": null,
|
|
165
|
+
"dependencies": [],
|
|
166
|
+
"registryDependencies": []
|
|
167
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge as KzBadge,
|
|
3
|
+
type BadgeProps as KzBadgeProps,
|
|
4
|
+
type BadgeVariants,
|
|
5
|
+
badgeVariants,
|
|
6
|
+
} from "@krizaka/ui/badge";
|
|
7
|
+
|
|
8
|
+
export { type BadgeVariants,badgeVariants };
|
|
9
|
+
|
|
10
|
+
/** The 1.x badge variants. @deprecated Since 2.0 — use `tone`. */
|
|
11
|
+
export type BadgeVariant = "default" | "success" | "warning" | "danger" | "accent";
|
|
12
|
+
|
|
13
|
+
/** Props of {@link Badge}: the @krizaka/ui badge, plus the 1.x `variant`. */
|
|
14
|
+
export type BadgeProps = KzBadgeProps & {
|
|
15
|
+
/** @deprecated Since 2.0 — use `tone` (`default` → `neutral`, the other names are the same). */
|
|
16
|
+
variant?: BadgeVariant;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TONE_OF_VARIANT = {
|
|
20
|
+
default: "neutral",
|
|
21
|
+
success: "success",
|
|
22
|
+
warning: "warning",
|
|
23
|
+
danger: "danger",
|
|
24
|
+
accent: "accent",
|
|
25
|
+
} as const satisfies Record<BadgeVariant, NonNullable<KzBadgeProps["tone"]>>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The @krizaka/ui badge (`tone` neutral · accent · success · warning · danger · scrim, `size`, `dot`, `pulse`), with
|
|
29
|
+
* the 1.x `variant` kept as a deprecated alias of `tone`. `tone` wins when both are given.
|
|
30
|
+
*
|
|
31
|
+
* @param props - {@link BadgeProps}
|
|
32
|
+
* @returns A status badge.
|
|
33
|
+
*/
|
|
34
|
+
export function Badge({ variant, tone, ...props }: BadgeProps) {
|
|
35
|
+
return <KzBadge tone={tone ?? (variant ? TONE_OF_VARIANT[variant] : undefined)} {...props} />;
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Button as KzButton, type ButtonProps } from "@krizaka/ui/button";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The button is the @krizaka/ui primitive. Same props as 1.x (`variant` primary · secondary · outline · ghost, `size`
|
|
5
|
+
* sm · md · lg · icon), plus `danger`, `shape`, `loading` and `asChild`; `className` is merged last, so an override
|
|
6
|
+
* always wins. React 19: `ref` is a prop. A product variant extends `buttonVariants` (`tv({ extend: buttonVariants })`),
|
|
7
|
+
* it never copies it.
|
|
8
|
+
*/
|
|
9
|
+
export {
|
|
10
|
+
type ButtonProps,
|
|
11
|
+
type ButtonVariants,
|
|
12
|
+
buttonVariants,
|
|
13
|
+
IconButton,
|
|
14
|
+
type IconButtonProps,
|
|
15
|
+
} from "@krizaka/ui/button";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The @krizaka/ui `Button`, with the Sovkora 1.x default kept: without a `variant`, it is the primary (accent) button
|
|
19
|
+
* — the primitive's own default is `secondary`. Everything else is the primitive's.
|
|
20
|
+
*
|
|
21
|
+
* @param props - {@link ButtonProps}
|
|
22
|
+
* @returns A button (or its child, with `asChild`).
|
|
23
|
+
*/
|
|
24
|
+
export function Button({ variant = "primary", ...props }: ButtonProps) {
|
|
25
|
+
return <KzButton variant={variant} {...props} />;
|
|
26
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ChatShowcase.tsx
|
|
5
|
+
* @description Self-playing, sovereign AI-chat showcase — Calm Obsidian 2026.
|
|
6
|
+
*
|
|
7
|
+
* A code-driven mockup of the Sovkora engine answering a query *locally*.
|
|
8
|
+
* Dramatizes the product's value prop (deterministic interceptor pipeline +
|
|
9
|
+
* on-prem inference + zero data egress). Token-driven,
|
|
10
|
+
* roles only (--kz-* utilities),
|
|
11
|
+
* reduced-motion safe, and framework-agnostic on copy (all strings via props
|
|
12
|
+
* so each app localizes through its own i18n).
|
|
13
|
+
*
|
|
14
|
+
* Shared by the Sovkora web app (its operator console included) (AGENTS.md §8).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { MessageComposer } from "@krizaka/ui/chat";
|
|
18
|
+
import { cn } from "@krizaka/ui/cn";
|
|
19
|
+
import * as React from "react";
|
|
20
|
+
|
|
21
|
+
import { Icon } from "../icon";
|
|
22
|
+
import { CHAT_SHOWCASE_CSS } from "./chat-showcase.css";
|
|
23
|
+
|
|
24
|
+
/** Localizable, non-conversational chrome labels. */
|
|
25
|
+
export interface ChatShowcaseLabels {
|
|
26
|
+
agent: string;
|
|
27
|
+
status: string;
|
|
28
|
+
model: string;
|
|
29
|
+
routed: string;
|
|
30
|
+
privacy: string;
|
|
31
|
+
placeholder: string;
|
|
32
|
+
/** The send button's name inside the composer (inert here, so it is never read); defaults to "Send". */
|
|
33
|
+
send?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Props for {@link ChatShowcase}. */
|
|
37
|
+
export interface ChatShowcaseProps {
|
|
38
|
+
/** The chrome's words, translated: the agent's name, its status, the model, the "answered locally" tag, the privacy line, the composer's placeholder. */
|
|
39
|
+
labels: ChatShowcaseLabels;
|
|
40
|
+
/** The user's scripted question. */
|
|
41
|
+
question: string;
|
|
42
|
+
/** The assistant's scripted answer (typed out char-by-char). */
|
|
43
|
+
answer: string;
|
|
44
|
+
/** Interceptor pipeline steps rendered as chips once the answer lands. */
|
|
45
|
+
pipeline: string[];
|
|
46
|
+
/** Extra classes on the wrapper (width, placement), merged last. */
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const noop = () => {};
|
|
51
|
+
|
|
52
|
+
type Phase = 0 | 1 | 2 | 3 | 4; // idle · user · typing · answering · done
|
|
53
|
+
|
|
54
|
+
const REDUCE = "(prefers-reduced-motion: reduce)";
|
|
55
|
+
const subscribeReduce = (notify: () => void) => {
|
|
56
|
+
if (typeof window === "undefined" || !window.matchMedia) return noop;
|
|
57
|
+
const query = window.matchMedia(REDUCE);
|
|
58
|
+
query.addEventListener?.("change", notify);
|
|
59
|
+
return () => query.removeEventListener?.("change", notify);
|
|
60
|
+
};
|
|
61
|
+
const readReduce = () => typeof window !== "undefined" && Boolean(window.matchMedia?.(REDUCE).matches);
|
|
62
|
+
|
|
63
|
+
/** `true` when the person asked for less motion; read on the client only (the server renders the first frame). */
|
|
64
|
+
function usePrefersReducedMotion(): boolean {
|
|
65
|
+
return React.useSyncExternalStore(subscribeReduce, readReduce, () => false);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Renders the animated sovereign-chat card.
|
|
70
|
+
*
|
|
71
|
+
* @param props - {@link ChatShowcaseProps}
|
|
72
|
+
* @returns The showcase React element.
|
|
73
|
+
*/
|
|
74
|
+
export function ChatShowcase({ labels, question, answer, pipeline, className }: Readonly<ChatShowcaseProps>) {
|
|
75
|
+
const reduce = usePrefersReducedMotion();
|
|
76
|
+
const [played, setPlayed] = React.useState({ answer, phase: 0 as Phase, typed: "" });
|
|
77
|
+
// The sequence restarts when the copy (locale) changes: the state is reset while rendering, never in an effect.
|
|
78
|
+
if (played.answer !== answer) setPlayed({ answer, phase: 0, typed: "" });
|
|
79
|
+
const setPhase = React.useCallback((next: Phase) => setPlayed((p) => ({ ...p, phase: next })), []);
|
|
80
|
+
const setTyped = React.useCallback((next: string) => setPlayed((p) => ({ ...p, typed: next })), []);
|
|
81
|
+
// Under reduced motion the final state is shown at once, and nothing is scheduled.
|
|
82
|
+
const phase: Phase = reduce ? 4 : played.phase;
|
|
83
|
+
const typed = reduce ? answer : played.typed;
|
|
84
|
+
|
|
85
|
+
// Orchestrate the scripted sequence (timers only: their callbacks move the state).
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
if (reduce) return;
|
|
88
|
+
const timers = [
|
|
89
|
+
setTimeout(() => setPhase(1), 650),
|
|
90
|
+
setTimeout(() => setPhase(2), 1350),
|
|
91
|
+
setTimeout(() => setPhase(3), 2450),
|
|
92
|
+
];
|
|
93
|
+
return () => timers.forEach(clearTimeout);
|
|
94
|
+
}, [answer, reduce, setPhase]);
|
|
95
|
+
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
if (phase !== 3) return;
|
|
98
|
+
let i = 0;
|
|
99
|
+
const id = setInterval(() => {
|
|
100
|
+
i += 2;
|
|
101
|
+
setTyped(answer.slice(0, i));
|
|
102
|
+
if (i >= answer.length) {
|
|
103
|
+
clearInterval(id);
|
|
104
|
+
setPhase(4);
|
|
105
|
+
}
|
|
106
|
+
}, 16);
|
|
107
|
+
return () => clearInterval(id);
|
|
108
|
+
}, [phase, answer, setPhase, setTyped]);
|
|
109
|
+
|
|
110
|
+
const renderConversation = (ph: Phase, text: string) => {
|
|
111
|
+
const shownText = ph === 4 ? answer : text;
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
{/* User */}
|
|
115
|
+
<div
|
|
116
|
+
className="flex justify-end transition-all duration-300"
|
|
117
|
+
style={{ opacity: ph >= 1 ? 1 : 0, transform: ph >= 1 ? "none" : "translateY(8px)" }}
|
|
118
|
+
>
|
|
119
|
+
<div className="max-w-[84%] rounded-2xl rounded-br-[5px] bg-accent px-3 py-2.5 text-[13px] font-medium leading-[1.55] text-on-accent">
|
|
120
|
+
{question}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Typing */}
|
|
125
|
+
{ph === 2 && (
|
|
126
|
+
<div className="flex justify-start">
|
|
127
|
+
<div className="inline-flex items-center gap-1 rounded-2xl rounded-bl-[5px] border border-border-subtle bg-surface-2 px-3 py-3">
|
|
128
|
+
<span className="kzc-typing h-1.5 w-1.5 rounded-full bg-fg-muted" />
|
|
129
|
+
<span className="kzc-typing h-1.5 w-1.5 rounded-full bg-fg-muted" />
|
|
130
|
+
<span className="kzc-typing h-1.5 w-1.5 rounded-full bg-fg-muted" />
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{/* Assistant */}
|
|
136
|
+
{ph >= 3 && (
|
|
137
|
+
<div className="flex justify-start">
|
|
138
|
+
<div className="max-w-[84%] rounded-2xl rounded-bl-[5px] border border-border-subtle bg-surface-2 px-3 py-2.5 text-[13px] leading-[1.55] text-fg">
|
|
139
|
+
<span className="mb-[7px] inline-flex items-center gap-1.5 font-mono text-[9.5px] font-bold uppercase tracking-[0.04em] text-success">
|
|
140
|
+
<Icon name="checkCircle" size={11} />
|
|
141
|
+
{labels.routed}
|
|
142
|
+
</span>
|
|
143
|
+
<p className="m-0">
|
|
144
|
+
{shownText}
|
|
145
|
+
{ph === 3 && (
|
|
146
|
+
<span className="kzc-caret ml-px inline-block h-[1em] w-0.5 translate-y-[2px] bg-accent align-text-bottom" />
|
|
147
|
+
)}
|
|
148
|
+
</p>
|
|
149
|
+
<div
|
|
150
|
+
className="mt-[11px] flex flex-wrap gap-1.5 border-t border-border-subtle pt-[11px] transition-opacity duration-300"
|
|
151
|
+
style={{ opacity: ph >= 4 ? 1 : 0 }}
|
|
152
|
+
>
|
|
153
|
+
{pipeline.map((step, i) => (
|
|
154
|
+
<span
|
|
155
|
+
key={step}
|
|
156
|
+
className="inline-flex items-center gap-1 rounded-full bg-accent-soft px-2 py-0.5 font-mono text-[10px] font-semibold text-accent"
|
|
157
|
+
>
|
|
158
|
+
<Icon name="check" size={10} />
|
|
159
|
+
{step}
|
|
160
|
+
{i < pipeline.length - 1 && <span className="ml-0.5 opacity-50">→</span>}
|
|
161
|
+
</span>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className={cn("relative w-full max-w-[440px]", className)}>
|
|
173
|
+
<div
|
|
174
|
+
aria-hidden
|
|
175
|
+
className="pointer-events-none absolute -inset-x-8 -top-10 -bottom-14 rounded-xl"
|
|
176
|
+
style={{
|
|
177
|
+
background: "radial-gradient(60% 55% at 60% 35%, var(--kz-accent-soft) 0%, transparent 70%)",
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
<div
|
|
182
|
+
role="img"
|
|
183
|
+
aria-label={`${question} — ${answer}`}
|
|
184
|
+
className="relative flex flex-col overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-lg"
|
|
185
|
+
>
|
|
186
|
+
{/* ── Header ── */}
|
|
187
|
+
<div className="flex items-center justify-between gap-3 border-b border-border-subtle bg-surface-2 px-4 py-3">
|
|
188
|
+
<div className="flex min-w-0 items-center gap-2.5">
|
|
189
|
+
<span className="grid h-[34px] w-[34px] shrink-0 place-items-center rounded-full bg-accent-soft text-accent">
|
|
190
|
+
<Icon name="shield" size={17} />
|
|
191
|
+
</span>
|
|
192
|
+
<span className="flex min-w-0 flex-col">
|
|
193
|
+
<span className="font-display text-[13px] font-bold tracking-tight text-fg">{labels.agent}</span>
|
|
194
|
+
<span className="flex items-center gap-1.5 text-[10.5px] text-fg-secondary">
|
|
195
|
+
<span className="kzc-dot h-1.5 w-1.5 rounded-full bg-success" />
|
|
196
|
+
{labels.status}
|
|
197
|
+
</span>
|
|
198
|
+
</span>
|
|
199
|
+
</div>
|
|
200
|
+
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-border-subtle bg-surface-1 px-2.5 py-1 font-mono text-[10px] font-semibold text-fg-secondary">
|
|
201
|
+
<Icon name="model" size={11} />
|
|
202
|
+
{labels.model}
|
|
203
|
+
</span>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* ── Conversation ──
|
|
207
|
+
Sized by its FINAL state: an invisible copy shares the grid cell with the live one (no layout shift). */}
|
|
208
|
+
<div className="grid min-h-[268px]">
|
|
209
|
+
<div
|
|
210
|
+
aria-hidden
|
|
211
|
+
data-chat-ghost
|
|
212
|
+
className="invisible col-start-1 row-start-1 flex flex-col gap-3 px-4 py-[18px]"
|
|
213
|
+
>
|
|
214
|
+
{renderConversation(4, answer)}
|
|
215
|
+
</div>
|
|
216
|
+
<div className="col-start-1 row-start-1 flex flex-col gap-3 px-4 py-[18px]">
|
|
217
|
+
{renderConversation(phase, typed)}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* ── Footer ── the very composer of the app (MessageComposer, pill): the card is an instance of the real component, inert. */}
|
|
222
|
+
<div className="flex flex-col border-t border-border-subtle bg-surface-2 px-4 pb-3 pt-[13px]" aria-hidden inert>
|
|
223
|
+
<MessageComposer
|
|
224
|
+
variant="pill"
|
|
225
|
+
value=""
|
|
226
|
+
onValueChange={noop}
|
|
227
|
+
onSend={noop}
|
|
228
|
+
placeholder={labels.placeholder}
|
|
229
|
+
sendLabel={labels.send ?? "Send"}
|
|
230
|
+
inputProps={{ tabIndex: -1, readOnly: true }}
|
|
231
|
+
status={
|
|
232
|
+
<>
|
|
233
|
+
<span className="text-success">
|
|
234
|
+
<Icon name="shield" size={12} />
|
|
235
|
+
</span>
|
|
236
|
+
{labels.privacy}
|
|
237
|
+
</>
|
|
238
|
+
}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<style>{CHAT_SHOWCASE_CSS}</style>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
ChatShowcase.displayName = "ChatShowcase";
|