Public: anyone can read this; the crew can change it.
Every action that has a keyboard shortcut shows its key beside it #8895
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8854-every-action-that-has-a-keyboard-shortcu → main 8 commits fork 4 threads · 4 unresolved
11 files
changed +108
−19
docs/design/areas/palette.md +4
| @@ -11,3 +11,7 @@ The command palette is the keyboard-first fuzzy-find overlay (`⌘K` / `Ctrl K`, | ||
| 11 | 11 | - `.palette-foot` — the faint one-line strip of key hints under the list, set off by a rule above it. |
| 12 | 12 | |
| 13 | 13 | The palette's tell row (`data-tell`, last in the list) sends the typed sentence to `/tell`. Its answer is one `.toast[data-palette-tell]` at the top of `#main`, drawn by `tellFromHere()` in `src/web/client.ts` from existing parts only: `.toast` and `.toast-body`, a link-variant small `.btn` as the dismiss ×, and focus moved to the toast (`tabindex="-1"`, `role="status"`) because the palette that held it has just closed. It clears itself after 12 seconds. No new component class. |
| 14 | ||
| 15 | ## Keys beside their actions | |
| 16 | ||
| 17 | `.keyhint` is the `<kbd>` of an action's shortcut (`keyHint()` in `ui.ts`); `btn`, `tabs`, menu links, the top bar and the review verdicts render it for every `data-shortcut`. It is hidden on phone widths and on touch-only screens (`pointer: coarse`), where there is no keyboard. |
src/web/chrome.ts +2 −2
| @@ -34,7 +34,7 @@ import { | ||
| 34 | 34 | type RepoScope, |
| 35 | 35 | } from "./scope.ts"; |
| 36 | 36 | import { repoSteps, TAB_STEPS, type RepoTab } from "./repo_steps.ts"; |
| 37 | import { badge, btn, empty, pill, tabs, type TabItem } from "./ui.ts"; | |
| 37 | import { badge, btn, empty, keyHint, pill, tabs, type TabItem } from "./ui.ts"; | |
| 38 | 38 | |
| 39 | 39 | // ---- loading ---- |
| 40 | 40 | |
| @@ -219,7 +219,7 @@ function repoNav(items: TabItem[]): Html { | ||
| 219 | 219 | <summary class="btn" data-variant="ghost" data-size="sm">More</summary> |
| 220 | 220 | <div class="menu-panel">${rest.map( |
| 221 | 221 | (t) => |
| 222 | html`<a class="menu-item" href="${t.href}"${t.shortcut ? html` data-shortcut="${t.shortcut}"` : null}>${t.label}${t.count !== undefined && t.count > 0 ? pill(t.count) : null}</a>`, | |
| 222 | html`<a class="menu-item" href="${t.href}"${t.shortcut ? html` data-shortcut="${t.shortcut}"` : null}>${t.label}${t.count !== undefined && t.count > 0 ? pill(t.count) : null}${t.shortcut ? keyHint(t.shortcut) : null}</a>`, | |
| 223 | 223 | )}</div> |
| 224 | 224 | </details>`; |
| 225 | 225 | return html`<div class="repo-nav">${tabs(shown, "Repository")}${more}</div>`; |
src/web/layout.ts +6 −5
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * `/search`), and every dialog (a link to a real page). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { NEW_ISSUE_KEY } from "./script_keys.ts"; | |
| 10 | 11 | import { newIssueHref } from "./issue_from.ts"; |
| 11 | 12 | import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts"; |
| 12 | 13 | import { protectionNotice } from "../kernel/protection.ts"; |
| @@ -56,7 +57,7 @@ import { html, raw, when, type Html } from "./html.ts"; | ||
| 56 | 57 | import { noRoom } from "../spawn/door.ts"; |
| 57 | 58 | import { quotaFull } from "../spawn/quota.ts"; |
| 58 | 59 | import { icon, inlineIcons, pointAtSprite, themeGlyph } from "./icons.ts"; |
| 59 | import { btn, copyField, kbd, menu, type MenuItem } from "./ui.ts"; | |
| 60 | import { btn, copyField, kbd, keyHint, menu, type MenuItem } from "./ui.ts"; | |
| 60 | 61 | import { SITE_LINE } from "./site_line.ts"; |
| 61 | 62 | |
| 62 | 63 | /** One row of the command palette: title, href, category. */ |
| @@ -194,7 +195,7 @@ const helpItem = html`<button class="menu-item js-only hide-sm" type="button" da | ||
| 194 | 195 | const newIssueItem = (ctx: Ctx): MenuItem[] => |
| 195 | 196 | ctx.url.pathname.startsWith("/origins/") |
| 196 | 197 | ? [] |
| 197 | : [{ label: "New issue", href: newIssueHref(ctx.url.pathname) }]; | |
| 198 | : [{ label: "New issue", href: newIssueHref(ctx.url.pathname), hint: NEW_ISSUE_KEY }]; | |
| 198 | 199 | |
| 199 | 200 | function guestMenu(ctx: Ctx, theme: ThemeName | undefined): Html { |
| 200 | 201 | return menu({ |
| @@ -565,7 +566,7 @@ function topbar( | ||
| 565 | 566 | return html`<a class="skip" href="#main">Skip to content</a> |
| 566 | 567 | <header class="topbar" role="banner"> |
| 567 | 568 | <a href="/" class="wordmark" data-shortcut="g h" |
| 568 | ><span class="mark"></span>forge</a | |
| 569 | ><span class="mark"></span>forge${keyHint("g h")}</a | |
| 569 | 570 | > |
| 570 | 571 | ${when( |
| 571 | 572 | crumbs, |
| @@ -585,8 +586,8 @@ function topbar( | ||
| 585 | 586 | ${searchBar()} |
| 586 | 587 | ${user |
| 587 | 588 | ? html`<a class="btn btn-icon" href="/talk" aria-label="Talk to the forge" title="Talk to the forge" data-shortcut="g t" |
| 588 | >${icon("comment")}</a | |
| 589 | ><a class="small" href="/asks" data-shortcut="g q">Asks</a | |
| 589 | >${icon("comment")}${keyHint("g t")}</a | |
| 590 | ><a class="small" href="/asks" data-shortcut="g q">Asks${keyHint("g q")}</a | |
| 590 | 591 | >${newMenu(ctx)}${userMenu( |
| 591 | 592 | ctx, |
| 592 | 593 | user, |
src/web/pages/pull.ts +2 −1
| @@ -92,6 +92,7 @@ import { | ||
| 92 | 92 | card, |
| 93 | 93 | field as formField, |
| 94 | 94 | icon, |
| 95 | keyHint, | |
| 95 | 96 | labelCss, |
| 96 | 97 | queuedPhrase, |
| 97 | 98 | row, |
| @@ -772,7 +773,7 @@ function reviewCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html { | ||
| 772 | 773 | ${i === options.length - 1 ? raw("checked") : null} |
| 773 | 774 | /> |
| 774 | 775 | <span |
| 775 | >${option.label} <span class="hint">${option.hint}</span></span | |
| 776 | >${option.label} ${keyHint(option.key)} <span class="hint">${option.hint}</span></span | |
| 776 | 777 | > |
| 777 | 778 | </label>`, |
| 778 | 779 | )} |
src/web/script_keys.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * The keys the page scripts bind themselves, with no `data-shortcut` on the | |
| 3 | * element (those declare their own). One list: the feed script reads its key | |
| 4 | * here (the shell's `C` is a literal in client.ts, which test/help-keys.test.ts | |
| 5 | * parses; test/web-ui.test.ts pins it to this list). The pages draw | |
| 6 | * `keyHint(key)` beside the action, and the test walks the list and fails on a page whose action shows no hint. | |
| 7 | */ | |
| 8 | ||
| 9 | export interface ScriptKey { | |
| 10 | key: string; | |
| 11 | /** What it does, as the help overlay and the test name it. */ | |
| 12 | action: string; | |
| 13 | /** A page that carries the action, with `{repo}` standing for an `owner/name` the viewer can reach. */ | |
| 14 | page: string; | |
| 15 | /** The text of the element that must show the key beside it. */ | |
| 16 | label: string; | |
| 17 | } | |
| 18 | ||
| 19 | /** Dismiss the focused feed card. */ | |
| 20 | export const DISMISS_KEY = "x"; | |
| 21 | /** | |
| 22 | * New issue from the page, on any page. `c` does it too where the page has no | |
| 23 | * box of its own (there it focuses the box), so the hint names the one key that | |
| 24 | * always works. | |
| 25 | */ | |
| 26 | export const NEW_ISSUE_KEY = "C"; | |
| 27 | ||
| 28 | export const SCRIPT_KEYS: readonly ScriptKey[] = [ | |
| 29 | { key: DISMISS_KEY, action: "Dismiss a feed card", page: "/feed", label: "Dismiss" }, | |
| 30 | { key: NEW_ISSUE_KEY, action: "New issue from this page", page: "/apps", label: "New issue" }, | |
| 31 | ]; |
src/web/studio/feed.ts +4 −3
| @@ -25,10 +25,11 @@ import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts"; | ||
| 25 | 25 | import { mayFetchLook, pictured } from "../pages/looks.ts"; |
| 26 | 26 | import { dividers } from "./day_divider.ts"; |
| 27 | 27 | import { html, type Html } from "../html.ts"; |
| 28 | import { DISMISS_KEY } from "../script_keys.ts"; | |
| 28 | 29 | import { fragment } from "../layout.ts"; |
| 29 | 30 | import { draftJs } from "../draft.ts"; |
| 30 | 31 | import { outboxJs } from "../outbox.ts"; |
| 31 | import { avatar, btn, card, disclosure, empty, nameMark, row, stateBadge, time } from "../ui.ts"; | |
| 32 | import { avatar, btn, card, disclosure, empty, keyHint, nameMark, row, stateBadge, time } from "../ui.ts"; | |
| 32 | 33 | import { stepLink } from "./step_link.ts"; |
| 33 | 34 | import { writableAppsOf } from "./asks.ts"; |
| 34 | 35 | |
| @@ -169,7 +170,7 @@ export function feedCard( | ||
| 169 | 170 | <span class="bar">${btn(target ? "Steer" : "Tell", reply ? { size: "sm" } : { size: "sm", variant: "primary" })}</span> |
| 170 | 171 | </form> |
| 171 | 172 | <span class="bar"> |
| 172 | <button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="dismissed" title="Dismiss (x)">Dismiss</button> | |
| 173 | <button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="dismissed" title="Dismiss (${DISMISS_KEY})">Dismiss ${keyHint(DISMISS_KEY)}</button> | |
| 173 | 174 | ${item.kind === "ask" || item.kind === "milestone" || item.kind === "choose" ? null : html`<button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="muted" title="Mute ${item.app}">Mute app</button>`} |
| 174 | 175 | </span>`, |
| 175 | 176 | { small: true, label: target ? "Steer, dismiss or mute" : reply ? "Reply, tell the crew, dismiss or mute" : "Tell the crew, dismiss or mute" }, |
| @@ -642,7 +643,7 @@ ${dayKeepJs} | ||
| 642 | 643 | outbox.flush().then(function (r) { if (r.refused && draft) draft.refused(text); }); |
| 643 | 644 | }); |
| 644 | 645 | document.addEventListener('keydown', function (e) { |
| 645 | if (e.key !== 'x' || e.metaKey || e.ctrlKey || e.altKey) return; | |
| 646 | if (e.key !== '${DISMISS_KEY}' || e.metaKey || e.ctrlKey || e.altKey) return; | |
| 646 | 647 | var a = document.activeElement; |
| 647 | 648 | if (!a || !a.matches || !a.matches('[data-item]') || !feed.contains(a)) return; |
| 648 | 649 | e.preventDefault(); |
src/web/style/components.ts +3
| @@ -278,6 +278,9 @@ kbd { | ||
| 278 | 278 | font-family: var(--mono); font-size: var(--fs-min); font-weight: 500; color: var(--muted-fg); |
| 279 | 279 | } |
| 280 | 280 | |
| 281 | .keyhint { margin-left: 6px; } | |
outdated
white-space: pre is unexplained; remove it or say why it is needed. |
||
| 282 | @media (pointer: coarse), (max-width: 640px) { .keyhint { display: none; } } | |
| 283 | ||
| 281 | 284 | .sha { |
| 282 | 285 | font-family: var(--mono); font-size: 12.5px; padding: 1px 5px; |
| 283 | 286 | border-radius: 4px; background: var(--muted); color: var(--muted-fg); |
src/web/ui.ts +11 −3
| @@ -194,7 +194,7 @@ function btnAttrs(o: BtnOptions): Html { | ||
| 194 | 194 | |
| 195 | 195 | /** A button or a link that looks like one. Content is the visible label. */ |
| 196 | 196 | export function btn(content: Html | string, o: BtnOptions = {}): Html { |
| 197 | const inner = html`${o.icon ? icon(o.icon) : null}${content}`; | |
| 197 | const inner = html`${o.icon ? icon(o.icon) : null}${content}${o.shortcut ? keyHint(o.shortcut) : null}`; | |
| 198 | 198 | if (o.href !== undefined) { |
| 199 | 199 | return html`<a |
| 200 | 200 | href="${o.href}" |
| @@ -756,6 +756,12 @@ export function kbd(keys: string[]): Html { | ||
| 756 | 756 | return html`${keys.map((k) => html`<kbd>${k}</kbd>`)}`; |
| 757 | 757 | } |
| 758 | 758 | |
| 759 | /** The keys of a `g i`-style shortcut, shown beside the action that carries | |
| 760 | * it; phone widths and touch-only screens hide it (`.keyhint`). */ | |
| 761 | export function keyHint(shortcut: string): Html { | |
| 762 | return html`<kbd class="keyhint">${shortcut}</kbd>`; | |
| 763 | } | |
| 764 | ||
| 759 | 765 | // ---- identity ---- |
| 760 | 766 | |
| 761 | 767 | /** Twelve stable buckets, so the same name always wears the same hue. */ |
| @@ -991,7 +997,7 @@ export function tabs(items: TabItem[], ariaLabel: string): Html { | ||
| 991 | 997 | >${t.icon ? icon(t.icon) : null}${t.label}${t.count !== undefined && |
| 992 | 998 | t.count > 0 |
| 993 | 999 | ? pill(t.count) |
| 994 | : null}</a | |
| 1000 | : null}${t.shortcut ? keyHint(t.shortcut) : null}</a | |
| 995 | 1001 | >`, |
| 996 | 1002 | )} |
| 997 | 1003 | </nav>`; |
| @@ -1061,6 +1067,8 @@ export interface MenuItem { | ||
| 1061 | 1067 | post?: string; |
| 1062 | 1068 | /** Shown but not usable: the reason, in words, under the label. */ |
| 1063 | 1069 | disabled?: string; |
| 1070 | /** The key that does the same, drawn beside the label. */ | |
| 1071 | hint?: string; | |
| 1064 | 1072 | } |
| 1065 | 1073 | |
| 1066 | 1074 | /** A dropdown built on `<details>` — the only construct correct with JS off. */ |
| @@ -1116,7 +1124,7 @@ export function menu(o: { | ||
| 1116 | 1124 | href="${i.href ?? "#"}" |
| 1117 | 1125 | ${i.current ? raw('aria-current="true"') : null} |
| 1118 | 1126 | >${i.icon ? icon(i.icon) : null}<span class="grow">${i.label}</span |
| 1119 | >${i.current ? icon("check") : null}</a | |
| 1127 | >${i.hint ? keyHint(i.hint) : null}${i.current ? icon("check") : null}</a | |
| 1120 | 1128 | >`; |
| 1121 | 1129 | })} |
| 1122 | 1130 | </div> |
test/home-feed.test.ts +1 −1
| @@ -453,7 +453,7 @@ describe("home is the feed", () => { | ||
| 453 | 453 | expect(home).not.toContain("things need you"); |
| 454 | 454 | expect(home).not.toContain("data-pick"); |
| 455 | 455 | expect(home).toContain('href="/asks"'); |
| 456 | const link = home.match(/<a[^>]*href="\/asks"[^>]*>([^<]*)<\/a\s*>/); | |
| 456 | const link = home.match(/<a[^>]*href="\/asks"[^>]*>([^<]*)(?:<kbd class="keyhint">.*?<\/kbd>)?<\/a\s*>/s); | |
| 457 | 457 | expect(link).not.toBeNull(); |
| 458 | 458 | expect(link![1]!.trim()).toBe("Asks"); |
| 459 | 459 | // Nothing after the link, up to the next tag, is a number. |
test/repo-head-quiet.test.ts +4 −4
| @@ -14,11 +14,11 @@ afterAll(async () => { | ||
| 14 | 14 | }); |
| 15 | 15 | |
| 16 | 16 | // Bytes on the snappy fixture as the owner: [before the head folded its rare steps away, measured now]. |
| 17 | // The budget is the measurement plus a few bytes for port digits, not room, plus the menu's "New issue" link (#8855), about 320 bytes on each. | |
| 17 | // The budget is the measurement plus a few bytes for port digits, not room. The shortcut keys beside tabs, the top bar and the menu's New issue (#8854) are the whole of the ~315 bytes added on each page: eleven `<kbd class="keyhint">` of ~30 bytes, shown on a pointer-fine desktop and hidden by CSS elsewhere. | |
| 18 | 18 | const BYTES: Record<string, { before: number; budget: number }> = { |
| 19 | "": { before: 25_929, budget: 25_190 }, | |
| 20 | "/issues": { before: 51_705, budget: 50_980 }, | |
| 21 | "/pulls": { before: 25_151, budget: 24_420 }, | |
| 19 | "": { before: 25_929, budget: 25_540 }, | |
| 20 | "/issues": { before: 51_705, budget: 51_330 }, | |
| 21 | "/pulls": { before: 25_151, budget: 24_760 }, | |
| 22 | 22 | }; |
outdated
Loosening the byte budgets to fit the change defeats them. Trim the hints from the head, or justify the growth. |
||
| 23 | 23 | |
| 24 | 24 | const get = async (path: string): Promise<string> => |
test/web-ui.test.ts +40
| @@ -14,6 +14,9 @@ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; | ||
| 14 | 14 | import { tmpdir } from "node:os"; |
| 15 | 15 | import { join } from "node:path"; |
| 16 | 16 | import { createApp, type App } from "../src/app.ts"; |
| 17 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 18 | import { getUserByName } from "../src/core/users.ts"; | |
| 19 | import { NEW_ISSUE_KEY, SCRIPT_KEYS } from "../src/web/script_keys.ts"; | |
| 17 | 20 | import { getRepo } from "../src/core/repos.ts"; |
| 18 | 21 | import { listLabels, labelsFor, getIssue } from "../src/core/issues.ts"; |
| 19 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -958,6 +961,43 @@ describe("web ui", () => { | ||
| 958 | 961 | expect(bad.loc.href).toBe(href); |
| 959 | 962 | }); |
| 960 | 963 | |
| 964 | test("every action bound to a key shows that key beside its label", async () => { | |
| 965 | const pull = `/mel/proj/pulls/${pullNumber}`; | |
| 966 | const paths = ["/", "/mel/proj", "/mel/proj/issues", "/mel/proj/issues/1", pull, `${pull}/files`, "/mel/proj/files", "/apps", "/asks", "/system"]; | |
| 967 | let seen = 0; | |
| 968 | for (const path of paths) { | |
| 969 | const html = await (await fetch(`${base}${path}`, { headers: { cookie: owner } })).text(); | |
| 970 | // The element a binding sits on, and for a radio the label around it. | |
| 971 | for (const m of html.matchAll(/<(a|button|span|input)\b[^>]*\bdata-shortcut="([^"]+)"[^>]*>/g)) { | |
| 972 | const chord = m[2]!; | |
| 973 | const from = m[1] === "input" ? html.lastIndexOf("<label", m.index) : m.index; | |
| 974 | const close = m[1] === "input" ? "</label>" : `</${m[1]}>`; | |
| 975 | const el = html.slice(from, html.indexOf(close, m.index)); | |
| 976 | if (!el.includes(`<kbd class="keyhint">${chord}</kbd>`) && !chord.split(" ").every((k) => el.includes(`<kbd>${k}</kbd>`))) { | |
| 977 | throw new Error(`${path}: "${chord}" has no <kbd> beside its label`); | |
| 978 | } | |
| 979 | seen++; | |
| 980 | } | |
| 981 | } | |
| 982 | expect(seen).toBeGreaterThan(10); | |
outdated
This check is conditional, so it passes when the feed has no cards. Seed a card and assert unconditionally; walk a declared list of script-bound keys (including C/c), not only data-shortcut attributes. |
||
| 983 | // The keys the page scripts bind without a data-shortcut are declared in | |
| 984 | // one list; each page that carries the action must show the key beside it. | |
| 985 | // A card is seeded so the feed always has its Dismiss to check. | |
| 986 | const mel = (getUserByName(app.db, "mel"))!; | |
| 987 | const proj = (await getRepo(app.db, "mel", "proj"))!; | |
| 988 | const seeded = openIssue(app, mel, proj, { title: "dismiss me" }); | |
| 989 | if (!seeded.ok) throw new Error(seeded.error); | |
| 990 | expect(assignIssue(app, mel, proj, seeded.value, mel).ok).toBe(true); | |
| 991 | expect(shellJs).toContain(`e.key === '${NEW_ISSUE_KEY}'`); | |
| 992 | for (const k of SCRIPT_KEYS) { | |
| 993 | const page = await (await fetch(`${base}${k.page}`, { headers: { cookie: `${owner}; forge_mode=studio` } })).text(); | |
| 994 | const hint = `<kbd class="keyhint">${k.key}</kbd>`; | |
| 995 | const at = page.indexOf(hint); | |
| 996 | const near = at < 0 ? "" : page.slice(Math.max(0, at - 400), at); | |
| 997 | if (!near.includes(k.label)) throw new Error(`${k.page}: "${k.label}" shows no ${k.key} beside it`); | |
| 998 | } | |
| 999 | }); | |
| 1000 | ||
| 961 | 1001 | test("the composer, the verdicts and the settings sections carry their keys", async () => { |
| 962 | 1002 | const as = async (path: string, cookie = "") => |
| 963 | 1003 | ( |
new above
offline
Use keyHint("x") instead of hand-written kbd markup so the hint has one source.