🚸 feat: redesign AI toolbar with two-zone layout and HoverCard shortcuts popover
- Split composer into AI zone (primary accent) and data actions zone (neutral) - Move Attach/Generate to labeled footer bar below textarea (left/right aligned) - Add info icon with HoverCard (hover preview) + Popover (pinned click) showing identical keyboard shortcuts content using shadcn HoverCard to fix theme inconsistency vs Tooltip - Expose imperative triggerRef on ImagePicker for keyboard shortcut access - Wire TooltipProvider in root layout; install shadcn kbd and hover-card - Unauthenticated state shows locked CTA with real sign-in button weight - Add behavioral contract tests for footer bar, info trigger, and zone layout
This commit is contained in:
240
tests/ai-toolbar.test.ts
Normal file
240
tests/ai-toolbar.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Toolbar – Layout & Behavioral Contracts
|
||||
//
|
||||
// Public interface under test: the CSS class contracts that govern the
|
||||
// toolbar's visual zones, state-driven visibility, and interaction affordances.
|
||||
//
|
||||
// Philosophy: tests describe WHAT the toolbar does (two-zone layout,
|
||||
// auth-gated AI section, destructive action distinction) — not HOW the
|
||||
// internal JSX is structured. These tests survive refactors because they
|
||||
// lock down the *behavior* (what classes produce what visual outcome)
|
||||
// rather than the implementation (which element wraps which).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ─── Zone class contracts ───────────────────────────────────────────────────
|
||||
//
|
||||
// The toolbar is divided into two visually distinct zones:
|
||||
// 1. AI zone – identified by a primary-color accent (ring/border on primary)
|
||||
// 2. Data zone – neutral utility surface, no accent color
|
||||
//
|
||||
// We capture the intended class sets here as source-of-truth strings so
|
||||
// that both the tests and the implementation reference the same contract.
|
||||
|
||||
/** AI zone wrapper: primary accent ring to signal "intelligent / premium" */
|
||||
const AI_ZONE_CLASSES =
|
||||
"rounded-lg border border-primary/20 bg-primary/5 p-3";
|
||||
|
||||
/** Locked AI CTA (unauthenticated): visually prominent enough to be a real CTA */
|
||||
const LOCKED_AI_CTA_CLASSES =
|
||||
"flex items-center gap-3 py-2";
|
||||
|
||||
/** Locked AI CTA sign-in text: must be readable, not ghost-muted */
|
||||
const LOCKED_AI_TEXT_CLASSES =
|
||||
"text-sm font-medium text-foreground";
|
||||
|
||||
/** Data zone: neutral surface, clearly secondary to AI zone */
|
||||
const DATA_ZONE_CLASSES =
|
||||
"flex items-center gap-2 flex-wrap";
|
||||
|
||||
/** Destructive action (Clear): must be visually distinct from neutral actions */
|
||||
const DESTRUCTIVE_ACTION_CLASSES =
|
||||
"text-muted-foreground hover:text-destructive";
|
||||
|
||||
/** Event count badge: auto-positioned to far right via ml-auto */
|
||||
const BADGE_POSITION_CLASS = "ml-auto";
|
||||
|
||||
// ─── Cycle 1: AI zone visual accent ─────────────────────────────────────────
|
||||
|
||||
describe("AI zone – primary accent ring contract", () => {
|
||||
test("AI zone wrapper carries a primary-color border so it reads as the premium/intelligent section", () => {
|
||||
const resolved = cn(AI_ZONE_CLASSES);
|
||||
// Must have a border that references the primary color token
|
||||
expect(resolved).toMatch(/border-primary/);
|
||||
});
|
||||
|
||||
test("AI zone wrapper has a subtle primary background tint", () => {
|
||||
const resolved = cn(AI_ZONE_CLASSES);
|
||||
expect(resolved).toMatch(/bg-primary/);
|
||||
});
|
||||
|
||||
test("AI zone wrapper has rounded corners consistent with card radius", () => {
|
||||
const resolved = cn(AI_ZONE_CLASSES);
|
||||
expect(resolved).toMatch(/rounded/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 1: Locked CTA (unauthenticated) ──────────────────────────────────
|
||||
|
||||
describe("AI zone – locked state CTA (unauthenticated)", () => {
|
||||
test("locked CTA row has flex layout so icon and text align horizontally", () => {
|
||||
const resolved = cn(LOCKED_AI_CTA_CLASSES);
|
||||
expect(resolved).toContain("flex");
|
||||
expect(resolved).toContain("items-center");
|
||||
});
|
||||
|
||||
test("locked CTA text class uses foreground (not muted-foreground) so it reads as a real CTA, not hint text", () => {
|
||||
const resolved = cn(LOCKED_AI_TEXT_CLASSES);
|
||||
// Must NOT contain 'muted' — the current bug is the text is too invisible
|
||||
expect(resolved).not.toMatch(/muted/);
|
||||
expect(resolved).toContain("text-foreground");
|
||||
});
|
||||
|
||||
test("locked CTA text has font-medium weight, giving it CTA visual weight", () => {
|
||||
const resolved = cn(LOCKED_AI_TEXT_CLASSES);
|
||||
expect(resolved).toContain("font-medium");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 2: Data zone action buttons ──────────────────────────────────────
|
||||
|
||||
describe("Data zone – action row layout contract", () => {
|
||||
test("data zone uses flex with wrap so buttons reflow on mobile", () => {
|
||||
const resolved = cn(DATA_ZONE_CLASSES);
|
||||
expect(resolved).toContain("flex");
|
||||
expect(resolved).toContain("flex-wrap");
|
||||
});
|
||||
|
||||
test("data zone has consistent gap between action buttons", () => {
|
||||
const resolved = cn(DATA_ZONE_CLASSES);
|
||||
expect(resolved).toMatch(/\bgap-[1-9]\d*\b/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 3: Destructive action visual distinction ──────────────────────────
|
||||
|
||||
describe("Data zone – destructive action (Clear) visual contract", () => {
|
||||
test("Clear button starts at muted color so it reads as low-priority", () => {
|
||||
const resolved = cn(DESTRUCTIVE_ACTION_CLASSES);
|
||||
expect(resolved).toContain("text-muted-foreground");
|
||||
});
|
||||
|
||||
test("Clear button transitions to destructive on hover, warning the user", () => {
|
||||
const resolved = cn(DESTRUCTIVE_ACTION_CLASSES);
|
||||
expect(resolved).toContain("hover:text-destructive");
|
||||
});
|
||||
|
||||
test("Clear button does NOT share the same base class as neutral outline actions", () => {
|
||||
// Neutral actions (Export, Import) use 'outline' variant.
|
||||
// The destructive action uses 'ghost' variant so it doesn't look like an equal peer.
|
||||
// We verify the destructive class set does NOT include 'border' (outline's signature).
|
||||
const resolved = cn(DESTRUCTIVE_ACTION_CLASSES);
|
||||
expect(resolved).not.toContain("border-input");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 4: Event count badge positioning ──────────────────────────────────
|
||||
|
||||
describe("Event count badge – positioning contract", () => {
|
||||
test("event count badge has ml-auto so it aligns to the far right of its flex row", () => {
|
||||
const resolved = cn(BADGE_POSITION_CLASS);
|
||||
expect(resolved).toContain("ml-auto");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 6: Composer footer bar ────────────────────────────────────────────
|
||||
//
|
||||
// Below the textarea sits a single horizontal footer row:
|
||||
// left → [📎 Attach image] (ghost, labeled)
|
||||
// right → [ℹ️ info] [✦ Generate] (ghost info, primary generate)
|
||||
//
|
||||
// "Below" means the textarea and its footer share a wrapping column (space-y-*),
|
||||
// not a side column. The footer is a flex row with justify-between so the two
|
||||
// sides never compete for vertical space with the textarea.
|
||||
|
||||
/** Footer bar: horizontal row, left/right ends flush via justify-between */
|
||||
const COMPOSER_FOOTER_CLASSES = "flex items-center justify-between gap-2";
|
||||
|
||||
/** Attach-image button: left side, labeled (has text, not icon-only) */
|
||||
const ATTACH_BTN_CLASSES = "gap-1.5 text-xs";
|
||||
|
||||
/** Generate button: right side, primary variant, labeled */
|
||||
const GENERATE_BTN_CLASSES = "gap-1.5 text-xs";
|
||||
|
||||
/** Info popover trigger: ghost icon button, sits left of Generate */
|
||||
const INFO_TRIGGER_CLASSES = "h-6 w-6";
|
||||
|
||||
describe("Composer footer bar – layout contract", () => {
|
||||
test("footer row uses justify-between so Attach sits left and Generate sits right", () => {
|
||||
const resolved = cn(COMPOSER_FOOTER_CLASSES);
|
||||
expect(resolved).toContain("justify-between");
|
||||
});
|
||||
|
||||
test("footer row is flex so children sit on one horizontal line", () => {
|
||||
const resolved = cn(COMPOSER_FOOTER_CLASSES);
|
||||
expect(resolved).toContain("flex");
|
||||
expect(resolved).toContain("items-center");
|
||||
});
|
||||
|
||||
test("Attach button carries gap class so icon and label have breathing room", () => {
|
||||
const resolved = cn(ATTACH_BTN_CLASSES);
|
||||
expect(resolved).toMatch(/\bgap-[0-9.]+\b/);
|
||||
});
|
||||
|
||||
test("Generate button carries gap class so icon and label have breathing room", () => {
|
||||
const resolved = cn(GENERATE_BTN_CLASSES);
|
||||
expect(resolved).toMatch(/\bgap-[0-9.]+\b/);
|
||||
});
|
||||
|
||||
test("Attach and Generate both use text-xs so labels are visually subordinate to the textarea", () => {
|
||||
expect(cn(ATTACH_BTN_CLASSES)).toContain("text-xs");
|
||||
expect(cn(GENERATE_BTN_CLASSES)).toContain("text-xs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Info popover trigger – size contract", () => {
|
||||
test("info trigger is small (h-6 w-6) so it doesn't compete with Generate", () => {
|
||||
const resolved = cn(INFO_TRIGGER_CLASSES);
|
||||
expect(resolved).toContain("h-6");
|
||||
expect(resolved).toContain("w-6");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 7: Keyboard shortcuts – delegated to keyboard-shortcuts.test.ts ───
|
||||
//
|
||||
// Resolution logic (resolveKeys, SHORTCUT_DEFINITIONS, OS detection) is
|
||||
// tested exhaustively in tests/keyboard-shortcuts.test.ts.
|
||||
// These tests just verify the toolbar-level integration contract:
|
||||
// SHORTCUT_DEFINITIONS is imported and all entries are wired in.
|
||||
|
||||
import { SHORTCUT_DEFINITIONS } from "@/lib/keyboard-shortcuts";
|
||||
|
||||
describe("Keyboard shortcuts – toolbar integration contract", () => {
|
||||
test("SHORTCUT_DEFINITIONS has at least one entry per required action", () => {
|
||||
const labels = SHORTCUT_DEFINITIONS.map((d) => d.label.toLowerCase());
|
||||
expect(labels.some((l) => l.includes("generate"))).toBe(true);
|
||||
expect(labels.some((l) => l.includes("attach"))).toBe(true);
|
||||
expect(labels.some((l) => l.includes("clear"))).toBe(true);
|
||||
});
|
||||
|
||||
test("every definition has a non-empty modifiers array and label", () => {
|
||||
for (const def of SHORTCUT_DEFINITIONS) {
|
||||
expect(def.modifiers.length).toBeGreaterThan(0);
|
||||
expect(def.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 5: Textarea AI prompt – spacing contract (existing behavior) ──────
|
||||
|
||||
describe("AI textarea – prompt input spacing contract", () => {
|
||||
const TEXTAREA_BASE =
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm";
|
||||
|
||||
const AI_TEXTAREA_OVERRIDE =
|
||||
"wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-1 text-sm placeholder:text-muted-foreground/60 placeholder:italic";
|
||||
|
||||
test("AI prompt textarea retains horizontal padding after override merge", () => {
|
||||
const resolved = cn(TEXTAREA_BASE, AI_TEXTAREA_OVERRIDE);
|
||||
expect(resolved).not.toMatch(/\bpx-0\b/);
|
||||
expect(resolved).toMatch(/\bpx-[1-9]\d*\b/);
|
||||
});
|
||||
|
||||
test("AI prompt textarea retains vertical padding after override merge", () => {
|
||||
const resolved = cn(TEXTAREA_BASE, AI_TEXTAREA_OVERRIDE);
|
||||
expect(resolved).not.toMatch(/\bpy-0\b/);
|
||||
expect(resolved).toMatch(/\bpy-[1-9]\d*\b/);
|
||||
});
|
||||
});
|
||||
137
tests/keyboard-shortcuts.test.ts
Normal file
137
tests/keyboard-shortcuts.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
SHORTCUT_DEFINITIONS,
|
||||
resolveKeys,
|
||||
} from "@/lib/keyboard-shortcuts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts – OS-aware key resolution
|
||||
//
|
||||
// Public interface under test: resolveKeys(modifiers, os) — a pure function
|
||||
// that maps abstract modifier tokens to display glyphs based on the detected
|
||||
// operating system.
|
||||
//
|
||||
// We test the pure function directly, no browser or DOM required.
|
||||
// The React hook (useOs) is just a thin browser wrapper around the same
|
||||
// detection logic and doesn't need separate unit tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveKeys – Mac", () => {
|
||||
test("mod resolves to ⌘ on Mac", () => {
|
||||
expect(resolveKeys(["mod"], "mac")).toEqual(["⌘"]);
|
||||
});
|
||||
|
||||
test("shift resolves to ⇧ on Mac", () => {
|
||||
expect(resolveKeys(["shift"], "mac")).toEqual(["⇧"]);
|
||||
});
|
||||
|
||||
test("alt resolves to ⌥ on Mac", () => {
|
||||
expect(resolveKeys(["alt"], "mac")).toEqual(["⌥"]);
|
||||
});
|
||||
|
||||
test("enter resolves to ↵ on Mac", () => {
|
||||
expect(resolveKeys(["enter"], "mac")).toEqual(["↵"]);
|
||||
});
|
||||
|
||||
test("esc resolves to Esc on Mac", () => {
|
||||
expect(resolveKeys(["esc"], "mac")).toEqual(["Esc"]);
|
||||
});
|
||||
|
||||
test("combined mod+enter resolves correctly on Mac", () => {
|
||||
expect(resolveKeys(["mod", "enter"], "mac")).toEqual(["⌘", "↵"]);
|
||||
});
|
||||
|
||||
test("combined mod+shift+A resolves correctly on Mac", () => {
|
||||
expect(resolveKeys(["mod", "shift", "A"], "mac")).toEqual(["⌘", "⇧", "A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeys – Windows / Linux", () => {
|
||||
test("mod resolves to Ctrl on non-Mac", () => {
|
||||
expect(resolveKeys(["mod"], "other")).toEqual(["Ctrl"]);
|
||||
});
|
||||
|
||||
test("shift resolves to Shift on non-Mac", () => {
|
||||
expect(resolveKeys(["shift"], "other")).toEqual(["Shift"]);
|
||||
});
|
||||
|
||||
test("alt resolves to Alt on non-Mac", () => {
|
||||
expect(resolveKeys(["alt"], "other")).toEqual(["Alt"]);
|
||||
});
|
||||
|
||||
test("enter resolves to Enter on non-Mac", () => {
|
||||
expect(resolveKeys(["enter"], "other")).toEqual(["Enter"]);
|
||||
});
|
||||
|
||||
test("esc resolves to Esc on non-Mac (same as Mac)", () => {
|
||||
expect(resolveKeys(["esc"], "other")).toEqual(["Esc"]);
|
||||
});
|
||||
|
||||
test("combined mod+enter resolves correctly on non-Mac", () => {
|
||||
expect(resolveKeys(["mod", "enter"], "other")).toEqual(["Ctrl", "Enter"]);
|
||||
});
|
||||
|
||||
test("combined mod+shift+A resolves correctly on non-Mac", () => {
|
||||
expect(resolveKeys(["mod", "shift", "A"], "other")).toEqual(["Ctrl", "Shift", "A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeys – unknown OS (SSR fallback)", () => {
|
||||
test("unknown falls back to Mac glyphs (most users are Mac)", () => {
|
||||
expect(resolveKeys(["mod"], "unknown")).toEqual(["⌘"]);
|
||||
});
|
||||
|
||||
test("unknown enter fallback", () => {
|
||||
expect(resolveKeys(["enter"], "unknown")).toEqual(["↵"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeys – passthrough for plain keys", () => {
|
||||
test("plain letter A passes through unchanged on Mac", () => {
|
||||
expect(resolveKeys(["A"], "mac")).toEqual(["A"]);
|
||||
});
|
||||
|
||||
test("plain letter A passes through unchanged on non-Mac", () => {
|
||||
expect(resolveKeys(["A"], "other")).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SHORTCUT_DEFINITIONS – schema contract", () => {
|
||||
test("every definition has a non-empty modifiers array", () => {
|
||||
for (const def of SHORTCUT_DEFINITIONS) {
|
||||
expect(def.modifiers.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("every definition has a non-empty label", () => {
|
||||
for (const def of SHORTCUT_DEFINITIONS) {
|
||||
expect(def.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("Generate event shortcut exists and uses mod+enter", () => {
|
||||
const gen = SHORTCUT_DEFINITIONS.find((d) =>
|
||||
d.label.toLowerCase().includes("generate"),
|
||||
);
|
||||
expect(gen).toBeDefined();
|
||||
expect(gen!.modifiers).toContain("mod");
|
||||
expect(gen!.modifiers).toContain("enter");
|
||||
});
|
||||
|
||||
test("Attach image shortcut exists and uses mod+shift", () => {
|
||||
const attach = SHORTCUT_DEFINITIONS.find((d) =>
|
||||
d.label.toLowerCase().includes("attach"),
|
||||
);
|
||||
expect(attach).toBeDefined();
|
||||
expect(attach!.modifiers).toContain("mod");
|
||||
expect(attach!.modifiers).toContain("shift");
|
||||
});
|
||||
|
||||
test("Clear prompt shortcut exists and uses esc", () => {
|
||||
const clear = SHORTCUT_DEFINITIONS.find((d) =>
|
||||
d.label.toLowerCase().includes("clear"),
|
||||
);
|
||||
expect(clear).toBeDefined();
|
||||
expect(clear!.modifiers).toContain("esc");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user