@chai-ui/vue 0.0.0-stage → 0.7.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 (88) hide show
  1. package/DESIGN.md +235 -0
  2. package/LICENSE +21 -0
  3. package/README.md +122 -2
  4. package/dist/ChaiProvider.vue.d.ts +24 -0
  5. package/dist/ChaiProvider.vue.d.ts.map +1 -0
  6. package/dist/Composer.vue.d.ts +94 -0
  7. package/dist/Composer.vue.d.ts.map +1 -0
  8. package/dist/EditCard.vue.d.ts +38 -0
  9. package/dist/EditCard.vue.d.ts.map +1 -0
  10. package/dist/MediaAnalyzer.vue.d.ts +54 -0
  11. package/dist/MediaAnalyzer.vue.d.ts.map +1 -0
  12. package/dist/PageSlide.vue.d.ts +23 -0
  13. package/dist/PageSlide.vue.d.ts.map +1 -0
  14. package/dist/ResultCard.vue.d.ts +29 -0
  15. package/dist/ResultCard.vue.d.ts.map +1 -0
  16. package/dist/composer/AttachMenu.vue.d.ts +17 -0
  17. package/dist/composer/AttachMenu.vue.d.ts.map +1 -0
  18. package/dist/composer/Chip.vue.d.ts +14 -0
  19. package/dist/composer/Chip.vue.d.ts.map +1 -0
  20. package/dist/dismiss.d.ts +4 -0
  21. package/dist/dismiss.d.ts.map +1 -0
  22. package/dist/edit-card/ExpandedImage.vue.d.ts +12 -0
  23. package/dist/edit-card/ExpandedImage.vue.d.ts.map +1 -0
  24. package/dist/edit-card/RegionShape.vue.d.ts +20 -0
  25. package/dist/edit-card/RegionShape.vue.d.ts.map +1 -0
  26. package/dist/icons.d.ts +43 -0
  27. package/dist/icons.d.ts.map +1 -0
  28. package/dist/index.d.ts +16 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +2515 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/page-slide.d.ts +31 -0
  33. package/dist/page-slide.d.ts.map +1 -0
  34. package/dist/primitives/Pagination.vue.d.ts +26 -0
  35. package/dist/primitives/Pagination.vue.d.ts.map +1 -0
  36. package/dist/primitives/SearchMenu.vue.d.ts +51 -0
  37. package/dist/primitives/SearchMenu.vue.d.ts.map +1 -0
  38. package/dist/primitives/Toggle.vue.d.ts +16 -0
  39. package/dist/primitives/Toggle.vue.d.ts.map +1 -0
  40. package/dist/primitives/search-menu.d.ts +19 -0
  41. package/dist/primitives/search-menu.d.ts.map +1 -0
  42. package/dist/reasoning.d.ts +6 -0
  43. package/dist/reasoning.d.ts.map +1 -0
  44. package/dist/result-card/IconButton.vue.d.ts +21 -0
  45. package/dist/result-card/IconButton.vue.d.ts.map +1 -0
  46. package/dist/result-card/MoreMenu.vue.d.ts +13 -0
  47. package/dist/result-card/MoreMenu.vue.d.ts.map +1 -0
  48. package/dist/result-card/ResultMedia.vue.d.ts +28 -0
  49. package/dist/result-card/ResultMedia.vue.d.ts.map +1 -0
  50. package/dist/result-card/ResultText.vue.d.ts +8 -0
  51. package/dist/result-card/ResultText.vue.d.ts.map +1 -0
  52. package/dist/result-card/VideoPlayer.vue.d.ts +18 -0
  53. package/dist/result-card/VideoPlayer.vue.d.ts.map +1 -0
  54. package/dist/result-card/types.d.ts +9 -0
  55. package/dist/result-card/types.d.ts.map +1 -0
  56. package/dist/style.css +2378 -0
  57. package/dist/useComposer.d.ts +22 -0
  58. package/dist/useComposer.d.ts.map +1 -0
  59. package/dist/useMediaAnalyzer.d.ts +17 -0
  60. package/dist/useMediaAnalyzer.d.ts.map +1 -0
  61. package/package.json +79 -3
  62. package/src/ChaiProvider.vue +26 -0
  63. package/src/Composer.vue +362 -0
  64. package/src/EditCard.vue +520 -0
  65. package/src/MediaAnalyzer.vue +216 -0
  66. package/src/PageSlide.vue +36 -0
  67. package/src/ResultCard.vue +297 -0
  68. package/src/composer/AttachMenu.vue +66 -0
  69. package/src/composer/Chip.vue +26 -0
  70. package/src/dismiss.ts +22 -0
  71. package/src/edit-card/ExpandedImage.vue +39 -0
  72. package/src/edit-card/RegionShape.vue +39 -0
  73. package/src/icons.ts +55 -0
  74. package/src/index.ts +77 -0
  75. package/src/page-slide.ts +32 -0
  76. package/src/primitives/Pagination.vue +76 -0
  77. package/src/primitives/SearchMenu.vue +167 -0
  78. package/src/primitives/Toggle.vue +23 -0
  79. package/src/primitives/search-menu.ts +20 -0
  80. package/src/reasoning.ts +10 -0
  81. package/src/result-card/IconButton.vue +18 -0
  82. package/src/result-card/MoreMenu.vue +35 -0
  83. package/src/result-card/ResultMedia.vue +77 -0
  84. package/src/result-card/ResultText.vue +37 -0
  85. package/src/result-card/VideoPlayer.vue +90 -0
  86. package/src/result-card/types.ts +10 -0
  87. package/src/useComposer.ts +38 -0
  88. package/src/useMediaAnalyzer.ts +33 -0
