🚸 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:
89
src/lib/keyboard-shortcuts.ts
Normal file
89
src/lib/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts – OS-aware key resolution
|
||||
//
|
||||
// Design:
|
||||
// - SHORTCUT_DEFINITIONS: abstract schema using modifier tokens
|
||||
// - resolveKeys(): pure function, safe to call anywhere (including tests)
|
||||
// - useOs(): client-only React hook that detects Mac vs other after hydration
|
||||
//
|
||||
// Modifier tokens:
|
||||
// "mod" → ⌘ (Mac) | Ctrl (other)
|
||||
// "shift" → ⇧ (Mac) | Shift (other)
|
||||
// "alt" → ⌥ (Mac) | Alt (other)
|
||||
// "enter" → ↵ (Mac) | Enter (other)
|
||||
// "esc" → Esc (both)
|
||||
// anything else passes through as-is (plain letter keys like "A")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Os = "mac" | "other" | "unknown";
|
||||
export type Modifier = string; // "mod" | "shift" | "alt" | "enter" | "esc" | plain key
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
modifiers: readonly Modifier[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ─── Abstract shortcut definitions ───────────────────────────────────────────
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
||||
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
||||
{ modifiers: ["esc"], label: "Clear prompt" },
|
||||
];
|
||||
|
||||
// ─── Key resolution ───────────────────────────────────────────────────────────
|
||||
|
||||
const MAC_MAP: Record<string, string> = {
|
||||
mod: "⌘",
|
||||
shift: "⇧",
|
||||
alt: "⌥",
|
||||
enter: "↵",
|
||||
esc: "Esc",
|
||||
};
|
||||
|
||||
const OTHER_MAP: Record<string, string> = {
|
||||
mod: "Ctrl",
|
||||
shift: "Shift",
|
||||
alt: "Alt",
|
||||
enter: "Enter",
|
||||
esc: "Esc",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function — maps abstract modifier tokens to display glyphs.
|
||||
* "unknown" falls back to Mac (most common dev/user base).
|
||||
*/
|
||||
export function resolveKeys(modifiers: readonly Modifier[], os: Os): string[] {
|
||||
const map = os === "other" ? OTHER_MAP : MAC_MAP;
|
||||
return modifiers.map((m) => map[m] ?? m);
|
||||
}
|
||||
|
||||
// ─── OS detection hook (client-only) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detects the user's OS after hydration.
|
||||
* Returns "unknown" on the server or before the effect runs.
|
||||
*
|
||||
* Detection order (most → least reliable):
|
||||
* 1. navigator.userAgentData.platform (modern browsers, Chromium)
|
||||
* 2. navigator.platform (legacy, still widely supported)
|
||||
* 3. navigator.userAgent string match (last resort)
|
||||
*/
|
||||
export function detectOs(): Os {
|
||||
if (typeof navigator === "undefined") return "unknown";
|
||||
|
||||
// Modern API — Chromium 90+
|
||||
const uaData = (navigator as Navigator & { userAgentData?: { platform: string } })
|
||||
.userAgentData;
|
||||
if (uaData?.platform) {
|
||||
return uaData.platform.toLowerCase().includes("mac") ? "mac" : "other";
|
||||
}
|
||||
|
||||
// Legacy API — still reliable on Safari and Firefox
|
||||
if (navigator.platform) {
|
||||
return navigator.platform.toLowerCase().startsWith("mac") ? "mac" : "other";
|
||||
}
|
||||
|
||||
// Last resort — UA string
|
||||
return /mac/i.test(navigator.userAgent) ? "mac" : "other";
|
||||
}
|
||||
Reference in New Issue
Block a user