Skip to content
7 changes: 7 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { EggEffectsLayer } from "./features/easter-eggs/components/EggEffectsLay
import { MyKnowledgeGapsProvider } from "./features/knowledge-gaps/MyKnowledgeGapsProvider";
import { KnowledgeGapOwnerAnnouncement } from "./features/knowledge-gaps/components/KnowledgeGapOwnerAnnouncement";
import { useScrollRestoration } from "./hooks/useScrollRestoration";
import { GlobalShortcuts } from "./features/shortcuts";

function AppContent() {
const { status } = useAuth();
Expand Down Expand Up @@ -129,6 +130,12 @@ function AppContent() {
where the page already is the buddy. */}
{showBuddyDock && <BuddyWidget />}

{/* The keyboard, app-wide: the destination chords and `?` open a listener here rather
than per page, because a shortcut that only works on the page you are already on is
not a shortcut. Signed-in only — the login screen has nothing to jump between, and
the help lists destinations a signed-out visitor has no access to. */}
{signedIn && <GlobalShortcuts />}

{/* Offers to keep whatever the hire has highlighted, from any page. Mounted here for the
same reason the buddy is: what is worth keeping is almost never found on the board.

Expand Down
46 changes: 42 additions & 4 deletions src/components/layout/SideBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useId, useState } from "react";
import { useCallback, useEffect, useId, useState } from "react";
import { motion, useMotionValue } from "framer-motion";
import { NavLink, useLocation } from "react-router-dom";
import { LogOut, Menu, Settings, X } from "lucide-react";
Expand All @@ -13,6 +13,12 @@ import { useMyKnowledgeGaps } from "../../features/knowledge-gaps/useMyKnowledge
import { usePmAttentionFlag } from "../../features/team-management/usePmAttentionFlag";
import { useOpenEscalationCount } from "../../features/knowledge-request/useOpenEscalationCount";
import { useUnseenSkipAnswerCount } from "../../features/onboarding/hooks/useUnseenSkipAnswerCount";
import {
SIDEBAR_TOGGLE_SHORTCUT,
navigationShortcut,
useShortcutListener,
} from "../../features/shortcuts";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import {
AdminIcon,
BlueprintsIcon,
Expand Down Expand Up @@ -206,6 +212,12 @@ function SidebarContent({
canAccessRoute(profile, item.path, canManageSelected),
);

// The icon-only footer button has no room for a chip, so its name carries the chord — in
// both attributes, because `aria-label` wins the accessible-name computation and a `title`
// alone would reach the mouse tooltip only.
const settingsShortcut = navigationShortcut("/settings");
const settingsLabel = settingsShortcut ? `Settings (${settingsShortcut})` : "Settings";

// `/insights/knowledge-requests` is deliberately absent: it has its own
// sidebar entry, so listing it here would leave two entries active at once
// -- including two active pills sharing one Framer Motion `layoutId`.
Expand Down Expand Up @@ -306,6 +318,7 @@ function SidebarContent({
(item.path === "/pm-dashboard" && isPmSectionActive) ||
(item.path === "/chat" && isAssistantSectionActive)
}
shortcut={navigationShortcut(item.path)}
indicatorLayoutId={indicatorLayoutId}
pointerY={pointerY}
hasAttentionMarker={
Expand Down Expand Up @@ -377,8 +390,8 @@ function SidebarContent({
: "text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
}`
}
title="Settings"
aria-label="Settings"
title={settingsLabel}
aria-label={settingsLabel}
>
<Settings className="h-[18px] w-[18px]" />
</NavLink>
Expand Down Expand Up @@ -456,6 +469,31 @@ export function SideBar() {
setIsMobileSidebarOpen(false);
};

// A chord can change the route without any link being clicked (Alt+H — nothing runs
// `onNavigate` then), and the drawer would still be standing open over the new page.
// Deferred to a microtask so this is not a synchronous setState inside the effect body,
// which `react-hooks/set-state-in-effect` rejects.
useEffect(() => {
void Promise.resolve().then(() => setIsMobileSidebarOpen(false));
}, [pathname]);

/**
* Alt+S works whatever the header's button works — one definition of "toggle the sidebar"
* for both ways in. Today that is the drawer below `lg`; the desktop collapse (#244) will
* give the same chord something to do on a wide screen without this line changing.
*
* Which is exactly why it only listens below `lg`: on a wide screen the drawer is not on
* screen, so the chord would flip state nobody can see — and leave it flipped, opening the
* drawer uninvited the next time the window narrows. Worse, `Alt+S` is Firefox's History
* menu on Windows, and swallowing it for a no-op takes the browser's own chord too.
*/
const toggleMobileSidebar = useCallback(() => {
setIsMobileSidebarOpen((isOpen) => !isOpen);
}, []);
const isDesktopLayout = useMediaQuery("(min-width: 1024px)");

useShortcutListener(SIDEBAR_TOGGLE_SHORTCUT, toggleMobileSidebar, !isDesktopLayout);

return (
<>
<aside
Expand Down Expand Up @@ -483,7 +521,7 @@ export function SideBar() {
type="button"
aria-label={isMobileSidebarOpen ? "Close sidebar" : "Open sidebar"}
aria-expanded={isMobileSidebarOpen}
onClick={() => setIsMobileSidebarOpen((isOpen) => !isOpen)}
onClick={toggleMobileSidebar}
className="flex h-[40px] w-[40px] items-center justify-center rounded-[8px] text-app-text-muted transition-colors hover:bg-app-surface-hover hover:text-app-text focus-visible:ring-2 focus-visible:ring-app-focus focus-visible:outline-none"
>
{isMobileSidebarOpen ? (
Expand Down
16 changes: 16 additions & 0 deletions src/components/layout/SidebarNavLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
import type { SidebarIcon } from "./SidebarNavIcons";
import { slidingIndicatorSpringToken } from "../../styles/tokens";
import { prefetchRoute } from "../../services/routePrefetch";
import { ShortcutHint } from "../ui/ShortcutHint";
import { useProjectContext } from "../../features/projects/useProjectContext";

/**
Expand Down Expand Up @@ -178,6 +179,12 @@ type SidebarNavLinkProps = {
*/
busy?: boolean;
busyLabel?: string;
/**
* The chord that jumps here, shown on hover/focus. Comes from the shortcuts registry
* (`navigationShortcut`) rather than being typed at each call site, so the hint on the
* entry and the keypress that answers it cannot drift apart.
*/
shortcut?: string;
onNavigate?: () => void;
};

Expand Down Expand Up @@ -214,6 +221,7 @@ export function SidebarNavLink({
countLabel,
busy = false,
busyLabel,
shortcut,
onNavigate,
}: SidebarNavLinkProps) {
const prefersReducedMotion = useReducedMotion();
Expand Down Expand Up @@ -344,6 +352,10 @@ export function SidebarNavLink({
<NavLink
to={to}
end={end}
// The chord lives in the title rather than in the link's text: the chip is
// `aria-hidden` (a second rendering of the same thing), and this is the copy a
// screen reader does get.
title={shortcut ? `${label} (${shortcut})` : undefined}
onClick={onNavigate}
// Pointerdown rather than hover: a sweep across the sidebar passes over several
// entries in one motion, and prefetching all of them would spend requests on
Expand Down Expand Up @@ -527,6 +539,10 @@ export function SidebarNavLink({

<span>{label}</span>

{shortcut && (
<ShortcutHint keys={shortcut} className="border-app-border text-app-text-muted" />
)}

{/* One trailing slot, not two. The count takes it when there is
one: a number and the active dot side by side read as
two separate signals about the same row, and the row
Expand Down
6 changes: 5 additions & 1 deletion src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ export function Modal({
ref={dialogRef}
data-testid={testId}
role={role}
aria-modal="true"
// Only while open: during the exit animation AnimatePresence keeps the node in
// the DOM for the length of the fade, and `aria-modal` left on it would keep
// global chords dead for exactly that window — a dropped keypress right after
// Escape.
aria-modal={isOpen ? "true" : undefined}
// A sideways flick inside a dialog is a flick inside a dialog. Without this it reached
// the page underneath, where it switches tabs behind the open overlay.
{...{ [SWIPE_IGNORE_ATTRIBUTE]: "" }}
Expand Down
6 changes: 5 additions & 1 deletion src/components/ui/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,11 @@ export function SidePanel({
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
// Only while it is actually open. A closed panel is kept mounted so its backdrop can
// fade, and calling it `aria-modal` then would tell the shortcuts layer that an
// overlay owns the keyboard — on the Board, where one lives for the whole visit,
// every chord would go silent.
aria-modal={isOpen ? "true" : undefined}
// A sideways flick inside a panel is a flick inside a panel. Without this it reached the
// page underneath, where it switches tabs -- behind an overlay the person is reading.
{...{ [SWIPE_IGNORE_ATTRIBUTE]: "" }}
Expand Down
28 changes: 25 additions & 3 deletions src/features/easter-eggs/lib/keyTargets.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
/**
* Key-target guards shared by the easter-egg games and the Space-to-play
* trigger. Generic keyboard helpers, so they live here rather than in any one
* game's hook.
* Key-target guards shared by the easter-egg games, the Space-to-play trigger
* and the keyboard shortcuts. Generic keyboard helpers, so they live here
* rather than in any one game's hook.
*/

/** Shared guard: true while the event landed in a text field that owns the keys. */
export const isTypingTarget = (el: EventTarget | null): boolean =>
el instanceof HTMLElement &&
(el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable === true);

/**
* Input types somebody can type into. Everything else an `<input>` can be —
* checkbox, radio, range, file… — is focusable without being a text field.
*/
const TEXT_INPUT_TYPES = new Set(["text", "search", "email", "url", "password", "number", "tel"]);

/**
* True when the event landed somewhere that produces text — the guard the
* shortcut matcher uses.
*
* Deliberately stricter than {@link isTypingTarget}, which answers the wider
* question "a form control owns this key": the games want that one, because
* arrows belong to a focused radio group and Space to a focused switch. A
* shortcut chord is neither, and the wider question meant that one click on a
* checkbox silenced every `Alt+…` chord until focus moved on.
*/
export const isTextEntryTarget = (el: EventTarget | null): boolean =>
el instanceof HTMLElement &&
(el instanceof HTMLInputElement
? TEXT_INPUT_TYPES.has(el.type)
: el.tagName === "TEXTAREA" || el.isContentEditable === true);

/**
* Controls on which Space already has a meaning of its own: activating a
* button, toggling a checkbox or switch, opening a select or a disclosure.
Expand Down
43 changes: 18 additions & 25 deletions src/features/projects/components/ProjectSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { motion } from "framer-motion";
import { ChevronsUpDown, FolderKanban, ShieldCheck } from "lucide-react";
import { useProjectContext } from "../useProjectContext";
import { ProjectSwitcherModal } from "./ProjectSwitcherModal";
import { Badge } from "../../../components/ui/Badge";
import { ShortcutHint } from "../../../components/ui/ShortcutHint";
import { SWITCH_PROJECT_SHORTCUT, shortcutChord, useShortcutListener } from "../../shortcuts";
import { monogramLetters, monogramTint } from "../projectMonogram";
import { hoverSpringToken } from "../../../styles/tokens";

const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
const SWITCHER_CHORD = IS_MAC ? "⌘ + K" : "Ctrl + K";
// The one spelling of the chord: the registry defines it, the hint below and the listener
// both read it from there.
const SWITCHER_CHORD = shortcutChord(SWITCH_PROJECT_SHORTCUT);

type ProjectSwitcherProps = {
className?: string;
Expand All @@ -35,27 +37,16 @@ export function ProjectSwitcher({ className = "" }: ProjectSwitcherProps) {

const [isOpen, setIsOpen] = useState(false);

// Cmd/Ctrl+K opens the switcher from anywhere.
useEffect(() => {
if (!isSwitcherEnabled) return;
/**
* Cmd/Ctrl+K opens the switcher from anywhere. The chord, and what counts as a press,
* come from the shortcuts registry — the hint on the trigger below and this listener
* read one definition. The gate stays this component's own: with the switcher disabled
* there is nothing to open. A bare `new Event("keydown")` from a browser extension has
* no `code`, so the registry's matcher refuses it rather than throwing on it.
*/
const openSwitcher = useCallback(() => setIsOpen(true), []);

const handleShortcut = (event: KeyboardEvent) => {
// Synthetic "keydown" events (e.g. from browser extensions dispatching a
// bare `new Event("keydown")` on window) have an undefined `key`. Bail
// before calling a method on it — real KeyboardEvents always have a
// string `key`, so this never affects the Cmd/Ctrl+K shortcut.
if (typeof event.key !== "string") return;
if (event.key.toLowerCase() !== "k" || !(event.metaKey || event.ctrlKey)) {
return;
}

event.preventDefault();
setIsOpen(true);
};

window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, [isSwitcherEnabled]);
useShortcutListener(SWITCH_PROJECT_SHORTCUT, openSwitcher, isSwitcherEnabled);

if (!isSwitcherEnabled) {
return null;
Expand All @@ -74,9 +65,11 @@ export function ProjectSwitcher({ className = "" }: ProjectSwitcherProps) {
type="button"
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-label={`Switch project. Current project: ${triggerLabel}`}
// The chord belongs in the label, not only in `title`: `aria-label` wins the
// accessible-name computation, so a title alone would leave screen readers without it.
aria-label={`Switch project (${SWITCHER_CHORD}). Current project: ${triggerLabel}`}
title={`Switch project (${SWITCHER_CHORD})`}
onClick={() => setIsOpen(true)}
onClick={openSwitcher}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={hoverSpringToken}
Expand Down
15 changes: 15 additions & 0 deletions src/features/shortcuts/components/GlobalShortcuts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useGlobalShortcuts } from "../hooks/useGlobalShortcuts";
import { KeyboardShortcutsModal } from "./KeyboardShortcutsModal";

/**
* The shortcuts layer: one window listener for every app-wide chord, and the help it opens.
*
* Mounted once from the signed-in shell — chords are only worth having because they work
* without knowing where you are, which is the opposite of mounting a listener per page.
* Rendering nothing on its own: the help goes through `ui/Modal`'s portal.
*/
export function GlobalShortcuts() {
const { isHelpOpen, closeHelp } = useGlobalShortcuts();

return <KeyboardShortcutsModal isOpen={isHelpOpen} onClose={closeHelp} />;
}
Loading
Loading