From 5c84e879afde0ce9bc8f164921869f1821c42da9 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 23 Aug 2026 19:50:31 -0400 Subject: [PATCH] docs(product): design coaching keyboard-discoverability tooltips (RIG-2530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design record for a coaching keyboard-discoverability tooltip layer: a reusable `CoachTip` component that shows a control's label plus its keyboard chord on hover AND focus, adopted across command-backed chrome so mouse and keyboard users passively learn the shortcuts — Linear's discoverability move. This is the deferred RIG-2530, refining DL-234's point-of-use net; distinct from RIG-2484's chord *mechanics* (this is the coaching/push layer, not the reference/pull one). ## What's here - `docs/designs/product/compass-coaching-tooltips/design.md` — the frozen-quality design record (Problem · Global Constraints · Approach A1-A5 · Alternatives · Plan T1-T3 · Resolved decisions D1-D5 · Deferred). - Ledger delta in `DECISIONS.md`: DL-245 (Kobalte-v2-alpha `Tooltip` adoption), DL-246 (sequence-aware chord rendering via `shortcutFor`), DL-247 (the `title=` convert/keep boundary + the two sidebar-toggle registrations). All Active, refining DL-234, no row superseded. ## Approach `CoachTip` builds on the installed Kobalte v2-alpha `Tooltip` primitive (a tooltip's hard parts are DL-150's a11y-hard scope), styles the existing `.cx-tooltip` box, reveals on hover and focus, and resolves its chord only via `shortcutFor` (DL-234 single-derivation, never hand-authored). Forward-compatible with RIG-2484 leader chords: it renders `Ctrl+B` through `ShortcutChip` today and `G then B` as plain text automatically once #544 lands — never blocked on it. The adoption sweep converts the seven command-backed `title=` sites (removing the native `title`, keeping `aria-keyshortcuts`); every other `title=` stays native. ## Design pass Drafted by a design subagent, red-teamed by a design-critic (Kobalte v2-alpha `Tooltip` API verified exact against installed `node_modules` types; Solid-v2 component shape confirmed; the `title=` census verified complete). One load-bearing fork ruled by Matt: the two topbar sidebar-toggle chords (`Mod+\`, `Mod+Shift+\`) are dead today (keymap rows, no registration), so the record registers `sidebar.toggleLeft`/`sidebar.toggleRight` beside their existing store behavior and coaches all seven sites, rather than shrinking the sweep to five live ones. ## CI note Base `fb234f33` carries a pre-existing DL-239 duplicate (two lanes collided on the id: #538/RIG-2453 and #524/RIG-2490), which reds the fail-closed `design-ledger-gate` on every ledger-touching PR. #560 (RIG-2602) fixes it. This PR's ledger gate goes green on rebase once #560 merges; its own delta (DL-245..247) introduces no new duplicate. Spec-impact: none. Refs RIG-2530 Co-authored-by: Matt Wilkinson --- docs/designs/DECISIONS.md | 3 + .../compass-coaching-tooltips/design.md | 484 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 docs/designs/product/compass-coaching-tooltips/design.md diff --git a/docs/designs/DECISIONS.md b/docs/designs/DECISIONS.md index c7835331..2da06c93 100644 --- a/docs/designs/DECISIONS.md +++ b/docs/designs/DECISIONS.md @@ -303,6 +303,9 @@ check enforces the mechanical half. Full rationale: | DL-232 | The Cmd/Ctrl+K command palette host anatomy is a bare fixed wrapper: a permanently-open Kobalte **Search** primitive (`open` pinned true; Control + Input + Listbox, with `Search.Portal`/`Search.Content` replaced by a plain div per Kobalte's published command-menu recipe, , `@kobalte/core` v0.13.13) inside a hand-positioned `position:fixed` div that IS `.cx-palette`, with its OWN `.cx-palette-backdrop`, Escape/outside-click dismiss, hand-wired focus grant (`ref` + `input.focus()` on mount) and focus-restore on close, plus per-row `onMouseDown`-preventDefault so a result click never blurs the pinned-open input — NO Portal/Content, floating-ui out of the test path. Search over Combobox because Search is built for caller-managed filtering (the palette's own fuzzy match) and publishes this pinned-open/no-portal recipe verbatim, where the same shape on bare Combobox was an unsupported extrapolation. The decider: a DOM-only test path with Kobalte reserved for the Search primitive's ARIA + keyboard traversal (DL-150's a11y-hard carve-out). A Search/Combobox hosted inside a Kobalte `Dialog` (scrim/trap/restore free) was rejected for nested-primitive + Portal/z-layer reconciliation cost against the shipped `.cx-palette` CSS and the window-level `Mod+K` toggle. | Active (Matt, 2026-08-23) | [command palette §Decisions](product/compass-command-palette/design.md#decisions) | | DL-233 | The palette's navigation mode ships ALL six destination kinds, including issue AND pr. The issue provider rides the tracker's assigned-issue seam (`store.assignedIssues`); the pr provider requires a NEW store-level PR accessor — `prs: Accessor` as a `createMemo` over `issues()` via the existing pure `prRows()` (`board.ts:132-134`), mapping each `{issue, pr}` row to a `Destination{kind:"pr"}` that selects the owning issue and reveals the PR pane (`store.selectIssue` + `store.setActiveRightTab("pr")`) — which Matt explicitly accepted as added palette-impl scope (the store has no PR collection today; `IssueTab`'s `"pr"` is a detail-pane tab, not a collection). The fixture already carries PR shapes (`stub-data.ts:238`), so no new offline data source is needed. | Active (Matt, 2026-08-23) | [command palette §Decisions](product/compass-command-palette/design.md#decisions) | | DL-234 | Point-of-use shortcut chips ship the seam plus the view-button now-win: the `ShortcutChip` primitive + `shortcutFor(id, platform)` + the `PrimaryAction` contract note + populated `board.*` shortcut fields (chips uniformly via `shortcutFor`, no hand-authored shortcut strings), AND `aria-keyshortcuts` + `title` populated via `shortcutFor` on the LeftSidebar `.bridge-link` view buttons (`LeftSidebar.tsx:437-482`: Bridge/Backlog/Done/Settings — the surfaces that actually invoke the D6-seeded `show*` paths), plus the Bridge topbar tab (`App.tsx:68-94`) for parity. Actual `.cx-menu`/hover-tooltip chip adoption is out of scope, deferred to follow-up issue RIG-2530 (it belongs to the first surface that mounts one). Seeds `view.settings/backlog/done` registrations stay under the shared commands-as-inventory rule (DL-229, owned by RIG-2529): the palette's seeds are `scope:"global"` view commands, so the palette is correct on the shared rule. | Active (Matt, 2026-08-23) | [command palette §Decisions](product/compass-command-palette/design.md#decisions) | +| DL-245 | The coaching keyboard-discoverability layer (RIG-2530, refining DL-234's deferred `.cx-menu`/hover-tooltip adoption) is a reusable `CoachTip` component built on the installed **Kobalte v2-alpha `Tooltip`** primitive (`@kobalte/core@2.0.0-alpha.0`), not hand-rolled: a tooltip's hard parts (open/skip-delay timing, safe-area pointer travel, Escape dismiss, portal stacking, `aria-describedby` id plumbing, hover+focus unification) are exactly DL-150's a11y-hard scope, `.cx-tooltip` was authored naming Kobalte as the behavior owner (`tooltip.css:1-2`), and the alpha already ships via the palette's Search (`Palette.tsx:19`) so the version risk is on the books. It styles the existing `.cx-tooltip` box, reveals on hover AND focus (Kobalte default; a mouse-only coaching layer is self-defeating), and delays 400ms via `COACH_TIP_DELAY_MS` mirroring `--cx-tooltip-delay`. Hand-rolled on `.cx-tooltip` was rejected (re-derives ratified a11y-hard behavior beside Kobalte). | Active (Matt, 2026-08-23) | [coaching tooltips §A1](product/compass-coaching-tooltips/design.md#a1--kobalte-v2-alpha-tooltip-not-hand-rolled) | +| DL-246 | `CoachTip` resolves its chord ONLY via `shortcutFor(id, platform)` (DL-234's single-derivation rule — never a hand-authored string) and renders it sequence-aware: a plus-chord (`Ctrl+B`) through `ShortcutChip` (which splits on `"+"`, `ShortcutChip.tsx:17`), a RIG-2484 sequence (`"G then B"`, no `"+"`) as plain text so it does not render one giant ``. Because it only reads `shortcutFor`'s return, it shows today's `Mod+B` now and `G then B` automatically once RIG-2484 (PR #544) lands — forward-compatible, never blocked on it. Teaching `ShortcutChip` to split on `" then "` is deferred to the RIG-2484 impl. A parallel tooltip-local chord formatter was rejected (forks the single derivation this net exists to keep). | Active (Matt, 2026-08-23) | [coaching tooltips §A3](product/compass-coaching-tooltips/design.md#a3--rig-2484-forward-compat-sequence-aware-chord-rendering) | +| DL-247 | The `title=` adoption boundary: the coaching sweep converts ONLY the seven command-backed `title=` sites (LeftSidebar view buttons ×4, App.tsx Bridge tab + both sidebar toggles), removing the native `title` (no double-tooltip) and keeping `aria-keyshortcuts` (DL-234, the AT-authoritative chord); every other `title=` (command-less actionable controls, truncation/status text) stays native, per DL-229 (no command registered purely for discoverability). The two sidebar-toggle chords (`Mod+\`/`Mod+Shift+\`, `keymap.ts:116-117`) are dead today (declared in the keymap, registered nowhere — the buttons call `store.toggle*()` directly), so this record registers exactly `sidebar.toggleLeft`/`sidebar.toggleRight` beside their existing store behavior (DL-229-compliant) so the coached chords actually fire — Matt-ruled 2026-08-23, amending the record's "registers nothing new" constraint over shrinking the sweep to five live sites. | Active (Matt, 2026-08-23) | [coaching tooltips §A4](product/compass-coaching-tooltips/design.md#a4--adoption-boundary-the-title-classification) | ## Bridge spawn control diff --git a/docs/designs/product/compass-coaching-tooltips/design.md b/docs/designs/product/compass-coaching-tooltips/design.md new file mode 100644 index 00000000..4cf8fece --- /dev/null +++ b/docs/designs/product/compass-coaching-tooltips/design.md @@ -0,0 +1,484 @@ +# Compass coaching keyboard-discoverability tooltips (RIG-2530) + +Status: Draft + +Parent: RIG-1661 (keyboard discoverability net). Refines DL-234 (point-of-use +chips, RIG-2483), which explicitly deferred the `.cx-menu`/hover-tooltip +adoption to this record. Forward-compatible with RIG-2484 (leader chords, +`compass-leader-chords`, PR #544 — submitted, unmerged), never blocked on it. + +## Problem / Intent + +Compass has a full keyboard surface (dispatcher, registry, palette, `?` +overlay) but the chrome teaches none of it: command-backed buttons carry only a +raw native `title=` (slow ~1s browser delay, unstyled, no chip), so a mouse +user never passively learns the keyboard path. Ship a real coaching Tooltip — +label + resolved chord chip on hover **and** focus, in the already-shipped +`.cx-tooltip` box — and adopt it across command-backed chrome: Linear's +signature discoverability move. Scope is the tooltip layer only; the wider +onboarding set (empty-state nudges, key-hint footer, first-run coach) is a +Matt-ratified deferral (see Deferred). + +## Global Constraints + +- **SolidJS v2** (`apps/ui/package.json:26` — `"solid-js": "^2.0.0-rc.1"`; + `:19` — `"@solidjs/web": "^2.0.0-rc.1"`). v2 is a foundational rework: no v1 + idioms (`produce`, `createResource`, ``, `batch`, + `solid-js/store`, `mergeProps`/`splitProps`). Component props are NEVER + destructured (severs reactivity — `skill://solid-skills` TTSR rule + `solid-no-destructured-props`). `ShortcutChip.tsx:14-17` is the in-tree v2 + shape to match (non-destructured `props`, thunked derivation). +- **Kobalte `2.0.0-alpha.0`** (`apps/ui/package.json:15` — + `"@kobalte/core": "2.0.0-alpha.0"`). Kobalte is scoped to a11y-hard behavior + only (DL-150, `docs/designs/product/DECISIONS.md:271`); all visuals via our + `.cx-*` classes. The v2-alpha Tooltip API used here is grounded from the + installed types (see Approach A1), never the 0.13 docs. +- **Chord source of truth is the keymap.** Every displayed chord resolves via + `shortcutFor(id, platform)` and every AT-parseable chord via + `shortcutForAria(id, platform)` (`apps/ui/src/keyboard/keymap.ts:57-76`) — + "a registration never hand-authors a `shortcut` string" (`keymap.ts:53-55`, + DL-234's single-derivation rule). This record NEVER hand-authors a chord. +- **Reuse `.cx-tooltip`** (`apps/ui/src/design/components/tooltip.css:6-19`) + and the `--cx-tooltip-delay: 400ms` token + (`apps/ui/src/design/tokens.css:227`). No new visual tokens. +- **Command ids `noun.verbCamel`**; the registry contract stays as-is + (`commands.ts:113-121`). This record registers exactly TWO commands — + `sidebar.toggleLeft`/`sidebar.toggleRight` — beside their existing store + behavior (`store.ts:328/331`), because their chords are declared in the + keymap (`keymap.ts:116-117`) yet registered nowhere, so they are dead today + and coaching them would teach a lie (see A4/T2). This is DL-229-compliant: + the behavior already exists (`store.toggleLeft/toggleRight`), so the command + is registered beside a behavior, not purely for discoverability. +- **Tests**: `cd apps/ui && bun test --conditions browser `; component + tests via `@solidjs/testing-library@1.0.0-beta.2` + (`apps/ui/package.json:36`). Red → green per `rule://red-green-testing`. +- **Ledger**: the driver folds `DECISIONS.md` in the same PR; new rows are + DL-245..247 (PR #544's leader-chord block was renumbered to DL-248..252 + after a concurrent merge, so it no longer overlaps). This record refines + DL-234; no row is superseded. +- **Docs gate**: markdownlint (`.markdownlint.json`) — blank lines around + every fence, space-padded table delimiter rows, dash bullets. + +## Approach + +One reusable coaching Tooltip built on the Kobalte v2-alpha `Tooltip` +primitive, styled by the existing `.cx-tooltip` box, whose content is the +control's label plus its keymap-resolved chord; then a mechanical adoption +sweep that converts the native `title=` on command-backed controls (removing +the `title` so no double tooltip) and leaves every other `title=` native. + +### A1 — Kobalte v2-alpha Tooltip, not hand-rolled + +The `.cx-tooltip` CSS was authored FOR Kobalte from day one: + +> `tooltip.css:1-2`: "Tooltip — .cx-tooltip (D3, Kobalte). Elev-1 float, open +> delay --cx-tooltip-delay (the delay is Kobalte's timing prop — this owns the +> visual box)." + +and the component spec agrees (`apps/ui/src/design/components.md:458-460` — +"**Class:** `.cx-tooltip` … open delay `--cx-tooltip-delay` (400ms) is +Kobalte's timing prop"). A tooltip is squarely DL-150's "a11y-hard behavior" +(hover+focus open, delay/skip-delay timing, Escape dismiss, safe-area +pointer-travel, `aria-describedby` wiring) — the class of thing we adopt +Kobalte for, unlike the ShortcutsOverlay's modal chrome which D5 ratified +hand-rolled (`ShortcutsOverlay.tsx:6-7` — "Hand-rolled modal on the +`.cx-dialog` convention … no @kobalte/core"). The palette already ships the +same alpha (`Palette.tsx:19` — `import { Search } from +"@kobalte/core/search";`), so the version risk is already on the books. + +The installed v2-alpha API (grounded from +`apps/ui/node_modules/@kobalte/core/dist/tooltip/index.d.ts` re-exporting +`dist/index/CDsbCLm32.d.ts`) is the familiar four-part anatomy, and it gives +us every behavior the coaching layer needs out of the box: + +- `Tooltip` (Root) — `TooltipRootOptions` (`CDsbCLm32.d.ts:65-101`) carries + `openDelay?: number` (`:82-83`), `closeDelay`, `skipDelayDuration`, and + `triggerOnFocusOnly?: boolean` with the default we want: "By default, opens + for both focus and hover" (`:77-81`). Root doc: "A popup that displays + information related to an element when the element receives keyboard focus + or the mouse hovers over it" (`:103-106`). +- `Tooltip.Trigger` — polymorphic, `declare function TooltipTrigger` (`:127`), and its render props include + `"aria-describedby": string | undefined` (`:120-122`) — the ARIA wiring is + automatic. +- `Tooltip.Content` — render props carry `role: "tooltip"` + (`:48-50`); we put `class="cx-tooltip"` on it. +- `Tooltip.Portal` — portals content to `body` (`:58-62`). + +So the WAI-ARIA bar (tooltip role + `aria-describedby` + focus reveal + +Escape dismiss) is met by the primitive, and our component only owns content +and styling. `openDelay` is passed as `400` — the number mirrored by the +`--cx-tooltip-delay: 400ms` token (`tokens.css:227`), per the tooltip.css +comment that the delay "is Kobalte's timing prop — this owns the visual box" +(the token documents the value; Kobalte's number prop enforces it — see D5). + +### A2 — Component shape: composable trigger, encapsulated content + +Solid v2's reactive prop helpers are `omit`/`merge` (v1's `splitProps`/ +`mergeProps` are gone), so a monolithic `` +wrapper that forwards a subset of props to the trigger is buildable +v2-cleanly via `omit` (Kobalte's own Tooltip does exactly this internally). +It is nonetheless the wrong shape. The three-part API instead mirrors +Kobalte's own anatomy, keeps call sites authoring their own trigger element +(same class/onClick/aria attributes they have today), and avoids a bespoke +prop-forwarding contract — the component encapsulates only what is new: + +```tsx +// apps/ui/src/components/CoachTip.tsx +import { Tooltip } from "@kobalte/core/tooltip"; + +/** Root with the house 400ms open delay; hover+focus reveal is Kobalte's + * default (triggerOnFocusOnly stays unset). */ +export const CoachTip: Component>; + +/** Re-export: the call site's existing