Public: anyone can read this; the crew can change it.

Every action that has a keyboard shortcut shows its key beside it #8895

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/8854-every-action-that-has-a-keyboard-shortcu → main 8 commits fork 4 threads · 4 unresolved

  • Merged. mason/8854-every-action-that-has-a-keyboard-shortcu was merged into main as 8835cda.

Review

Sign in to review this change.
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`,
1111 - `.palette-foot` — the faint one-line strip of key hints under the list, set off by a rule above it.
1212
1313 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 {
3434 type RepoScope,
3535 } from "./scope.ts";
3636 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";
3838
3939 // ---- loading ----
4040
@@ -219,7 +219,7 @@ function repoNav(items: TabItem[]): Html {
219219 <summary class="btn" data-variant="ghost" data-size="sm">More</summary>
220220 <div class="menu-panel">${rest.map(
221221 (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>`,
223223 )}</div>
224224 </details>`;
225225 return html`<div class="repo-nav">${tabs(shown, "Repository")}${more}</div>`;
src/web/layout.ts +6 −5
@@ -7,6 +7,7 @@
77 * `/search`), and every dialog (a link to a real page).
88 */
99
10 import { NEW_ISSUE_KEY } from "./script_keys.ts";
1011 import { newIssueHref } from "./issue_from.ts";
1112 import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts";
1213 import { protectionNotice } from "../kernel/protection.ts";
@@ -56,7 +57,7 @@ import { html, raw, when, type Html } from "./html.ts";
5657 import { noRoom } from "../spawn/door.ts";
5758 import { quotaFull } from "../spawn/quota.ts";
5859 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";
6061 import { SITE_LINE } from "./site_line.ts";
6162
6263 /** 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
194195 const newIssueItem = (ctx: Ctx): MenuItem[] =>
195196 ctx.url.pathname.startsWith("/origins/")
196197 ? []
197 : [{ label: "New issue", href: newIssueHref(ctx.url.pathname) }];
198 : [{ label: "New issue", href: newIssueHref(ctx.url.pathname), hint: NEW_ISSUE_KEY }];
198199
199200 function guestMenu(ctx: Ctx, theme: ThemeName | undefined): Html {
200201 return menu({
@@ -565,7 +566,7 @@ function topbar(
565566 return html`<a class="skip" href="#main">Skip to content</a>
566567 <header class="topbar" role="banner">
567568 <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
569570 >
570571 ${when(
571572 crumbs,
@@ -585,8 +586,8 @@ function topbar(
585586 ${searchBar()}
586587 ${user
587588 ? 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
590591 >${newMenu(ctx)}${userMenu(
591592 ctx,
592593 user,
src/web/pages/pull.ts +2 −1
@@ -92,6 +92,7 @@ import {
9292 card,
9393 field as formField,
9494 icon,
95 keyHint,
9596 labelCss,
9697 queuedPhrase,
9798 row,
@@ -772,7 +773,7 @@ function reviewCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html {
772773 ${i === options.length - 1 ? raw("checked") : null}
773774 />
774775 <span
775 >${option.label} <span class="hint">${option.hint}</span></span
776 >${option.label} ${keyHint(option.key)} <span class="hint">${option.hint}</span></span
776777 >
777778 </label>`,
778779 )}
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";
2525 import { mayFetchLook, pictured } from "../pages/looks.ts";
2626 import { dividers } from "./day_divider.ts";
2727 import { html, type Html } from "../html.ts";
28 import { DISMISS_KEY } from "../script_keys.ts";
2829 import { fragment } from "../layout.ts";
2930 import { draftJs } from "../draft.ts";
3031 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";
3233 import { stepLink } from "./step_link.ts";
3334 import { writableAppsOf } from "./asks.ts";
3435
@@ -169,7 +170,7 @@ export function feedCard(
169170 <span class="bar">${btn(target ? "Steer" : "Tell", reply ? { size: "sm" } : { size: "sm", variant: "primary" })}</span>
170171 </form>
171172 <span class="bar">
outdated

Use keyHint("x") instead of hand-written kbd markup so the hint has one source.

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>
173174 ${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>`}
174175 </span>`,
175176 { 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}
642643 outbox.flush().then(function (r) { if (r.refused && draft) draft.refused(text); });
643644 });
644645 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;
646647 var a = document.activeElement;
647648 if (!a || !a.matches || !a.matches('[data-item]') || !feed.contains(a)) return;
648649 e.preventDefault();
src/web/style/components.ts +3
@@ -278,6 +278,9 @@ kbd {
278278 font-family: var(--mono); font-size: var(--fs-min); font-weight: 500; color: var(--muted-fg);
279279 }
280280
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
281284 .sha {
282285 font-family: var(--mono); font-size: 12.5px; padding: 1px 5px;
283286 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 {
194194
195195 /** A button or a link that looks like one. Content is the visible label. */
196196 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}`;
198198 if (o.href !== undefined) {
199199 return html`<a
200200 href="${o.href}"
@@ -756,6 +756,12 @@ export function kbd(keys: string[]): Html {
756756 return html`${keys.map((k) => html`<kbd>${k}</kbd>`)}`;
757757 }
758758
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
759765 // ---- identity ----
760766
761767 /** Twelve stable buckets, so the same name always wears the same hue. */
@@ -991,7 +997,7 @@ export function tabs(items: TabItem[], ariaLabel: string): Html {
991997 >${t.icon ? icon(t.icon) : null}${t.label}${t.count !== undefined &&
992998 t.count > 0
993999 ? pill(t.count)
994 : null}</a
1000 : null}${t.shortcut ? keyHint(t.shortcut) : null}</a
9951001 >`,
9961002 )}
9971003 </nav>`;
@@ -1061,6 +1067,8 @@ export interface MenuItem {
10611067 post?: string;
10621068 /** Shown but not usable: the reason, in words, under the label. */
10631069 disabled?: string;
1070 /** The key that does the same, drawn beside the label. */
1071 hint?: string;
10641072 }
10651073
10661074 /** A dropdown built on `<details>` — the only construct correct with JS off. */
@@ -1116,7 +1124,7 @@ export function menu(o: {
11161124 href="${i.href ?? "#"}"
11171125 ${i.current ? raw('aria-current="true"') : null}
11181126 >${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
11201128 >`;
11211129 })}
11221130 </div>
test/home-feed.test.ts +1 −1
@@ -453,7 +453,7 @@ describe("home is the feed", () => {
453453 expect(home).not.toContain("things need you");
454454 expect(home).not.toContain("data-pick");
455455 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);
457457 expect(link).not.toBeNull();
458458 expect(link![1]!.trim()).toBe("Asks");
459459 // 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 () => {
1414 });
1515
1616 // 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.
1818 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 },
2222 };
outdated

Loosening the byte budgets to fit the change defeats them. Trim the hints from the head, or justify the growth.

2323
2424 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";
1414 import { tmpdir } from "node:os";
1515 import { join } from "node:path";
1616 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";
1720 import { getRepo } from "../src/core/repos.ts";
1821 import { listLabels, labelsFor, getIssue } from "../src/core/issues.ts";
1922 import { serve, type Servers } from "../src/http/server.ts";
@@ -958,6 +961,43 @@ describe("web ui", () => {
958961 expect(bad.loc.href).toBe(href);
959962 });
960963
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
9611001 test("the composer, the verdicts and the settings sections carry their keys", async () => {
9621002 const as = async (path: string, cookie = "") =>
9631003 (

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs