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)testing
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".