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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8930-hotkey-hints-stay-out-of-the-way-shown-o → main 6 commits fork 3 threads · 3 unresolved
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 | ||
| 14 | 14 | |
| 15 | 15 | ## Keys beside their actions |
| 16 | 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. | |
| 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 | ||
| 12 | 12 | repeats its own label: keep the label and hide the icon, or keep the icon and its aria-label. |
| 13 | 13 | - `inline-colour` — a colour name in a `style=""` attribute. Colour is a token (`var(--…)`); hex and `rgb()` literals |
| 14 | 14 | 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 | ||
| 315 | 315 | } |
| 316 | 316 | |
| 317 | 317 | /** 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"; | |
| 319 | 319 | 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"]; | |
| 321 | 321 | const STATUS_WORD = "open|closed|merged|draft|done|failed|passing|failing|pending|running|queued|shipped|blocked|active|ready"; |
| 322 | 322 | |
| 323 | 323 | /** 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[] { | ||
| 341 | 341 | 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" }); |
| 342 | 342 | } |
| 343 | 343 | } |
| 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 | } | |
| 344 | 350 | // A colour name in an inline style="": the stylesheet scan reads css templates only. |
| 345 | 351 | for (const m of code.matchAll(/\bstyle="([^"]*)"/g)) { |
| 346 | 352 | const bad = m[1]!.match(NAMED_RE); |
| @@ -349,6 +355,19 @@ export function quiet(text: string): QuietFinding[] { | ||
| 349 | 355 | return out.sort((a, b) => a.line - b.line); |
| 350 | 356 | } |
| 351 | 357 | |
| 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 | ||
| 352 | 371 | export const QUIET_DEBT = "docs/design/quiet-debt.json"; |
| 353 | 372 | export type QuietDebt = Record<string, Partial<Record<QuietRule, number>>>; |
| 354 | 373 |
src/web/client.ts +11 −1
| @@ -1461,6 +1461,12 @@ const shellCore = /* js */ ` | ||
| 1461 | 1461 | seq.length = 0; e.preventDefault(); |
| 1462 | 1462 | location.assign(p.indexOf('/new/issue') === 0 ? '/new/issue' : '/new/issue?from=' + encodeURIComponent(p)); |
| 1463 | 1463 | } |
| 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); }); | |
| 1464 | 1470 | doc.addEventListener('keydown', function (e) { |
| 1465 | 1471 | var t = e.target; |
| 1466 | 1472 | // Inside an open dialog the dialog owns the keys; none of these apply. |
| @@ -1506,7 +1512,11 @@ const shellCore = /* js */ ` | ||
| 1506 | 1512 | else if (e.key === '/') { |
| 1507 | 1513 | var box = doc.querySelector('[data-search-input]'); |
| 1508 | 1514 | 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 | } | |
| 1510 | 1520 | else if (e.key === ':') { seq.length = 0; e.preventDefault(); openPalette(); } |
| 1511 | 1521 | }); |
| 1512 | 1522 |
src/web/layout.ts +6 −6
| @@ -57,7 +57,7 @@ import { html, raw, when, type Html } from "./html.ts"; | ||
| 57 | 57 | import { noRoom } from "../spawn/door.ts"; |
| 58 | 58 | import { quotaFull } from "../spawn/quota.ts"; |
| 59 | 59 | 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"; | |
| 61 | 61 | import { SITE_LINE } from "./site_line.ts"; |
| 62 | 62 | |
| 63 | 63 | /** One row of the command palette: title, href, category. */ |
| @@ -565,8 +565,8 @@ function topbar( | ||
| 565 | 565 | const user = ctx.auth.user; |
| 566 | 566 | return html`<a class="skip" href="#main">Skip to content</a> |
| 567 | 567 | <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 | |
| 570 | 570 | > |
| 571 | 571 | ${when( |
| 572 | 572 | crumbs, |
| @@ -585,9 +585,9 @@ function topbar( | ||
| 585 | 585 | <span class="topbar-tools"> |
| 586 | 586 | ${searchBar()} |
| 587 | 587 | ${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 | |
| 591 | 591 | >${newMenu(ctx)}${userMenu( |
| 592 | 592 | ctx, |
| 593 | 593 | user, |
src/web/pages/pull.ts +3 −3
| @@ -92,7 +92,7 @@ import { | ||
| 92 | 92 | card, |
| 93 | 93 | field as formField, |
| 94 | 94 | icon, |
| 95 | keyHint, | |
| 95 | hintAttr, | |
| 96 | 96 | labelCss, |
| 97 | 97 | queuedPhrase, |
| 98 | 98 | row, |
| @@ -763,7 +763,7 @@ function reviewCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html { | ||
| 763 | 763 | <legend class="sr-only">Your verdict</legend> |
| 764 | 764 | ${options.map( |
| 765 | 765 | (option, i) => |
| 766 | html`<label class="check"> | |
| 766 | html`<label class="check" ${hintAttr(option.key)}> | |
| 767 | 767 | <input |
| 768 | 768 | type="radio" |
| 769 | 769 | name="state" |
| @@ -773,7 +773,7 @@ function reviewCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html { | ||
| 773 | 773 | ${i === options.length - 1 ? raw("checked") : null} |
| 774 | 774 | /> |
| 775 | 775 | <span |
| 776 | >${option.label} ${keyHint(option.key)} <span class="hint">${option.hint}</span></span | |
| 776 | >${option.label} <span class="hint">${option.hint}</span></span | |
| 777 | 777 | > |
| 778 | 778 | </label>`, |
| 779 | 779 | )} |
src/web/pages/talk.ts +1 −1
| @@ -270,7 +270,7 @@ function talkPage(ctx: Ctx, conversation: Conversation, before: string | undefin | ||
| 270 | 270 | <div class="talk-chips" data-chips role="group" aria-label="Attached files"></div> |
| 271 | 271 | <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> |
| 272 | 272 | <div class="bar"> |
| 273 | <span class="muted small" data-hint></span> | |
| 273 | <span class="muted small" data-compose-hint></span> | |
| 274 | 274 | <label class="btn talk-attach" data-attach hidden>Attach<input type="file" multiple hidden data-pick /></label> |
| 275 | 275 | <span class="spacer"></span> |
| 276 | 276 | ${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) { | ||
| 72 | 72 | const paged = root.dataset.paged === "1"; |
| 73 | 73 | const { c: cid, here } = root.dataset; |
| 74 | 74 | const say = (words) => { status.textContent = words; }; |
| 75 | const hint = root.querySelector("[data-hint]"); | |
| 75 | const hint = root.querySelector("[data-compose-hint]"); | |
| 76 | 76 | if (hint) hint.textContent = "Enter sends, Shift+Enter adds a line"; |
| 77 | 77 | const jump = Object.assign(document.createElement("button"), { type: "button", className: "btn talk-jump", textContent: "Latest ↓", hidden: true }); |
| 78 | 78 | 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"; | ||
| 29 | 29 | import { fragment } from "../layout.ts"; |
| 30 | 30 | import { draftJs } from "../draft.ts"; |
| 31 | 31 | 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"; | |
| 33 | 33 | import { stepLink } from "./step_link.ts"; |
| 34 | 34 | import { writableAppsOf } from "./asks.ts"; |
| 35 | 35 | |
| @@ -170,7 +170,7 @@ export function feedCard( | ||
| 170 | 170 | <span class="bar">${btn(target ? "Steer" : "Tell", reply ? { size: "sm" } : { size: "sm", variant: "primary" })}</span> |
| 171 | 171 | </form> |
| 172 | 172 | <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> | |
| 174 | 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>`} |
| 175 | 175 | </span>`, |
| 176 | 176 | { 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 { | ||
| 278 | 278 | font-family: var(--mono); font-size: var(--fs-min); font-weight: 500; color: var(--muted-fg); |
| 279 | 279 | } |
| 280 | 280 | |
| 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. */ | |
| 281 | 283 | .keyhint { margin-left: 6px; } |
| 282 | 284 | @media (pointer: coarse), (max-width: 640px) { .keyhint { display: none; } } |
| 283 | 285 | |
| 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 | ||
| 284 | 318 | .sha { |
| 285 | 319 | font-family: var(--mono); font-size: 12.5px; padding: 1px 5px; |
| 286 | 320 | 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 { | ||
| 187 | 187 | ${o.size ? html`data-size="${o.size}"` : null} |
| 188 | 188 | ${o.title ? html`title="${o.title}"` : null} |
| 189 | 189 | ${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} | |
| 191 | 191 | ${o.copy ? html`data-copy="${o.copy}"` : null} |
| 192 | 192 | ${o.pressed !== undefined ? html`aria-pressed="${o.pressed ? "true" : "false"}"` : null}${o.current ? raw('aria-current="page"') : null}`; |
| 193 | 193 | } |
| 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}${o.shortcut ? keyHint(o.shortcut) : null}`; | |
| 197 | const inner = html`${o.icon ? icon(o.icon) : null}${content}`; | |
| 198 | 198 | if (o.href !== undefined) { |
| 199 | 199 | return html`<a |
| 200 | 200 | href="${o.href}" |
| @@ -756,8 +756,21 @@ 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`). */ | |
| 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. */ | |
| 761 | 774 | export function keyHint(shortcut: string): Html { |
| 762 | 775 | return html`<kbd class="keyhint">${shortcut}</kbd>`; |
| 763 | 776 | } |
| @@ -993,11 +1006,11 @@ export function tabs(items: TabItem[], ariaLabel: string): Html { | ||
| 993 | 1006 | html`<a |
| 994 | 1007 | href="${t.href}" |
| 995 | 1008 | ${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} | |
| 997 | 1010 | >${t.icon ? icon(t.icon) : null}${t.label}${t.count !== undefined && |
| 998 | 1011 | t.count > 0 |
| 999 | 1012 | ? pill(t.count) |
| 1000 | : null}${t.shortcut ? keyHint(t.shortcut) : null}</a | |
| 1013 | : null}</a | |
| 1001 | 1014 | >`, |
| 1002 | 1015 | )} |
| 1003 | 1016 | </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", () => { | ||
| 267 | 267 | const doc = window.document as any; |
| 268 | 268 | doc.body.innerHTML = `<div class="talk" data-talk data-user="${user}" data-paged="0" data-running="1"><div> |
| 269 | 269 | <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>`; | |
| 271 | 271 | const how = { value: "started" }; |
| 272 | 272 | const cancel = { status: 200 }; |
| 273 | 273 | const sent: string[] = []; |
| @@ -333,7 +333,7 @@ describe("the page", () => { | ||
| 333 | 333 | expect(t.key({})).toBe(false); |
| 334 | 334 | await tick(); |
| 335 | 335 | 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"); | |
| 337 | 337 | await t.window.happyDOM.close(); |
| 338 | 338 | }); |
| 339 | 339 |
test/web-ui.test.ts +4 −4
| @@ -973,7 +973,7 @@ describe("web ui", () => { | ||
| 973 | 973 | const from = m[1] === "input" ? html.lastIndexOf("<label", m.index) : m.index; |
| 974 | 974 | const close = m[1] === "input" ? "</label>" : `</${m[1]}>`; |
| 975 | 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>`))) { | |
| 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>`))) { | |
| 977 | 977 | throw new Error(`${path}: "${chord}" has no <kbd> beside its label`); |
| 978 | 978 | } |
| 979 | 979 | seen++; |
| @@ -991,9 +991,9 @@ describe("web ui", () => { | ||
| 991 | 991 | expect(shellJs).toContain(`e.key === '${NEW_ISSUE_KEY}'`); |
| 992 | 992 | for (const k of SCRIPT_KEYS) { |
| 993 | 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); | |
| 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); | |
| 997 | 997 | if (!near.includes(k.label)) throw new Error(`${k.page}: "${k.label}" shows no ${k.key} beside it`); |
| 998 | 998 | } |
| 999 | 999 | }); |
new above
offline
[data-hint]{position:relative}competes with component rules by order. Make it order-proof or test that no hinted class positions itself otherwise.