skills/keyboard-shortcuts/SKILL.md
--- name: keyboard-shortcuts description: Wire a global keyboard shortcut handler with editable-element and modifier guards, plus a discoverable help dialog. Power-user signal that pays for itself in demos, screencasts, and portfolio respect. Use on any utility app with more than three primary actions. category: quality-review argument-hint: [--actions <file>] [--help-key ?] [--no-dialog] allowed-tools: Bash(*) Read Write Edit Glob Grep --- # Keyboard Shortcuts A small amount of keyboard wirin
npx skillsauth add RonanCodes/ronan-skills skills/keyboard-shortcutsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
A small amount of keyboard wiring changes how an app feels. Arrow keys to navigate, ? for a help overlay, bindings for the 3-5 most-common actions. Takes 30 minutes, reads as craft.
/ro:keyboard-shortcuts # wire defaults (arrows, ?, common verbs)
/ro:keyboard-shortcuts --help-key h # use 'h' instead of '?' for help
/ro:keyboard-shortcuts --no-dialog # skip the help overlay, just wire bindings
src/hooks/useKeyboardShortcuts.ts — single global handler with proper guards.src/components/ShortcutsDialog.tsx — Radix or headless dialog listing bindings.useKeyboardShortcuts(actions).Press ? for shortcuts.// src/hooks/useKeyboardShortcuts.ts
import { useEffect } from 'react'
export interface Shortcut {
key: string | string[] // e.g. 'ArrowLeft' or ['t', 'T']
description: string // shown in the help dialog
handler: (e: KeyboardEvent) => void
allowInInputs?: boolean // default false — skip if user typing
}
function isEditable(el: Element | null): boolean {
if (!el) return false
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if ((el as HTMLElement).isContentEditable) return true
return false
}
export function useKeyboardShortcuts(
shortcuts: Shortcut[],
opts?: { enabled?: boolean },
) {
useEffect(() => {
if (opts?.enabled === false) return
const handler = (e: KeyboardEvent) => {
// Never hijack browser shortcuts.
if (e.metaKey || e.ctrlKey || e.altKey) return
const shortcut = shortcuts.find((s) => {
const keys = Array.isArray(s.key) ? s.key : [s.key]
return keys.includes(e.key)
})
if (!shortcut) return
if (!shortcut.allowInInputs && isEditable(document.activeElement)) return
e.preventDefault()
shortcut.handler(e)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [shortcuts, opts?.enabled])
}
Why a single handler, not many addEventListener calls scattered around?
Use whatever dialog primitive the app already has (Radix, Headless UI, shadcn). The content is a static table from the same shortcuts array.
// src/components/ShortcutsDialog.tsx
import * as Dialog from '@radix-ui/react-dialog'
import type { Shortcut } from '@/hooks/useKeyboardShortcuts'
export function ShortcutsDialog({
open,
onOpenChange,
shortcuts,
}: {
open: boolean
onOpenChange: (v: boolean) => void
shortcuts: Shortcut[]
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<Dialog.Title className="text-lg font-semibold mb-4">
Keyboard shortcuts
</Dialog.Title>
<dl className="space-y-2">
{shortcuts.map((s) => (
<div key={s.description} className="flex justify-between gap-4">
<dt>{s.description}</dt>
<dd className="font-mono text-sm">
{(Array.isArray(s.key) ? s.key : [s.key]).map((k) => (
<kbd
key={k}
className="px-2 py-0.5 bg-gray-100 border border-gray-300 rounded"
>
{prettyKey(k)}
</kbd>
))}
</dd>
</div>
))}
</dl>
<Dialog.Close className="mt-4 text-sm text-gray-500">Close (Esc)</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
function prettyKey(k: string): string {
const map: Record<string, string> = {
ArrowLeft: '←',
ArrowRight: '→',
ArrowUp: '↑',
ArrowDown: '↓',
Escape: 'Esc',
Enter: '↵',
}
return map[k] ?? k
}
// src/App.tsx or src/routes/__root.tsx
import { useState } from 'react'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { ShortcutsDialog } from '@/components/ShortcutsDialog'
export function App() {
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const shortcuts = [
{ key: ['ArrowLeft', ','], description: 'Previous day', handler: goToPreviousDay },
{ key: ['ArrowRight', '.'], description: 'Next day', handler: goToNextDay },
{ key: ['t', 'T'], description: 'Go to today', handler: goToToday },
{ key: ['h', 'H'], description: 'Toggle hints', handler: toggleHints },
{ key: ['s', 'S'], description: 'Settings', handler: () => setSettingsOpen(true) },
{ key: '?', description: 'Show this help', handler: () => setShortcutsOpen(true) },
]
useKeyboardShortcuts(shortcuts)
return (
<>
{/* ...app */}
<ShortcutsDialog
open={shortcutsOpen}
onOpenChange={setShortcutsOpen}
shortcuts={shortcuts}
/>
</>
)
}
Do bind:
t for today, h for hint, s for settings, / for search focus).? or h for the help dialog — whichever your app doesn't already use.Esc for close modals / clear selection — usually handled by Radix out of the box, but verify.Don't bind:
Cmd+X / Ctrl+X combo. You'll clash with browser shortcuts (copy, save, new tab). Leave those alone.Cmd+← is browser back. Never preventDefault it./ traditionally focuses search — map your app's search to that instead of coming up with something novel.? is the de-facto help key. GitHub, Gmail, Linear, Notion all use it. Don't invent a new one.Press ? for shortcuts converts the feature from invisible to visible. Without it, nobody knows.Pairs with /ro:accessibility-ci. Keyboard shortcuts are one half of keyboard accessibility; the other half is standard tab-order navigation, visible focus rings, and aria-keyshortcuts:
<button aria-keyshortcuts="t" onClick={goToToday}>Today</button>
aria-keyshortcuts is announced by screen readers when they focus the button, so users with assistive tech discover your bindings.
Playwright is excellent for this — deterministic key events:
// tests/keyboard.spec.ts
test('arrow keys navigate days', async ({ page }) => {
await page.goto('/')
const originalDate = await page.locator('[data-test="puzzle-date"]').textContent()
await page.keyboard.press('ArrowLeft')
const newDate = await page.locator('[data-test="puzzle-date"]').textContent()
expect(newDate).not.toBe(originalDate)
})
test('shortcuts ignored in inputs', async ({ page }) => {
await page.goto('/')
await page.locator('[data-test="search-input"]').focus()
await page.keyboard.press('t')
// Should not have jumped to today — search value should be 't'
await expect(page.locator('[data-test="search-input"]')).toHaveValue('t')
})
keydown vs keypress. Use keydown. keypress is deprecated and doesn't fire for non-character keys like arrows.alert) in the setup path.document vs window. window is better — catches events even when focus is in an iframe you embed./ro:app-polish — umbrella; this is check #6/ro:accessibility-ci — complementary aria-keyshortcuts + focus-ring work/ro:posthog — track which shortcuts actually get used (feature_used with shortcut field)development
--- name: worktree description: Coordinate multiple agents on one repo via a worktree-lock pool, so two agents never clobber each other's working tree. Acquire the first free slot (main, then beta/gamma… worktrees, created on demand), work there on your own branch, release when you've pushed. Use before modifying any repo that might be in use by another agent (factory, dataforce, etc.), or whenever you're told a repo is being worked on. Backed by `ro worktree`. category: development argument-hin
testing
--- name: ship description: Ship a feature branch the local-CI-first way — run the full local gate, push, open a PR, squash-merge, then deploy, without waiting on GitHub Actions. Use when a branch is ready for main and you want it merged and deployed now. Reads CI policy from `ro ci` (default skips remote CI because GitHub Actions billing keeps hitting limits). Sibling to /ro:gh-ship (waits on GitHub checks) and /ro:cf-ship (the deploy half). Triggers on "ship it", "ship this", "merge and deploy
testing
--- name: setup-logging description: Set up (or audit) the observability stack in a TanStack Start + Cloudflare Workers app so it is "diagnosable by default" — structured logging (logtape) with a request context carrying trace_id + userId + tenant/orgId, a trace_id propagated FE→BE→logs→Sentry→PostHog, Cloudflare Workers observability enabled, and Sentry + PostHog wired. Two modes: `setup` (wire it into an app) and `audit` (check an existing app + report gaps). Use when scaffolding a new app, wh
development
Manage credentials INSIDE the active ~/.claude/.env file — read which token/account to use for a given app (Simplicity vs Dataforce vs Ronan-personal), add or update a secret WITHOUT it passing through the chat (an interactive Terminal window prompts for it), and track secrets that were exposed in a transcript so they get rotated. Sibling to /ro:context (which switches WHICH env file is active). Use when the user wants to add an API key/token/secret, asks "which credential do I use for X", needs the env organized/labelled, or a secret was pasted into the chat and should be rotated.