@@ -0,0 +1,216 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Turns attached media into a prompt: a drop zone for images, a video or
4
+ * audio, a model menu with auto-select, a prompt-length menu, and submit.
5
+ * Pair with `useMediaAnalyzer`. Same props as React's `MediaAnalyzer`, with
6
+ * `v-model:attachments`, `v-model:model-id`, `v-model:auto-select-model`
7
+ * and `v-model:prompt-length`.
8
+ */
9
+ import "@material/web/fab/fab.js";
10
+ import { computed, ref, watch } from "vue";
11
+ import {
12
+ DEFAULT_MAX_ATTACHMENTS_BY_KIND,
13
+ isMediaAnalyzerAtCap,
14
+ mediaAnalyzerModelSections,
15
+ nextMediaAnalyzerAttachments,
16
+ PROMPT_LENGTH_HINT,
17
+ readMediaFiles,
18
+ replacedNote,
19
+ SUGGESTED_ANALYSIS_MODELS,
20
+ type MediaAnalysisPromptLength,
21
+ type MediaAnalyzerAttachment,
22
+ type MediaAnalyzerSubmitPayload,
23
+ type MediaKind,
24
+ type ModelOption,
25
+ } from "@chai-ui/core";
26
+ import { ArrowForwardIcon, AudioIcon, MixedMediaIcon, RemoveIcon, StopIcon, VideoIcon } from "./icons.js";
27
+ import SearchMenu from "./primitives/SearchMenu.vue";
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ attachments: MediaAnalyzerAttachment[];
32
+ maxAttachmentsByKind?: Record<MediaKind, number>;
33
+ /** Fallback roster for a kind `modelsByKind` doesn't list. */
34
+ models?: ModelOption[];
35
+ /** Per-kind rosters; once a kind is attached, only its models show. Defaults to Chai's suggested OpenRouter models. */
36
+ modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
37
+ /** The picked model. While `autoSelectModel` is on it's `null` until a submit routes, then the routed model. */
38
+ modelId: string | null;
39
+ /** The "Auto-select model" switch inside the model menu. Turning it on clears the pick. */
40
+ autoSelectModel: boolean;
41
+ /** `null` until a person picks one: the trigger says "Prompt length" rather than pre-filling a choice. */
42
+ promptLength: MediaAnalysisPromptLength | null;
43
+ promptLengthOptions: { value: MediaAnalysisPromptLength; label: string }[];
44
+ disabled?: boolean;
45
+ submitting?: boolean;
46
+ submitError?: string | null;
47
+ /** With a listener, submit turns into stop while `submitting` (wire it to `useMediaAnalyzer`'s `cancel`); without one, submit is just disabled. */
48
+ onAbort?: () => void;
49
+ }>(),
50
+ { maxAttachmentsByKind: () => DEFAULT_MAX_ATTACHMENTS_BY_KIND, models: () => [], modelsByKind: () => SUGGESTED_ANALYSIS_MODELS }
51
+ );
52
+ const emit = defineEmits<{
53
+ "update:attachments": [next: MediaAnalyzerAttachment[]];
54
+ "update:modelId": [id: string | null];
55
+ "update:autoSelectModel": [next: boolean];
56
+ "update:promptLength": [value: MediaAnalysisPromptLength];
57
+ unsupportedFile: [fileName: string];
58
+ submit: [payload: MediaAnalyzerSubmitPayload];
59
+ }>();
60
+
61
+ const input = ref<HTMLInputElement>();
62
+ // A count, not a boolean: dragenter/dragleave fire for every child the
63
+ // pointer crosses too, so a boolean would flicker off over the icon or text.
64
+ const dragDepth = ref(0);
65
+ // What the last attach replaced (one kind per analysis), shown until the
66
+ // attachments change some other way. Matched by ids: a parent's ref hands
67
+ // the list back as a reactive copy, never the same array.
68
+ const replaced = ref<{ note: string; ids: string } | null>(null);
69
+ const idsOf = (list: MediaAnalyzerAttachment[]) => list.map((a) => a.id).join();
70
+
71
+ const kind = computed(() => props.attachments[0]?.kind);
72
+ const atCap = computed(() => isMediaAnalyzerAtCap(props.attachments, props.maxAttachmentsByKind));
73
+ const sections = computed(() => mediaAnalyzerModelSections(kind.value, props.modelsByKind, props.models));
74
+ // Looked up across every section, so a pick made before any media is attached still names itself on the trigger.
75
+ const selectedModel = computed(() => sections.value.flatMap((s) => s.options).find((m) => m.id === props.modelId));
76
+ const modelTriggerLabel = computed(
77
+ () => selectedModel.value?.label ?? (props.autoSelectModel ? "Auto-select model" : "Select model")
78
+ );
79
+ const lengthOptions = computed(() =>
80
+ // The clarifying suffix shows only in the list, never on the trigger.
81
+ props.promptLengthOptions.map((o) => ({ id: o.value, label: o.label, rowLabel: `${o.label} (${PROMPT_LENGTH_HINT[o.value]})` }))
82
+ );
83
+ const lengthLabel = computed(() => props.promptLengthOptions.find((o) => o.value === props.promptLength)?.label ?? "Prompt length");
84
+ const isSubmitDisabled = computed(() => props.disabled || props.attachments.length === 0);
85
+ const showStop = computed(() => Boolean(props.submitting && props.onAbort));
86
+
87
+ // Auto-select chooses at submit, so turning it on clears the current pick
88
+ // and the trigger says what will happen until it does.
89
+ watch(
90
+ () => props.autoSelectModel,
91
+ (on, was) => {
92
+ if (on && !was) emit("update:modelId", null);
93
+ }
94
+ );
95
+
96
+ /** Reads every file first, then applies them in drop order and commits once. */
97
+ async function handleFiles(files: FileList | null | undefined) {
98
+ if (!files || files.length === 0) return;
99
+ const { media, unsupported } = await readMediaFiles(files);
100
+ unsupported.forEach((name) => emit("unsupportedFile", name));
101
+ if (media.length === 0) return;
102
+ const next = media.reduce((acc, m) => nextMediaAnalyzerAttachments(acc, m, props.maxAttachmentsByKind), props.attachments);
103
+ const note = replacedNote(props.attachments, next);
104
+ replaced.value = note ? { note, ids: idsOf(next) } : null;
105
+ emit("update:attachments", next);
106
+ }
107
+
108
+ function onDrop(e: DragEvent) {
109
+ dragDepth.value = 0;
110
+ if (!props.disabled) void handleFiles(e.dataTransfer?.files);
111
+ }
112
+
113
+ function onFileChange(e: Event) {
114
+ const el = e.target as HTMLInputElement;
115
+ void handleFiles(el.files);
116
+ el.value = "";
117
+ }
118
+
119
+ function remove(id: string) {
120
+ emit(
121
+ "update:attachments",
122
+ props.attachments.filter((a) => a.id !== id)
123
+ );
124
+ }
125
+
126
+ function onSubmitClick() {
127
+ if (showStop.value) props.onAbort?.();
128
+ else if (!isSubmitDisabled.value && !props.submitting) {
129
+ emit("submit", {
130
+ attachments: props.attachments,
131
+ modelId: props.modelId,
132
+ autoSelectModel: props.autoSelectModel,
133
+ models: kind.value ? (props.modelsByKind[kind.value] ?? props.models) : [],
134
+ promptLength: props.promptLength,
135
+ });
136
+ }
137
+ }
138
+ </script>
139
+
140
+ <template>
141
+ <div class="chai-media-analyzer">
142
+ <div
143
+ :class="[
144
+ 'chai-media-analyzer__dropzone',
145
+ { 'chai-media-analyzer__dropzone--disabled': atCap, 'chai-media-analyzer__dropzone--drag-active': dragDepth > 0 },
146
+ ]"
147
+ @dragenter.prevent="dragDepth++"
148
+ @dragleave.prevent="dragDepth = Math.max(0, dragDepth - 1)"
149
+ @dragover.prevent
150
+ @drop.prevent="onDrop"
151
+ >
152
+ <span class="chai-media-analyzer__dropzone-icon" aria-hidden="true"><MixedMediaIcon /></span>
153
+ <!-- `atCap` is only ever true for image. -->
154
+ <p class="chai-media-analyzer__dropzone-hint">{{ atCap ? "Maximum images added" : "Add media to convert to prompt" }}</p>
155
+ <button type="button" class="chai-chip-button" :disabled="disabled" @click="input?.click()">Select files</button>
156
+ <input ref="input" type="file" accept="image/*,video/*,audio/*" multiple hidden :disabled="disabled" @change="onFileChange" />
157
+ </div>
158
+
159
+ <div v-if="attachments.length > 0" class="chai-media-analyzer__thumbnails">
160
+ <div v-for="a in attachments" :key="a.id" class="chai-media-analyzer__thumb">
161
+ <img v-if="a.kind === 'image'" class="chai-media-analyzer__thumb-img" :src="a.src" alt="" />
162
+ <span v-else class="chai-media-analyzer__thumb-icon"><VideoIcon v-if="a.kind === 'video'" /><AudioIcon v-else /></span>
163
+ <button type="button" class="chai-media-analyzer__thumb-remove" :aria-label="`Remove ${a.name ?? a.kind}`" @click="remove(a.id)">
164
+ <RemoveIcon />
165
+ </button>
166
+ </div>
167
+ </div>
168
+
169
+ <p v-if="replaced && replaced.ids === idsOf(attachments)" class="chai-media-analyzer__note" role="status">{{ replaced.note }}</p>
170
+ <p v-if="submitError" class="chai-media-analyzer__error" role="alert">{{ submitError }}</p>
171
+
172
+ <div class="chai-media-analyzer__controls">
173
+ <div class="chai-media-analyzer__controls-start">
174
+ <SearchMenu
175
+ :options="sections.length === 1 ? sections[0]!.options : undefined"
176
+ :sections="sections.length > 1 ? sections : undefined"
177
+ :toggle-header="{ label: 'Auto-select model', value: autoSelectModel, onChange: (next) => emit('update:autoSelectModel', next) }"
178
+ :value="modelId"
179
+ :trigger-label="modelTriggerLabel"
180
+ menu-label="Select model"
181
+ :searchable="false"
182
+ trigger-variant="text"
183
+ :disabled="disabled"
184
+ :panel-width="320"
185
+ @select="emit('update:modelId', $event.id)"
186
+ />
187
+ <SearchMenu
188
+ :options="lengthOptions"
189
+ :value="promptLength"
190
+ :trigger-label="lengthLabel"
191
+ menu-label="Prompt length"
192
+ :searchable="false"
193
+ trigger-variant="text"
194
+ :disabled="disabled"
195
+ @select="emit('update:promptLength', $event.id as MediaAnalysisPromptLength)"
196
+ />
197
+ </div>
198
+
199
+ <!-- The same unfilled-until-ready submit as Composer's FAB. -->
200
+ <md-fab
201
+ class="chai-media-analyzer__submit"
202
+ size="small"
203
+ variant="secondary"
204
+ label=""
205
+ :data-ready="showStop || !isSubmitDisabled || undefined"
206
+ :data-error="Boolean(submitError) || undefined"
207
+ :data-stop="showStop || undefined"
208
+ :aria-label="showStop ? 'Stop' : submitError ? 'Retry' : 'Analyze media'"
209
+ :aria-disabled="showStop ? 'false' : submitting || isSubmitDisabled ? 'true' : 'false'"
210
+ @click="onSubmitClick"
211
+ >
212
+ <span slot="icon"><StopIcon v-if="showStop" /><ArrowForwardIcon v-else /></span>
213
+ </md-fab>
214
+ </div>
215
+ </div>
216
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Renders a page with its slide. The outgoing image stays in the layout
4
+ * while it slides away, so the card holds its height even while the new
5
+ * image is still loading, and the new page slides in over that space. The
6
+ * page keeps its key once the slide ends, so it isn't remounted (no
7
+ * reloaded image, no restarted video).
8
+ */
9
+ import { computed } from "vue";
10
+ import type { Slide } from "./page-slide.js";
11
+
12
+ const props = defineProps<{ slide: Slide | null; pageClass?: string }>();
13
+ const emit = defineEmits<{ end: [] }>();
14
+
15
+ const side = computed(() => (props.slide?.forward ? "left" : "right"));
16
+ const outgoing = computed(() => (props.slide?.active ? props.slide.from : undefined));
17
+ </script>
18
+
19
+ <template>
20
+ <img
21
+ v-if="outgoing"
22
+ :key="`out-${slide!.key}`"
23
+ :class="`chai-slide-out chai-slide-out--${side}`"
24
+ :src="outgoing"
25
+ alt=""
26
+ aria-hidden="true"
27
+ draggable="false"
28
+ />
29
+ <div
30
+ :key="`in-${slide?.key ?? 0}`"
31
+ :class="[pageClass, slide?.active && `chai-slide-in chai-slide-in--${side}`, outgoing && 'chai-slide-in--over']"
32
+ @animationend.self="emit('end')"
33
+ >
34
+ <slot />
35
+ </div>
36
+ </template>
@@ -0,0 +1,297 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The Composer bar's complement, and the last stop in the pipeline: the
4
+ * results from one prompt, one at a time, paged with the dots when there
5
+ * are several. Flips to show the prompt and details. Same props as React's
6
+ * `ResultCard`; `onAction` is the `action` event.
7
+ */
8
+ import { computed, nextTick, reactive, ref, watch } from "vue";
9
+ import {
10
+ downloadFilename,
11
+ downloadMedia,
12
+ formatAspectRatio,
13
+ formatCost,
14
+ formatDuration,
15
+ formatTokens,
16
+ resultModelLabel,
17
+ shareMedia,
18
+ type ModelOption,
19
+ type Result,
20
+ } from "@chai-ui/core";
21
+ import { CheckIcon, CloseIcon, CopyIcon, InfoIcon, RefreshIcon, ThumbDownIcon, ThumbUpIcon } from "./icons.js";
22
+ import PageSlide from "./PageSlide.vue";
23
+ import { usePageSlide } from "./page-slide.js";
24
+ import Pagination from "./primitives/Pagination.vue";
25
+ import IconButton from "./result-card/IconButton.vue";
26
+ import MoreMenu from "./result-card/MoreMenu.vue";
27
+ import ResultMedia from "./result-card/ResultMedia.vue";
28
+ import VideoPlayer from "./result-card/VideoPlayer.vue";
29
+ import type { ResultCardMoreAction, ResultCardVote } from "./result-card/types.js";
30
+
31
+ const props = defineProps<{
32
+ /** The results from one prompt: `results[0]` if there's only one, paged internally if more. */
33
+ results: Result[];
34
+ /** Fixed width in px. Defaults to 375; height follows the media. */
35
+ width?: number;
36
+ /** Max height in px of a text result before it scrolls. Defaults to 400. */
37
+ textMaxHeight?: number;
38
+ /** The request's prompt, shown read-only (copiable) on the info side. */
39
+ prompt: string;
40
+ /** Alt text for a generated image. Defaults to `prompt`. */
41
+ altText?: (result: Result) => string;
42
+ /** Resolves `Result.modelId` to a label, on the info side and beside the page dots. */
43
+ models?: ModelOption[];
44
+ /** A result's vote, if any. Controlled: what a vote means is the app's policy. */
45
+ getVote?: (result: Result) => ResultCardVote;
46
+ /** Extra items in the ⋯ menu. Omit to hide the menu. */
47
+ moreActions?: ResultCardMoreAction[];
48
+ disabled?: boolean;
49
+ }>();
50
+ const emit = defineEmits<{
51
+ /** `"like"`, `"dislike"`, `"download"`, `"share"`, `"copy-prompt"`, `"retry"`, or a `moreActions` id. */
52
+ action: [action: string, result: Result];
53
+ }>();
54
+
55
+ const activeIndex = ref(0);
56
+ const flipped = ref(false);
57
+ const copied = ref(false);
58
+ const linkCopied = ref(false);
59
+ const mediaExpanded = ref(false);
60
+ // Measured off the loaded media for the aspect-ratio chip, with the id it
61
+ // was measured from, so a stale ratio never shows after paging.
62
+ const measured = ref<{ id: string; width: number; height: number } | null>(null);
63
+ const videoTime = reactive({ current: 0 });
64
+ const videoWasPlaying = ref(false);
65
+ const front = ref<HTMLElement>();
66
+ const back = ref<HTMLElement>();
67
+ const overlay = ref<HTMLElement>();
68
+
69
+ const index = computed(() => Math.min(activeIndex.value, Math.max(0, props.results.length - 1)));
70
+ const result = computed(() => props.results[index.value]);
71
+ const kind = computed(() => result.value?.output?.kind);
72
+ const paginated = computed(() => props.results.length > 1);
73
+ // An image or video runs under a dark fade into the bar; text, audio and status pages keep the plain surface.
74
+ const overMedia = computed(() => kind.value === "image" || kind.value === "video");
75
+ const vote = computed(() => (result.value && props.getVote?.(result.value)) ?? null);
76
+ const modelLabel = computed(() => (result.value ? resultModelLabel(result.value, props.models) : ""));
77
+ const alt = computed(() => (result.value ? (props.altText?.(result.value) ?? props.prompt) : ""));
78
+ const chips = computed(() => {
79
+ const r = result.value;
80
+ const dims = measured.value?.id === r?.id ? measured.value : null;
81
+ return [
82
+ modelLabel.value,
83
+ formatTokens(r?.usage?.totalTokens),
84
+ formatCost(r?.usage?.costUsd),
85
+ formatDuration(r?.durationMs),
86
+ dims ? formatAspectRatio(dims.width, dims.height) : undefined,
87
+ ].filter(Boolean) as string[];
88
+ });
89
+ const cardStyle = computed(() => ({
90
+ ...(props.width != null && { "--chai-result-card-width": `${props.width}px` }),
91
+ ...(props.textMaxHeight != null && { "--chai-result-card-text-max-height": `${props.textMaxHeight}px` }),
92
+ }));
93
+
94
+ // Paging slides, with the last image sliding out (other kinds just slide in).
95
+ const { slide, endSlide } = usePageSlide(() => ({
96
+ id: result.value?.id,
97
+ index: index.value,
98
+ image: kind.value === "image" ? result.value!.output!.src : undefined,
99
+ }));
100
+
101
+ const act = (action: string) => emit("action", action, result.value!);
102
+
103
+ function flash(flag: typeof copied) {
104
+ flag.value = true;
105
+ setTimeout(() => (flag.value = false), 1500);
106
+ }
107
+
108
+ function copyPrompt() {
109
+ navigator.clipboard?.writeText(props.prompt).catch(() => {});
110
+ flash(copied);
111
+ act("copy-prompt");
112
+ }
113
+
114
+ async function download() {
115
+ const r = result.value!;
116
+ if (r.output) await downloadMedia(r.output.src, downloadFilename(r.id, r.output.kind, r.output.src));
117
+ emit("action", "download", r);
118
+ }
119
+
120
+ async function share() {
121
+ const r = result.value!;
122
+ // With no share sheet the link is copied; say so, or the click looks like nothing happened.
123
+ if (r.output && (await shareMedia(r.output.src)) === "copied") flash(linkCopied);
124
+ emit("action", "share", r);
125
+ }
126
+
127
+ // Flipping moves focus to the side now showing, so a keyboard user isn't left on a button that just turned away.
128
+ watch(
129
+ flipped,
130
+ (isFlipped) => {
131
+ const target = isFlipped
132
+ ? back.value?.querySelector<HTMLElement>('[aria-label="Close details"]')
133
+ : front.value?.querySelector<HTMLElement>('[aria-label="Show details"]');
134
+ target?.focus();
135
+ },
136
+ { flush: "post" }
137
+ );
138
+
139
+ // The expanded view is a modal dialog: focus moves in, Tab stays inside,
140
+ // Escape closes, and focus returns to Expand, the only way in.
141
+ watch(mediaExpanded, async (open) => {
142
+ await nextTick();
143
+ if (open) overlay.value?.querySelector<HTMLElement>(".chai-result-card__collapse")?.focus();
144
+ else front.value?.querySelector<HTMLElement>('[aria-label="Expand"]')?.focus();
145
+ });
146
+
147
+ function onOverlayKeydown(e: KeyboardEvent) {
148
+ if (e.key === "Escape") {
149
+ e.stopPropagation();
150
+ mediaExpanded.value = false;
151
+ return;
152
+ }
153
+ if (e.key !== "Tab") return;
154
+ const focusable = [
155
+ ...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('button, [tabindex="0"], a[href], input, video[controls]'),
156
+ ].filter((el) => !el.hasAttribute("disabled"));
157
+ const first = focusable[0];
158
+ const last = focusable[focusable.length - 1];
159
+ if (!first || !last) return;
160
+ if (e.shiftKey && document.activeElement === first) {
161
+ e.preventDefault();
162
+ last.focus();
163
+ } else if (!e.shiftKey && document.activeElement === last) {
164
+ e.preventDefault();
165
+ first.focus();
166
+ }
167
+ }
168
+ </script>
169
+
170
+ <template>
171
+ <template v-if="result">
172
+ <div :class="['chai-result-card', { 'chai-result-card--flipped': flipped }]" :style="cardStyle">
173
+ <div class="chai-result-card__inner">
174
+ <!-- The face turned away is inert, so its buttons can't be tabbed to. -->
175
+ <div
176
+ ref="front"
177
+ :class="['chai-result-card__face chai-result-card__front', { 'chai-result-card__front--paginated': paginated }]"
178
+ :aria-hidden="flipped"
179
+ :inert="flipped"
180
+ >
181
+ <div class="chai-result-card__media-frame">
182
+ <PageSlide :slide="slide" page-class="chai-result-card__media-page" @end="endSlide">
183
+ <ResultMedia
184
+ :result="result"
185
+ :alt="alt"
186
+ :disabled="disabled"
187
+ :media-expanded="mediaExpanded"
188
+ :link-copied="linkCopied"
189
+ :video-time="videoTime"
190
+ :video-was-playing="videoWasPlaying"
191
+ @download="download"
192
+ @share="share"
193
+ @expand="mediaExpanded = true"
194
+ @dimensions="(w, h) => (measured = { id: result!.id, width: w, height: h })"
195
+ @video-playing-change="videoWasPlaying = $event"
196
+ />
197
+ </PageSlide>
198
+ <div v-if="paginated && overMedia" class="chai-card-fade" />
199
+ </div>
200
+
201
+ <!-- With several results, the page dots are the card's last row, with the model that made this page on the left. -->
202
+ <div v-if="paginated" :class="['chai-card-pager-bar', { 'chai-card-pager-bar--over-media': overMedia }]">
203
+ <span class="chai-card-pager-label">{{ modelLabel }}</span>
204
+ <Pagination
205
+ :count="results.length"
206
+ :index="index"
207
+ label="Results"
208
+ :disabled="disabled"
209
+ @update:index="activeIndex = $event"
210
+ />
211
+ </div>
212
+
213
+ <!-- Floating over the bottom of the media: votes and retry in one pill, details on the right. -->
214
+ <div class="chai-card-bottom">
215
+ <div class="chai-card-actions">
216
+ <div class="chai-card-pill">
217
+ <IconButton variant="pill" label="Like" :active="vote === 'like'" :disabled="disabled" @click="act('like')">
218
+ <ThumbUpIcon />
219
+ </IconButton>
220
+ <IconButton variant="pill" label="Dislike" :active="vote === 'dislike'" :disabled="disabled" @click="act('dislike')">
221
+ <ThumbDownIcon />
222
+ </IconButton>
223
+ <IconButton variant="pill" label="Retry" :disabled="disabled" @click="act('retry')"><RefreshIcon /></IconButton>
224
+ <MoreMenu v-if="moreActions?.length" :actions="moreActions" :disabled="disabled" @select="act($event.id)" />
225
+ </div>
226
+ <IconButton variant="round" label="Show details" :disabled="disabled" @click="flipped = true"><InfoIcon /></IconButton>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ <div ref="back" class="chai-result-card__face chai-result-card__back" :aria-hidden="!flipped" :inert="!flipped">
232
+ <button type="button" class="chai-result-card__close" aria-label="Close details" @click="flipped = false">
233
+ <CloseIcon />
234
+ </button>
235
+ <div class="chai-result-card__prompt-box">
236
+ <p class="chai-result-card__prompt-text">{{ prompt }}</p>
237
+ <button
238
+ type="button"
239
+ class="chai-result-card__copy"
240
+ :aria-label="copied ? 'Copied' : 'Copy prompt'"
241
+ :title="copied ? 'Copied' : 'Copy prompt'"
242
+ @click="copyPrompt"
243
+ >
244
+ <CheckIcon v-if="copied" /><CopyIcon v-else />
245
+ </button>
246
+ </div>
247
+ <div class="chai-result-card__chips">
248
+ <span v-for="(chip, i) in chips" :key="i" class="chai-result-card__chip">{{ chip }}</span>
249
+ <!-- The builder's own backend checks: pass green with a check, fail red with a cross, so the verdict never relies on color alone. -->
250
+ <span
251
+ v-for="evaluation in result.evaluations"
252
+ :key="evaluation.id"
253
+ :class="[
254
+ 'chai-result-card__chip chai-result-card__chip--evaluation',
255
+ `chai-result-card__chip--${evaluation.passed ? 'passed' : 'failed'}`,
256
+ ]"
257
+ :title="`${evaluation.label}: ${evaluation.passed ? 'passed' : 'failed'}`"
258
+ >
259
+ <CheckIcon v-if="evaluation.passed" /><CloseIcon v-else />{{ evaluation.label
260
+ }}<span class="chai-visually-hidden">{{ evaluation.passed ? ", passed" : ", failed" }}</span>
261
+ </span>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ </div>
266
+
267
+ <!-- The expanded view is the card's sibling, not its child: the card's
268
+ `perspective` (for the flip) would make a nested `position: fixed`
269
+ overlay cover only the card. The expanded video is a second element;
270
+ `videoTime` and `videoWasPlaying` carry its place across. -->
271
+ <div
272
+ v-if="mediaExpanded && result.output && overMedia"
273
+ ref="overlay"
274
+ class="chai-result-card__expand-overlay"
275
+ role="dialog"
276
+ aria-modal="true"
277
+ :aria-label="kind === 'video' ? 'Expanded video' : 'Expanded image'"
278
+ @keydown="onOverlayKeydown"
279
+ >
280
+ <div class="chai-result-card__expand-scrim" role="presentation" @click="mediaExpanded = false" />
281
+ <div class="chai-result-card__expand-frame">
282
+ <VideoPlayer
283
+ v-if="kind === 'video'"
284
+ :src="result.output.src"
285
+ expanded
286
+ :time="videoTime"
287
+ :was-playing="videoWasPlaying"
288
+ @playing-change="videoWasPlaying = $event"
289
+ />
290
+ <img v-else class="chai-result-card__image" :src="result.output.src" :alt="alt" />
291
+ <button type="button" class="chai-result-card__collapse" aria-label="Collapse" @click="mediaExpanded = false">
292
+ <CloseIcon />
293
+ </button>
294
+ </div>
295
+ </div>
296
+ </template>
297
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ /** The "+" / "×" attach control: a positioned popover of the use-case actions, and the hidden file picker behind "Add media". */
3
+ import { ref } from "vue";
4
+ import type { ComposerAttachMenuAction, composerAttachMenuItems } from "@chai-ui/core";
5
+ import { useDismiss } from "../dismiss.js";
6
+ import { AttachFileIcon, CloseSymbolIcon, ContentCutIcon, ImageIcon, PlusIcon, VideocamIcon } from "../icons.js";
7
+
8
+ defineProps<{
9
+ items: ReturnType<typeof composerAttachMenuItems>;
10
+ /** Edit mode: one image file at a time. */
11
+ imagesOnly?: boolean;
12
+ disabled?: boolean;
13
+ }>();
14
+ const emit = defineEmits<{ select: [action: ComposerAttachMenuAction]; files: [files: FileList | null] }>();
15
+
16
+ const ICONS = { "add-media": AttachFileIcon, "create-image": ImageIcon, "create-video": VideocamIcon, "edit-media": ContentCutIcon };
17
+ const open = ref(false);
18
+ const root = ref<HTMLElement>();
19
+ const input = ref<HTMLInputElement>();
20
+ useDismiss(root, open, () => (open.value = false));
21
+
22
+ function pick(action: ComposerAttachMenuAction) {
23
+ open.value = false;
24
+ if (action === "add-media") input.value?.click();
25
+ emit("select", action);
26
+ }
27
+
28
+ function onFileChange(e: Event) {
29
+ const el = e.target as HTMLInputElement;
30
+ emit("files", el.files);
31
+ el.value = "";
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <div ref="root" class="chai-composer__attach">
37
+ <button
38
+ type="button"
39
+ :class="['chai-composer__attach-toggle', { 'chai-composer__attach-toggle--open': open }]"
40
+ :disabled="disabled"
41
+ :aria-expanded="open"
42
+ aria-haspopup="menu"
43
+ :aria-label="open ? 'Close attach menu' : 'Add media'"
44
+ @click="open = !open"
45
+ >
46
+ <CloseSymbolIcon v-if="open" /><PlusIcon v-else />
47
+ </button>
48
+ <ul v-if="open" class="chai-composer__attach-menu" role="menu">
49
+ <li v-for="(item, i) in items" :key="item.action" role="none">
50
+ <button type="button" role="menuitem" class="chai-composer__attach-menu-item" @click="pick(item.action)">
51
+ <span class="chai-composer__attach-menu-icon"><component :is="ICONS[item.action]" /></span>{{ item.label }}
52
+ </button>
53
+ <hr v-if="item.divider && i < items.length - 1" class="chai-composer__attach-menu-divider" role="separator" />
54
+ </li>
55
+ </ul>
56
+ <input
57
+ ref="input"
58
+ type="file"
59
+ :accept="imagesOnly ? 'image/*' : 'image/*,video/*,audio/*'"
60
+ :multiple="!imagesOnly"
61
+ hidden
62
+ :disabled="disabled"
63
+ @change="onFileChange"
64
+ />
65
+ </div>
66
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A use-case or region chip: its label, and a remove button revealed on
4
+ * hover when it can be removed. A region chip shows its numbered badge in
5
+ * the region's color, matching the box on the image.
6
+ */
7
+ import { regionColor } from "@chai-ui/core";
8
+ import { CloseSymbolIcon } from "../icons.js";
9
+
10
+ defineProps<{ label: string; regionNumber?: number; removable?: boolean; disabled?: boolean }>();
11
+ const emit = defineEmits<{ remove: [] }>();
12
+ </script>
13
+
14
+ <template>
15
+ <span
16
+ :class="['chai-composer__chip', { 'chai-composer__chip--region': regionNumber != null }]"
17
+ :style="regionNumber != null ? { '--chai-region-color': regionColor(regionNumber) } : undefined"
18
+ >
19
+ <!-- Label and button touch: whitespace between them would render as a space. -->
20
+ <span v-if="regionNumber != null" class="chai-composer__region-badge" aria-hidden="true">{{ regionNumber }}</span
21
+ >{{ label }}<button
22
+ v-if="removable" type="button" class="chai-composer__chip-remove" :disabled="disabled" :aria-label="`Remove ${label}`" @click="emit('remove')">
23
+ <CloseSymbolIcon />
24
+ </button>
25
+ </span>
26
+ </template>
package/src/dismiss.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { onScopeDispose, watch, type Ref } from "vue";
2
+
3
+ /** While `open`, a press outside `root` or Escape calls `close`: the inline menus' popover behavior. */
4
+ export function useDismiss(root: Ref<HTMLElement | undefined>, open: Ref<boolean>, close: () => void) {
5
+ function onPointerDown(e: PointerEvent) {
6
+ if (!root.value?.contains(e.target as Node)) close();
7
+ }
8
+ function onKeydown(e: KeyboardEvent) {
9
+ if (e.key === "Escape") close();
10
+ }
11
+ function listen(on: boolean) {
12
+ if (on) {
13
+ document.addEventListener("pointerdown", onPointerDown);
14
+ document.addEventListener("keydown", onKeydown);
15
+ } else {
16
+ document.removeEventListener("pointerdown", onPointerDown);
17
+ document.removeEventListener("keydown", onKeydown);
18
+ }
19
+ }
20
+ watch(open, listen);
21
+ onScopeDispose(() => listen(false));
22
+ }