Files
local-cal/tests/ai-toolbar.test.ts
Dmytro Stanchiev 722c0f0f7d 🚸 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
2026-04-08 13:08:36 -04:00

241 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/);
});
});