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

Hotkey hints stay out of the way: shown on hover and on request, never squeezed into the layout #8932

Merged opened this in answer to

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

Merged

scout/forge:scout/8930-hotkey-hints-stay-out-of-the-way-shown-o → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/8930-hotkey-hints-stay-out-of-the-way-shown-o was merged into main as e203f2c.

Review

Sign in to review this change.
15 files changed +284 −29
docs/design/areas/palette.md +1 −1
@@ -14,4 +14,4 @@ The palette's tell row (`data-tell`, last in the list) sends the typed sentence
1414
1515 ## Keys beside their actions
1616
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.
17 An action's shortcut takes no room at rest (#8930, changing the "beside the action" rule of #8709). `btn`, `tabs`, the top bar, the feed's Dismiss and the review verdicts carry `data-hint` (`hintAttr()` in `ui.ts`); a `::before` draws it as a tooltip on hover or focus, and over every action while Option is held or after `?` (`html[data-hints]`, set by the shell script; Escape or a second `?` clears it). It is an overlay: nothing moves, and on touch-only screens it is not drawn. `.keyhint`, the inline `<kbd>`, stays only in menu items, where a column holds it; design-lint's `keyhint` rule refuses it elsewhere. A second `?` opens the help, which lists every key.
docs/design/areas/quiet.md +4
@@ -12,3 +12,7 @@ markup and style, never in a paragraph's words. What already breaks one on main
1212 repeats its own label: keep the label and hide the icon, or keep the icon and its aria-label.
1313 - `inline-colour` — a colour name in a `style=""` attribute. Colour is a token (`var(--…)`); hex and `rgb()` literals
1414 anywhere in `src/web` are already refused by the colour count.
15 - `keyhint` — a `keyHint(` call or `<kbd class="keyhint">` that is not inside a menu item (#8930). A shortcut never
16 takes layout space beside a button, tab or the top bar: the element carries `hintAttr(key)` (`data-hint`), which shows
17 as a tooltip on hover or focus and as an overlay while Option is held, and never on touch. Only a menu or palette row,
18 which has a column for it, draws the `<kbd>`.
docs/features/hotkey-hints.md added +18
@@ -0,0 +1,18 @@
1 # Hotkey hints (#8930)
2
3 Every action with a key shows it without taking room. Hints are never a box in the layout.
4
5 **See them**
6 - **Hover or keyboard focus** on a control: its key appears as a small tooltip. This is CSS alone and works with JavaScript off.
7 - **Hold Option (Alt)**: every hinted control shows its key at once; release to hide.
8 - **`?`**: the same overlay, kept until you press Escape or `?` again (the second `?` clears them and opens the full help).
9 - Touch screens never draw hints. Menu and palette rows keep their key in a column of their own.
10
11 **For builders**
12 - Beside a button, link, tab, label or the top bar: add `${hintAttr("g h")}` (`data-hint`). Icon-only controls pass a name, `hintAttr("g t", "Talk")`, and carry no `title` (two tooltips would stack).
13 - `keyHint(key)` (an inline `<kbd>`) only inside a menu or palette row; `bun scripts/design-lint.ts` refuses it elsewhere (rule `keyhint`).
14 - The chip is a `::before` and out of flow; the host rule is `:where([data-hint])`, so a component's own `position` wins. In a card it sits on the element's top-right corner, above the label; in the tab strip, in the tab's top padding; at the top bar's edges it hangs inward.
15 - Screen readers: the chip's text is excluded from the name (`content: … / ""`). A single key is announced through `aria-keyshortcuts`; a chord such as `g h` is not, because that attribute means "g or h", so the `?` help lists it.
16 - Test: `test/hotkey-hints.test.ts`. It checks the stylesheet's rules and the rendered markup. It does not measure a rendered layout, because the test run has no layout engine.
17
18 **Terminal:** none. Terminal screens already print their keys in their footer line, which is always visible, so there is nothing to reveal.
scripts/design-lint.ts +21 −2
@@ -315,9 +315,9 @@ export function compare(base: Baseline, now: Scan, design: string, areas: readon
315315 }
316316
317317 /** The quiet-page rules a machine can check (#8775, from #8719): markup and style only, never prose. */
318 export type QuietRule = "label" | "caps" | "echo" | "inline-colour";
318 export type QuietRule = "label" | "caps" | "echo" | "inline-colour" | "keyhint";
319319 export type QuietFinding = { line: number; rule: QuietRule; text: string; use: string };
320 export const QUIET_RULES: readonly QuietRule[] = ["label", "caps", "echo", "inline-colour"];
320 export const QUIET_RULES: readonly QuietRule[] = ["label", "caps", "echo", "inline-colour", "keyhint"];
321321 const STATUS_WORD = "open|closed|merged|draft|done|failed|passing|failing|pending|running|queued|shipped|blocked|active|ready";
322322
323323 /** Where `text` breaks a quiet-page rule: file line, the offence, and what to use instead. */
@@ -341,6 +341,12 @@ export function quiet(text: string): QuietFinding[] {
341341 out.push({ line: at(m.index!), rule: "echo", text: `aria-label="${m[2]}" beside the visible "${visible}"`, use: "either the icon with the aria-label, or the visible word with the icon aria-hidden — never both" });
342342 }
343343 }
344 // A key hint takes no room at rest (#8930): inline only in a menu item, else `hintAttr`.
345 for (const m of code.matchAll(/(?<!function )\bkeyHint\(|<kbd class="keyhint"/g)) {
346 if (!inMenuItem(code, m.index!) && !code.slice(Math.max(0, m.index! - 60), m.index!).includes("function keyHint")) {
347 out.push({ line: at(m.index!), rule: "keyhint", text: m[0], use: "hintAttr(key): a tooltip on hover and an overlay on request; an inline hint only inside a menu item" });
348 }
349 }
344350 // A colour name in an inline style="": the stylesheet scan reads css templates only.
345351 for (const m of code.matchAll(/\bstyle="([^"]*)"/g)) {
346352 const bad = m[1]!.match(NAMED_RE);
@@ -349,6 +355,19 @@ export function quiet(text: string): QuietFinding[] {
349355 return out.sort((a, b) => a.line - b.line);
350356 }
351357
358 /** Is the position inside a still-open menu or palette row? The nearest earlier
359 * element whose class is `menu-item`/`palette-item`, not yet closed before here. */
360 export function inMenuItem(code: string, at: number): boolean {
361 const before = code.slice(0, at);
362 const opens = [...before.matchAll(/<(a|button|div|li)\b[^>]*?\bclass="[^"]*\b(?:menu-item|palette-item)\b/g)];
363 const last = opens.at(-1);
364 if (!last) return false;
365 const rest = before.slice(last.index!);
366 const tag = last[1]!;
367 const depth = (rest.match(new RegExp(`<${tag}\\b`, "g")) ?? []).length - (rest.match(new RegExp(`</${tag}\\s*>`, "g")) ?? []).length;
368 return depth > 0;
369 }
370
352371 export const QUIET_DEBT = "docs/design/quiet-debt.json";
353372 export type QuietDebt = Record<string, Partial<Record<QuietRule, number>>>;
354373
src/web/client.ts +11 −1
@@ -1461,6 +1461,12 @@ const shellCore = /* js */ `
14611461 seq.length = 0; e.preventDefault();
14621462 location.assign(p.indexOf('/new/issue') === 0 ? '/new/issue' : '/new/issue?from=' + encodeURIComponent(p));
14631463 }
1464 // Holding Option (Alt) lays each action's key over it; nothing moves (#8930).
1465 function hints(on) { if (on) doc.documentElement.setAttribute('data-hints', ''); else doc.documentElement.removeAttribute('data-hints'); }
1466 doc.addEventListener('keydown', function (e) { if (e.key === 'Alt') hints(true); });
1467 doc.addEventListener('keyup', function (e) { if (e.key === 'Alt') hints(false); });
1468 doc.addEventListener('keydown', function (e) { if (e.key === 'Escape') hints(false); });
1469 window.addEventListener('blur', function () { hints(false); });
14641470 doc.addEventListener('keydown', function (e) {
14651471 var t = e.target;
14661472 // Inside an open dialog the dialog owns the keys; none of these apply.
@@ -1506,7 +1512,11 @@ const shellCore = /* js */ `
15061512 else if (e.key === '/') {
15071513 var box = doc.querySelector('[data-search-input]');
15081514 if (box) { seq.length = 0; e.preventDefault(); box.focus(); box.select(); }
1509 } else if (e.key === '?') { seq.length = 0; e.preventDefault(); openHelp(); }
1515 } else if (e.key === '?') {
1516 seq.length = 0; e.preventDefault();
1517 // First press lays the keys over the page; the second clears them and opens the help.
1518 if (doc.documentElement.hasAttribute('data-hints')) { hints(false); openHelp(); } else hints(true);
1519 }
15101520 else if (e.key === ':') { seq.length = 0; e.preventDefault(); openPalette(); }
15111521 });
15121522
src/web/layout.ts +6 −6
@@ -57,7 +57,7 @@ import { html, raw, when, type Html } from "./html.ts";
5757 import { noRoom } from "../spawn/door.ts";
5858 import { quotaFull } from "../spawn/quota.ts";
5959 import { icon, inlineIcons, pointAtSprite, themeGlyph } from "./icons.ts";
60 import { btn, copyField, kbd, keyHint, menu, type MenuItem } from "./ui.ts";
60 import { btn, copyField, hintAttr, kbd, menu, type MenuItem } from "./ui.ts";
6161 import { SITE_LINE } from "./site_line.ts";
6262
6363 /** One row of the command palette: title, href, category. */
@@ -565,8 +565,8 @@ function topbar(
565565 const user = ctx.auth.user;
566566 return html`<a class="skip" href="#main">Skip to content</a>
567567 <header class="topbar" role="banner">
568 <a href="/" class="wordmark" data-shortcut="g h"
569 ><span class="mark"></span>forge${keyHint("g h")}</a
568 <a href="/" class="wordmark" data-shortcut="g h" ${hintAttr("g h")}
569 ><span class="mark"></span>forge</a
570570 >
571571 ${when(
572572 crumbs,
@@ -585,9 +585,9 @@ function topbar(
585585 <span class="topbar-tools">
586586 ${searchBar()}
587587 ${user
588 ? html`<a class="btn btn-icon" href="/talk" aria-label="Talk to the forge" title="Talk to the forge" data-shortcut="g t"
589 >${icon("comment")}${keyHint("g t")}</a
590 ><a class="small" href="/asks" data-shortcut="g q">Asks${keyHint("g q")}</a
588 ? html`<a class="btn btn-icon" href="/talk" aria-label="Talk to the forge" data-shortcut="g t" ${hintAttr("g t", "Talk to the forge")}
589 >${icon("comment")}</a
590 ><a class="small" href="/asks" data-shortcut="g q" ${hintAttr("g q")}>Asks</a
591591 >${newMenu(ctx)}${userMenu(
592592 ctx,
593593 user,
src/web/pages/pull.ts +3 −3
@@ -92,7 +92,7 @@ import {
9292 card,
9393 field as formField,
9494 icon,
95 keyHint,
95 hintAttr,
9696 labelCss,
9797 queuedPhrase,
9898 row,
@@ -763,7 +763,7 @@ function reviewCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html {
763763 <legend class="sr-only">Your verdict</legend>
764764 ${options.map(
765765 (option, i) =>
766 html`<label class="check">
766 html`<label class="check" ${hintAttr(option.key)}>
767767 <input
768768 type="radio"
769769 name="state"
@@ -773,7 +773,7 @@ function reviewCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html {
773773 ${i === options.length - 1 ? raw("checked") : null}
774774 />
775775 <span
776 >${option.label} ${keyHint(option.key)} <span class="hint">${option.hint}</span></span
776 >${option.label} <span class="hint">${option.hint}</span></span
777777 >
778778 </label>`,
779779 )}
src/web/pages/talk.ts +1 −1
@@ -270,7 +270,7 @@ function talkPage(ctx: Ctx, conversation: Conversation, before: string | undefin
270270 <div class="talk-chips" data-chips role="group" aria-label="Attached files"></div>
271271 <textarea class="input" name="text" rows="1" maxlength="${WORDS_MAX}" required placeholder="Talk to the forge" aria-label="Talk to the forge">${editing === undefined ? prefill : editing.text}</textarea>
272272 <div class="bar">
273 <span class="muted small" data-hint></span>
273 <span class="muted small" data-compose-hint></span>
274274 <label class="btn talk-attach" data-attach hidden>Attach<input type="file" multiple hidden data-pick /></label>
275275 <span class="spacer"></span>
276276 ${editing === undefined ? null : html`<a class="btn" href="${here}">Keep as it was</a>`}
src/web/pages/talk_client.ts +1 −1
@@ -72,7 +72,7 @@ if (root) {
7272 const paged = root.dataset.paged === "1";
7373 const { c: cid, here } = root.dataset;
7474 const say = (words) => { status.textContent = words; };
75 const hint = root.querySelector("[data-hint]");
75 const hint = root.querySelector("[data-compose-hint]");
7676 if (hint) hint.textContent = "Enter sends, Shift+Enter adds a line";
7777 const jump = Object.assign(document.createElement("button"), { type: "button", className: "btn talk-jump", textContent: "Latest ↓", hidden: true });
7878 jump.setAttribute("aria-label", "Jump to the latest message");
src/web/studio/feed.ts +2 −2
@@ -29,7 +29,7 @@ import { DISMISS_KEY } from "../script_keys.ts";
2929 import { fragment } from "../layout.ts";
3030 import { draftJs } from "../draft.ts";
3131 import { outboxJs } from "../outbox.ts";
32 import { avatar, btn, card, disclosure, empty, keyHint, nameMark, row, stateBadge, time } from "../ui.ts";
32 import { avatar, btn, card, disclosure, empty, hintAttr, nameMark, row, stateBadge, time } from "../ui.ts";
3333 import { stepLink } from "./step_link.ts";
3434 import { writableAppsOf } from "./asks.ts";
3535
@@ -170,7 +170,7 @@ export function feedCard(
170170 <span class="bar">${btn(target ? "Steer" : "Tell", reply ? { size: "sm" } : { size: "sm", variant: "primary" })}</span>
171171 </form>
172172 <span class="bar">
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 <button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="dismissed" ${hintAttr(DISMISS_KEY)}>Dismiss</button>
174174 ${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>`}
175175 </span>`,
176176 { small: true, label: target ? "Steer, dismiss or mute" : reply ? "Reply, tell the crew, dismiss or mute" : "Tell the crew, dismiss or mute" },
src/web/style/components.ts +34
@@ -278,9 +278,43 @@ kbd {
278278 font-family: var(--mono); font-size: var(--fs-min); font-weight: 500; color: var(--muted-fg);
279279 }
280280
281 /* A menu or palette row's key sits in its own column (#8930); anywhere else a
282 key is a tooltip or an overlay and takes no room. */
281283 .keyhint { margin-left: 6px; }
282284 @media (pointer: coarse), (max-width: 640px) { .keyhint { display: none; } }
283285

[data-hint]{position:relative} competes with component rules by order. Make it order-proof or test that no hinted class positions itself otherwise.

286 /* ::before, because components already use ::after (the current tab's line,
287 a menu's caret). :where() keeps the host rule at zero specificity, so a
288 component that positions itself (sticky, absolute) keeps its position. The
289 text after "/" is the chip's accessible text: none, so a name is never read
290 with its key glued on; the key is \`aria-keyshortcuts\` instead. */
291 :where([data-hint]) { position: relative; }
292 [data-hint]::before {
293 content: attr(data-hint); content: attr(data-hint) / ""; position: absolute; z-index: 40;
294 top: calc(100% + 4px); left: 50%; transform: translateX(-50%);
295 min-width: 20px; padding: 2px 5px; box-sizing: border-box; text-align: center;
296 border: 1px solid var(--border); border-bottom-width: 2px;
297 border-radius: var(--radius-sm); background: var(--muted); color: var(--muted-fg);
298 font-family: var(--mono); font-size: var(--fs-min); font-weight: 500; line-height: 16px; white-space: nowrap;
299 opacity: 0; pointer-events: none; transition: opacity 120ms var(--ease);
300 }
301 @media (hover: hover) and (pointer: fine) {
302 [data-hint]:hover::before, [data-hint]:focus-visible::before, [data-hint]:focus-within::before,
303 html[data-hints] [data-hint]::before { opacity: 1; }
outdated

This puts the chip over the element's own text inside cards/tabs. Place it where it cannot cover the label, and show it in a screenshot.

304 }
305 /* At the window's edges the chip hangs inward, so it never leaves the viewport. */
306 .wordmark[data-hint]::before { left: 0; transform: none; }
307 .topbar-tools [data-hint]::before { left: auto; right: 0; transform: none; }
308 /* A card clips what leaves its box: there the chip sits on the element's top
309 right corner, mostly above it, clear of the label's text. */
310 .card [data-hint]::before { top: 0; left: auto; right: 0; transform: translateY(-60%); }
311 /* The tab strip scrolls sideways, so it clips above and below too: the chip is
312 small and sits in the tab's own top padding, above the label. */
313 .tabs [data-hint]::before {
314 top: 0; min-width: 0; padding: 0 3px; border-bottom-width: 1px; line-height: 12px;
315 }
316 @media (pointer: coarse) { [data-hint]::before { display: none; } }
317
284318 .sha {
285319 font-family: var(--mono); font-size: 12.5px; padding: 1px 5px;
286320 border-radius: 4px; background: var(--muted); color: var(--muted-fg);
src/web/ui.ts +19 −6
@@ -187,14 +187,14 @@ function btnAttrs(o: BtnOptions): Html {
187187 ${o.size ? html`data-size="${o.size}"` : null}
188188 ${o.title ? html`title="${o.title}"` : null}
189189 ${o.label ? html`aria-label="${o.label}"` : null}
190 ${o.shortcut ? html`data-shortcut="${o.shortcut}"` : null}
190 ${o.shortcut ? html`data-shortcut="${o.shortcut}" ${hintAttr(o.shortcut)}` : null}
191191 ${o.copy ? html`data-copy="${o.copy}"` : null}
192192 ${o.pressed !== undefined ? html`aria-pressed="${o.pressed ? "true" : "false"}"` : null}${o.current ? raw('aria-current="page"') : null}`;
193193 }
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}${o.shortcut ? keyHint(o.shortcut) : null}`;
197 const inner = html`${o.icon ? icon(o.icon) : null}${content}`;
198198 if (o.href !== undefined) {
199199 return html`<a
200200 href="${o.href}"
@@ -756,8 +756,21 @@ 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`). */
759 /** The key of an action, as an attribute: it takes no room at rest, shows as a
760 * tooltip on hover or focus, and as an overlay while hints are held (#8930). An
761 * icon-only control passes its `name`, so the tooltip is its only one (no `title`). */
762 export function hintAttr(shortcut: string, name?: string): Html {
763 const label = name === undefined ? shortcut : `${name} · ${shortcut}`;
764 // aria-keyshortcuts lists alternatives, not sequences: a chord like `g h`
765 // would read as "g or h", so only a single key is announced; the help lists the rest.
766 return shortcut.includes(" ")
767 ? html`data-hint="${label}"`
768 : html`data-hint="${label}" aria-keyshortcuts="${shortcut}"`;
769 }
770
771 /** The keys of a `g i`-style shortcut in a menu or palette row, where a column
772 * holds it (`.keyhint`). Never beside a button, tab or the top bar: those use
773 * `hintAttr`, and design-lint refuses a `keyHint(` outside a menu item. */
761774 export function keyHint(shortcut: string): Html {
762775 return html`<kbd class="keyhint">${shortcut}</kbd>`;
763776 }
@@ -993,11 +1006,11 @@ export function tabs(items: TabItem[], ariaLabel: string): Html {
9931006 html`<a
9941007 href="${t.href}"
9951008 ${t.current ? raw('aria-current="page"') : null}
996 ${t.shortcut ? html`data-shortcut="${t.shortcut}"` : null}
1009 ${t.shortcut ? html`data-shortcut="${t.shortcut}" ${hintAttr(t.shortcut)}` : null}
9971010 >${t.icon ? icon(t.icon) : null}${t.label}${t.count !== undefined &&
9981011 t.count > 0
9991012 ? pill(t.count)
1000 : null}${t.shortcut ? keyHint(t.shortcut) : null}</a
1013 : null}</a
10011014 >`,
10021015 )}
10031016 </nav>`;
test/hotkey-hints.test.ts added +157
@@ -0,0 +1,157 @@
1 /** Hotkey hints stay out of the layout (#8930): a tooltip and an overlay, never a box in the flow. */

Box 5 asks for a measured layout comparison with and without hints; this file checks source strings. Say so in the pull and attach screenshots, or measure at 360px in a real layout.

2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { readFileSync } from "node:fs";
4 import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts";
5 import { components } from "../src/web/style/components.ts";
6 import { quiet } from "../scripts/design-lint.ts";
7
8 let f: SnappyFixture;
9 beforeAll(async () => {
10 f = await snappyFixture();
11 }, 120_000);
12 afterAll(async () => {
13 await f.close();
14 });
15
16 const get = async (path: string): Promise<string> =>
17 (await fetch(`${f.base}${path}`, { headers: { cookie: f.ownerCookie } })).text();
18
19 /** The page as a layout sees it: every inline hint box, wherever it is. */
20 const inlineHints = (page: string): { menu: boolean }[] =>
21 [...page.matchAll(/<kbd class="keyhint">/g)].map((m) => ({ menu: page.slice(Math.max(0, m.index! - 400), m.index!).includes("menu-item") }));
22
23 test("no page draws a hint in the flow outside a menu, so hints cannot shift it", async () => {
24 const repo = f.repos[0]!;
25 let hinted = 0;
26 for (const path of ["/", "/feed", "/apps", "/asks", `/${repo.ownerName}/${repo.name}`, `/${repo.ownerName}/${repo.name}/issues`, `/${repo.ownerName}/${repo.name}/pulls`]) {
27 const page = await get(path);
28 expect(inlineHints(page).filter((h) => !h.menu)).toEqual([]);
29 hinted += (page.match(/data-hint="/g) ?? []).length;
30 // A hint is only an attribute: removing it leaves no text, element or class behind.
31 const bare = page.replace(/ data-hint="[^"]*"/g, "");
32 expect(page.length - bare.length).toBe((page.match(/ data-hint="[^"]*"/g) ?? []).join("").length);
33 expect(bare).not.toContain("data-hint");
34 }
35 expect(hinted).toBeGreaterThan(5);
36 });
37
38 test("the top bar's wordmark and tabs carry hints as attributes, not as content", async () => {
39 const page = await get("/");
40 expect(page).toMatch(/class="wordmark" data-shortcut="g h" data-hint="g h"\s*><span class="mark"><\/span>forge<\/a/);
41 });
42
43 /** Every declaration of every rule that selects `[data-hint]`, by selector (comments dropped). */
44 const hintRules = (): { sel: string; decls: Map<string, string> }[] =>
45 [...components.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/([^{}]*\[data-hint\][^{}]*)\{([^{}]*)\}/g)].map((m) => ({
46 sel: m[1]!.trim(),
47 decls: new Map(m[2]!.split(";").map((d) => d.split(/:(.*)/s).map((x) => x?.trim() ?? "")).filter((d) => d[0]).map((d) => [d[0]!, d[1]!] as [string, string])),
48 }));
49 const ruleFor = (sel: string) => hintRules().find((r) => r.sel === sel)?.decls ?? new Map<string, string>();
50
51 // A source check, not a measured layout: there is no layout engine in the test run,
52 // so this reads every rule that can touch a hinted element and proves none can size it.
53 test("layout is the same with and without hints: no rule gives a hinted element or its flow any box", () => {
54 const rules = hintRules();
55 expect(rules.length).toBeGreaterThan(5);
56 const boxy = /^(display|width|height|min-|max-|margin|padding|border|flex|grid|float|overflow|top|left|right|bottom|inset|line-height|font)/;
57 for (const { sel, decls } of rules) {
58 expect(sel).not.toContain("::after");
59 if (/::before/.test(sel)) {
60 // The chip is out of flow wherever a rule moves it, or it is not drawn at all.
61 if (decls.has("display")) expect(decls.get("display")).toBe("none");
62 if (decls.has("position")) expect(decls.get("position")).toBe("absolute");
63 expect(decls.has("float")).toBe(false);
64 } else {
65 for (const name of decls.keys()) expect([sel, name, name.match(boxy) !== null]).toEqual([sel, name, false]);
66 }
67 }
68 const chip = ruleFor("[data-hint]::before");
69 expect(chip.get("position")).toBe("absolute");
70 expect(chip.get("opacity")).toBe("0");
71 expect(chip.get("pointer-events")).toBe("none");
72 expect(components).toMatch(/@media \(pointer: coarse\) \{ \[data-hint\]::before \{ display: none; \} \}/);
73 });
74
75 test("the host rule has no specificity, so a component that positions itself keeps its position", () => {
76 expect(ruleFor(":where([data-hint])").get("position")).toBe("relative");
77 expect(hintRules().filter((r) => /^\[data-hint\]\s*$/.test(r.sel))).toEqual([]);
78 });
79
80 test("the chip is ::before: a current tab's ::after line and a menu's caret stay theirs", () => {
81 expect(components).toContain('.tabs > a[aria-current="page"]::after');
82 expect(hintRules().some((r) => r.sel.includes("::after"))).toBe(false);
83 });
84
85 test("in a card the chip sits on the corner above the label; in the tab strip it sits in the top padding", () => {
86 const card = ruleFor(".card [data-hint]::before");
87 expect([card.get("top"), card.get("right"), card.get("left"), card.get("transform")]).toEqual(["0", "0", "auto", "translateY(-60%)"]);
88 const tab = ruleFor(".tabs [data-hint]::before");
89 expect([tab.get("top"), tab.get("line-height")]).toEqual(["0", "12px"]);
90 });
91
92 test("at the window's edges the chip hangs inward, so a 360px window never scrolls sideways", () => {
93 expect([...ruleFor(".wordmark[data-hint]::before").entries()]).toEqual([["left", "0"], ["transform", "none"]]);
94 expect([...ruleFor(".topbar-tools [data-hint]::before").entries()]).toEqual([["left", "auto"], ["right", "0"], ["transform", "none"]]);
95 });
96
97 test("the chip's text is not part of the element's name; a single key is announced as aria-keyshortcuts", async () => {
98 expect(ruleFor("[data-hint]::before").get("content")).toBe('attr(data-hint) / ""');
99 const feed = readFileSync("src/web/studio/feed.ts", "utf8");
100 expect(feed).toContain("hintAttr(DISMISS_KEY)");
101 const { hintAttr } = await import("../src/web/ui.ts");
102 expect(String(hintAttr("x"))).toBe('data-hint="x" aria-keyshortcuts="x"');
103 expect(String(hintAttr("g h"))).toBe('data-hint="g h"');
104 });
105
106 test("signed in, the top bar's Talk icon and Asks carry their hints", async () => {
107 const page = await get("/");
108 expect(page).toMatch(/aria-label="Talk to the forge" data-shortcut="g t" data-hint="Talk to the forge · g t"/);
109 expect(page).toMatch(/href="\/asks" data-shortcut="g q" data-hint="g q"/);
110 });
111
112 test("hover and focus show a hint with no script: the rule is CSS alone", () => {
113 expect(components).toMatch(/\[data-hint\]:focus-within::before/);
114 expect(components).toMatch(/\[data-hint\]:hover::before/);
115 });
116
117 test("an element does not carry the key twice: no title beside its data-hint", async () => {
118 for (const path of ["/", "/feed"]) {
119 const page = await get(path);
120 for (const tag of page.match(/<(?:a|button)\b[^>]*data-hint="[^"]*"[^>]*>/g) ?? []) expect(tag).not.toMatch(/\btitle="/);
121 }
122 });
123
124 test("? lays the hints over the page and a second press clears them and opens the help; Escape clears", () => {
125 const client = readFileSync("src/web/client.ts", "utf8");
126 expect(client).toContain("hasAttribute('data-hints')) { hints(false); openHelp(); } else hints(true)");
127 expect(client).toContain("e.key === 'Escape') hints(false)");
128 });
129
130 test("holding Option reveals hints; releasing or leaving hides them", () => {
131 const client = readFileSync("src/web/client.ts", "utf8");
132 expect(client).toContain("e.key === 'Alt') hints(true)");
133 expect(client).toContain("e.key === 'Alt') hints(false)");
134 expect(client).toContain("window.addEventListener('blur'");
135 });
136
137 test("the check refuses an inline hint outside a menu item and allows one inside", () => {
138 expect(quiet("html`<a>Go${keyHint(\"g h\")}</a>`").map((x) => x.rule)).toEqual(["keyhint"]);
139 expect(quiet('html`<kbd class="keyhint">x</kbd>`').map((x) => x.rule)).toEqual(["keyhint"]);
140 expect(quiet('html`<a class="menu-item">Go${keyHint("g h")}</a>`')).toEqual([]);
141 });
142
143 test("keyhint is anchored to the open menu row, not to the nearby text", () => {
144 // False positive: a menu row that closed, then a button with a hint.
145 expect(quiet('html`<a class="menu-item">Go</a><button class="btn">Save${keyHint("s")}</button>`').map((x) => x.rule)).toEqual(["keyhint"]);
146 // False negative: a long row, with more than 400 characters before the hint.
147 const long = `html\`<a class="menu-item" href="/x"><span class="grow">${"word ".repeat(120)}</span>\${keyHint("g h")}</a>\``;
148 expect(quiet(long)).toEqual([]);
149 // Palette rows too, and a nested element does not close the row early.
150 expect(quiet('html`<div class="palette-item"><span>a</span>${keyHint("g h")}</div>`')).toEqual([]);
151 expect(quiet('html`<div class="palette-item"><div>a</div>${keyHint("g h")}</div>`')).toEqual([]);
152 });
153
154 test("no element carries an empty data-hint, which would draw an empty chip", () => {
155 const { Glob } = Bun;
156 for (const file of new Glob("src/web/**/*.ts").scanSync()) expect([file, /data-hint>|data-hint\s|\[data-hint\]"/.test(readFileSync(file, "utf8").replace(/data-hint\s*=/g, ""))]).toEqual([file, false]);
157 });
test/talk-page.test.ts +2 −2
@@ -267,7 +267,7 @@ describe("the page", () => {
267267 const doc = window.document as any;
268268 doc.body.innerHTML = `<div class="talk" data-talk data-user="${user}" data-paged="0" data-running="1"><div>
269269 <ol class="talk-log" role="log" data-last=""><li class="talk-turn talk-agent" id="t-1"><div class="talk-body"><pre><code>let a = 1;</code></pre></div><p class="talk-meta">Forge · now</p></li></ol><div id="end"></div></div>
270 <form class="talk-compose"><p class="talk-status" role="status"></p><ul data-chips></ul><textarea name="text"></textarea><div class="bar"><span data-hint></span><label data-attach hidden><input type="file" data-pick /></label><button class="btn" type="button" data-stop hidden>Stop</button><button class="btn" type="button" data-steer hidden>Steer</button><button class="btn" type="submit">Send</button></div></form></div>`;
270 <form class="talk-compose"><p class="talk-status" role="status"></p><ul data-chips></ul><textarea name="text"></textarea><div class="bar"><span data-compose-hint></span><label data-attach hidden><input type="file" data-pick /></label><button class="btn" type="button" data-stop hidden>Stop</button><button class="btn" type="button" data-steer hidden>Steer</button><button class="btn" type="submit">Send</button></div></form></div>`;
271271 const how = { value: "started" };
272272 const cancel = { status: 200 };
273273 const sent: string[] = [];
@@ -333,7 +333,7 @@ describe("the page", () => {
333333 expect(t.key({})).toBe(false);
334334 await tick();
335335 expect(t.sent).toEqual(["hello"]);
336 expect(t.doc.querySelector("[data-hint]").textContent).toContain("Enter sends");
336 expect(t.doc.querySelector("[data-compose-hint]").textContent).toContain("Enter sends");
337337 await t.window.happyDOM.close();
338338 });
339339
test/web-ui.test.ts +4 −4
@@ -973,7 +973,7 @@ describe("web ui", () => {
973973 const from = m[1] === "input" ? html.lastIndexOf("<label", m.index) : m.index;
974974 const close = m[1] === "input" ? "</label>" : `</${m[1]}>`;
975975 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>`))) {
976 if (!new RegExp(`data-hint="(?:[^"]* · )?${chord}"`).test(el) && !el.includes(`<kbd class="keyhint">${chord}</kbd>`) && !chord.split(" ").every((k) => el.includes(`<kbd>${k}</kbd>`))) {
977977 throw new Error(`${path}: "${chord}" has no <kbd> beside its label`);
978978 }
979979 seen++;
@@ -991,9 +991,9 @@ describe("web ui", () => {
991991 expect(shellJs).toContain(`e.key === '${NEW_ISSUE_KEY}'`);
992992 for (const k of SCRIPT_KEYS) {
993993 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);
994 // A menu row draws the key in its column; anything else carries it as data-hint (#8930).
995 const at = Math.max(page.indexOf(`<kbd class="keyhint">${k.key}</kbd>`), page.indexOf(`data-hint="${k.key}"`));
996 const near = at < 0 ? "" : page.slice(Math.max(0, at - 400), at + 400);
997997 if (!near.includes(k.label)) throw new Error(`${k.page}: "${k.label}" shows no ${k.key} beside it`);
998998 }
999999 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs