diff --git a/DESIGN.md b/DESIGN.md index c1cd4e1..adff4ad 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -163,6 +163,11 @@ components: textColor: "{colors.on-accent}" rounded: "{rounded.pill}" size: "46px" + keycap: + backgroundColor: "{colors.sunk}" + textColor: "{colors.ink}" + rounded: "{rounded.sm}" + padding: "4px 6px" --- # Design System: Radial @@ -376,6 +381,15 @@ goal on a project's list, a request everywhere else — and on a smart list it i Its menu is generated from the space's own registry records, so what it offers is data rather than markup. `⌘N` is the same button. +### The keyboard map +`/` puts the caret in quick find, `⌘N` is the ⊕ of the view being shown, Escape closes one layer per +press — and `?` draws the map itself, over whatever is already open. It is the picker's paper card on +the same scrim: a group heading per surface, a sans keycap per chord (`Esc` is a label printed on a key, +not a machine identifier, so keycaps are never mono), and one plain sentence beside each. The pane bar +carries a keyboard button next to the appearance toggle, titled with the key it stands for — a shortcut +nobody can find is a shortcut nobody has. While the map is up it holds the keyboard: nothing behind it +answers a key, and Escape takes the map away before it takes anything else. + ### The URI chip Mono, 11.5px, sunk, 4px radius, `cursor: copy`, flashing sage on copy. It is how a record's identity stays available without a record ever being the headline. diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 5803894..76d971c 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -829,6 +829,39 @@ select.sl { resize: none; cursor: pointer; padding-right: 8px; } box-shadow: 0 2px 6px oklch(0.25 0.03 262 / 0.10), 0 24px 60px -14px oklch(0.25 0.03 262 / 0.4); } +/* ─── keyboard help ──────────────────────────────────────────────────────── */ +/* The map, drawn (`keys.ts` holds the table it draws). It is the same paper card on the same scrim + as the picker, one layer above it, because it is opened over whatever is already up — including + the picker. The keycaps are sans, not mono: `Esc` and `Home` are labels printed on a key, and mono + in this system means a machine identifier. */ +.keys-scrim { z-index: 60; } +.keys-card { + width: min(520px, 100%); background: var(--paper); + border: 1px solid var(--line); border-radius: 13px; padding: 20px 24px 24px; + box-shadow: 0 2px 6px oklch(0.25 0.03 262 / 0.10), 0 24px 60px -14px oklch(0.25 0.03 262 / 0.4); + text-align: left; +} +.keys-head { display: flex; align-items: center; gap: 12px; } +.keys-head h2 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.012em; } +.keys-head .btn { margin-left: auto; flex: none; padding: 5px 11px; font-size: 12.5px; } +.keys-group { + margin: 20px 0 0; padding-bottom: 5px; border-bottom: 1px solid var(--line-soft); + font-size: 12px; font-weight: 700; color: var(--ink-2); +} +.keys-when { margin: 7px 0 0; font-size: 12px; line-height: 1.5; color: var(--ink-2); } +.keys-list { + margin: 11px 0 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); + gap: 9px 14px; align-items: baseline; +} +.keys-list dt { display: flex; align-items: center; gap: 5px; } +.keys-list dd { margin: 0; font-size: 13px; line-height: 1.5; color: var(--ink-2); } +.keys-list kbd { + font: 500 12px/1 var(--sans); color: var(--ink); white-space: nowrap; + background: var(--sunk); border: 1px solid var(--line); border-bottom-color: var(--stroke); + border-radius: 5px; padding: 4px 6px; +} +.keys-or { font-size: 11px; color: var(--ink-2); } + /* ─── skeleton ───────────────────────────────────────────────────────────── */ /* The shape of a space, while it is being read. Every bar is the same neutral the app already uses for a sunk surface — a skeleton that shimmers in the accent would be the loading state advertising @@ -950,6 +983,9 @@ select.sl { resize: none; cursor: pointer; padding-right: 8px; } there is no disc, so the word stays — it is the only thing that would say what the button does. */ .acct-btn.with-disc .who-name { display: none; } .acct-pop { width: min(292px, calc(100vw - 32px)); } + /* A viewport this narrow is a phone, and a phone has no keys to map. `?` still opens it for a + keyboard that happens to be attached; what goes is the button advertising it. */ + .keys-btn { display: none; } } @media (prefers-reduced-motion: reduce) { diff --git a/packages/ui/src/lib/components/PaneBar.svelte b/packages/ui/src/lib/components/PaneBar.svelte index 8db8fbd..4f37d82 100644 --- a/packages/ui/src/lib/components/PaneBar.svelte +++ b/packages/ui/src/lib/components/PaneBar.svelte @@ -89,6 +89,23 @@ + + + + + + {#each SHORTCUTS as group (group.title)} +

{group.title}

+ {#if group.when}

{group.when}

{/if} +
+ {#each group.items as item (item.keys.join('/'))} +
+ {#each item.keys as chord, index (chord)} + {#if index > 0}or{/if}{chord} + {/each} +
+
{item.what}
+ {/each} +
+ {/each} + + +{/if} diff --git a/packages/ui/src/lib/keys.test.ts b/packages/ui/src/lib/keys.test.ts index 2bed077..9a7c72e 100644 --- a/packages/ui/src/lib/keys.test.ts +++ b/packages/ui/src/lib/keys.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { compose, openDraft } from './compose.svelte.js' -import { onKeydown, registerPrimary, registerQuickFind, type KeyPress } from './keys.js' +import { onKeydown, registerPrimary, registerQuickFind, SHORTCUTS, type KeyPress } from './keys.js' import { session } from './session.svelte.js' import { ui } from './ui.svelte.js' @@ -24,6 +24,7 @@ beforeEach(() => { ui.query = '' ui.accountOpen = false ui.menuOpen = false + ui.helpOpen = false session.picking = false compose.draft = undefined registerQuickFind(quickFind) @@ -81,6 +82,42 @@ describe('⌘N', () => { }) }) +describe('?', () => { + it('shows the map, and shows it away again', () => { + onKeydown(press('?').event) + expect(ui.helpOpen).toBe(true) + onKeydown(press('?').event) + expect(ui.helpOpen).toBe(false) + }) + + it('is a question mark when a question is being asked', () => { + onKeydown(press('?', { target: field('TEXTAREA') }).event) + expect(ui.helpOpen).toBe(false) + }) + + it('leaves ⌘? to the browser', () => { + const { event, preventDefault } = press('?', { metaKey: true }) + onKeydown(event) + expect(ui.helpOpen).toBe(false) + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('holds the keyboard while it is up — nothing behind it answers', () => { + const primary = vi.fn() + registerPrimary(primary) + ui.helpOpen = true + onKeydown(press('/').event) + onKeydown(press('n', { metaKey: true }).event) + expect(quickFind.focus).not.toHaveBeenCalled() + expect(primary).not.toHaveBeenCalled() + }) + + it('lists the keys the map actually answers to', () => { + const listed = SHORTCUTS.flatMap((group) => group.items).flatMap((item) => item.keys) + for (const chord of ['/', '⌘N', '?', 'Esc']) expect(listed).toContain(chord) + }) +}) + describe('Escape', () => { it('leaves the field rather than throwing away what is in it', () => { const brief = field('TEXTAREA') @@ -109,6 +146,11 @@ describe('Escape', () => { ui.menuOpen = true ui.accountOpen = true session.picking = true + ui.helpOpen = true + + onKeydown(press('Escape').event) + expect(ui.helpOpen).toBe(false) + expect(session.picking).toBe(true) onKeydown(press('Escape').event) expect(session.picking).toBe(false) diff --git a/packages/ui/src/lib/keys.ts b/packages/ui/src/lib/keys.ts index 74e1c71..68b5d83 100644 --- a/packages/ui/src/lib/keys.ts +++ b/packages/ui/src/lib/keys.ts @@ -11,8 +11,13 @@ // // / focus quick find // ⌘N/^N the ⊕ of the view being shown — a goal on a project's list, a request everywhere else +// ? show the map itself // Esc leave the field, or close the topmost thing that is open // +// The third of those is why `SHORTCUTS` sits here rather than in the component that draws it: a list +// of keys maintained anywhere but beside the handlers is a list that goes stale the first time a +// binding moves. The table below is the one the reader sees, next to the code it describes. +// // Nothing here reaches for the DOM: `isField` reads a tag name and the two registries hold whatever // was handed to them, so the whole map is exercised in `keys.test.ts` without a browser. @@ -52,6 +57,49 @@ export function registerPrimary(action: () => void): () => void { } } +/** One line of the map: what to press, and what it does. */ +export interface Shortcut { + /** The chords that do it — alternatives, drawn as chips and read as "or". */ + keys: readonly string[] + what: string +} + +export interface ShortcutGroup { + title: string + /** When the group only applies somewhere in particular, said once rather than on every line. */ + when?: string + items: readonly Shortcut[] +} + +/** + * The map as a human reads it. The first group is this file; the second is the rail's grip, which + * handles its own keys because they only exist while it has focus (`RailResizer.svelte`) — a + * reader does not care which module a key lives in, so both are listed. + */ +export const SHORTCUTS: readonly ShortcutGroup[] = [ + { + title: 'Anywhere', + items: [ + { keys: ['/'], what: 'Put the caret in quick find, which filters the view you are on' }, + { + keys: ['⌘N', 'Ctrl N'], + what: 'Make an item of this list — a goal on a project, a request everywhere else. A smart list is somebody else’s list, so it has no ⊕ and no ⌘N', + }, + { keys: ['?'], what: 'Show this list' }, + { keys: ['Esc'], what: 'Leave the field, or close the topmost thing that is open' }, + ], + }, + { + title: 'The rail’s edge', + when: 'While the grip between the rail and the pane has focus.', + items: [ + { keys: ['←', '→'], what: 'Move the edge 8 pixels — 32 with Shift held' }, + { keys: ['Home', 'End'], what: 'Take it to its narrowest, or to its widest' }, + { keys: ['Enter'], what: 'Put it back to the width it started at' }, + ], + }, +] + const FIELDS = new Set(['INPUT', 'TEXTAREA', 'SELECT']) /** Text is being typed into this, so the letter keys belong to it and not to the app. */ @@ -63,8 +111,15 @@ const isField = (target: unknown): boolean => { /** * One layer, topmost first. A compose card sits *below* the menu that opened it and above the quick * find query, which is the order they were opened in and the order a reader expects to unwind them. + * The help is above all of it: it is opened over whatever is already up, so it is the first thing + * Escape takes away — closing the card underneath it and leaving the map on screen would be the app + * answering a press the reader could not see the effect of. */ function dismiss(): boolean { + if (ui.helpOpen) { + ui.helpOpen = false + return true + } if (session.picking) { session.picking = false return true @@ -107,6 +162,19 @@ export function onKeydown(event: KeyPress): void { if (field) return + // `?` is Shift+/ on most layouts, so it is asked about before `/` — and it toggles, because the + // way out of a list of keys should be the key that opened it as much as it is Escape. ⌘? is the + // browser's own help on a Mac and not this app's business. + if (event.key === '?' && event.metaKey !== true && event.ctrlKey !== true) { + event.preventDefault() + ui.helpOpen = !ui.helpOpen + return + } + + // The map is drawn over everything, so while it is up it is what the reader is reading rather than + // what they are driving: `/` behind it would put the caret in a box under a scrim. + if (ui.helpOpen) return + if (event.key === '/') { event.preventDefault() quickFind?.focus() diff --git a/packages/ui/src/lib/ui.svelte.ts b/packages/ui/src/lib/ui.svelte.ts index 65d9c52..07d81b0 100644 --- a/packages/ui/src/lib/ui.svelte.ts +++ b/packages/ui/src/lib/ui.svelte.ts @@ -10,6 +10,8 @@ export const ui = $state({ accountOpen: false, /** The ⊕ menu. Shared because ⌘N opens it from `keys.ts` and Escape closes it from there too. */ menuOpen: false, + /** The keyboard map, drawn over everything. `?` opens it and the pane bar has a button for it. */ + helpOpen: false, }) export function closeMenu(): void { diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 892cfed..49f0d18 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -13,6 +13,7 @@ import PaneBar from '$lib/components/PaneBar.svelte' import PlusMenu from '$lib/components/PlusMenu.svelte' import Rail from '$lib/components/Rail.svelte' + import Shortcuts from '$lib/components/Shortcuts.svelte' import Skeleton from '$lib/components/Skeleton.svelte' import SpacePicker from '$lib/components/SpacePicker.svelte' import Toast from '$lib/components/Toast.svelte' @@ -206,3 +207,7 @@ {/if} + + +