feat: support multiple image uploads for AI event generation 🖼️

- Updated OpenRouter integration to accept an array of image URLs
- Updated ImagePicker to use the `multiple` attribute natively
- Added `appendImagesDeduped` for handling client-side image deduplication
- Enhanced clipboard pasting to extract multiple images at once
- Rendered multiple images in a horizontal thumbnail strip in the AIToolbar
- Added tests to cover multi-image logic and AI request mapping
This commit is contained in:
2026-04-08 20:46:43 -04:00
parent cac201a4d2
commit 513aafcebc
18 changed files with 881 additions and 238 deletions

View File

@@ -217,6 +217,69 @@ describe("Keyboard shortcuts toolbar integration contract", () => {
});
});
// ─── Cycle 8: Multi-image thumbnail strip ────────────────────────────────────
//
// When multiple images are attached, they render as a horizontal scrollable
// strip of 64×64 thumbnails below the textarea.
//
// Contract:
// - Strip wrapper: `flex` + `overflow-x-auto` so it scrolls horizontally
// - Each thumbnail wrapper: `relative inline-block` so the X button can be
// positioned absolutely on top
// - Image itself: fixed 64×64, `object-cover`
// - Remove button: `absolute`, positioned at top-right corner
const IMAGE_STRIP_CLASSES = "flex gap-2 overflow-x-auto py-1";
const THUMBNAIL_WRAPPER_CLASSES = "relative inline-block shrink-0";
const THUMBNAIL_IMAGE_CLASSES = "h-16 w-16 rounded-md object-cover";
const THUMBNAIL_REMOVE_BTN_CLASSES = "absolute -top-1.5 -right-1.5";
describe("Multi-image strip layout contract", () => {
test("image strip wrapper uses flex layout for horizontal row", () => {
const resolved = cn(IMAGE_STRIP_CLASSES);
expect(resolved).toContain("flex");
});
test("image strip wrapper has overflow-x-auto for horizontal scroll when many images", () => {
const resolved = cn(IMAGE_STRIP_CLASSES);
expect(resolved).toContain("overflow-x-auto");
});
test("image strip wrapper has gap between thumbnails", () => {
const resolved = cn(IMAGE_STRIP_CLASSES);
expect(resolved).toMatch(/\bgap-[1-9]\d*\b/);
});
test("thumbnail wrapper is relative+inline-block so the remove button can be positioned absolutely", () => {
const resolved = cn(THUMBNAIL_WRAPPER_CLASSES);
expect(resolved).toContain("relative");
expect(resolved).toContain("inline-block");
});
test("thumbnail wrapper does not shrink (shrink-0) so images keep their size in flex row", () => {
const resolved = cn(THUMBNAIL_WRAPPER_CLASSES);
expect(resolved).toContain("shrink-0");
});
test("thumbnail image has fixed 64×64 size (h-16 w-16)", () => {
const resolved = cn(THUMBNAIL_IMAGE_CLASSES);
expect(resolved).toContain("h-16");
expect(resolved).toContain("w-16");
});
test("thumbnail image uses object-cover so it crops without distortion", () => {
const resolved = cn(THUMBNAIL_IMAGE_CLASSES);
expect(resolved).toContain("object-cover");
});
test("remove button is positioned absolutely at top-right corner of the thumbnail", () => {
const resolved = cn(THUMBNAIL_REMOVE_BTN_CLASSES);
expect(resolved).toContain("absolute");
expect(resolved).toMatch(/-top-/);
expect(resolved).toMatch(/-right-/);
});
});
// ─── Cycle 5: Textarea AI prompt spacing contract (existing behavior) ──────
describe("AI textarea prompt input spacing contract", () => {