Skip to content

feat(annotate): hide HTML tools by default, toggle chords for annotate mode and tools, shortcut tooltips on the header controls - #1531

Merged
backnotprop merged 3 commits into
mainfrom
feat/html-surface-chrome-defaults
Sep 15, 2026
Merged

backnotprop merged 3 commits into
mainfrom
feat/html-surface-chrome-defaults

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Three owner requests for the raw-HTML annotate surface.

"In HTML mode, when we annotate an HTML file on its own, default to hiding the tools."
"A keyboard shortcut to turn on and off the annotate capability would be nice. I know I can Escape it; how do I bring it back?"
"On hover, a more integrated tooltip that gives the description of each of those header controls and their keyboard shortcut."

1. Tools hidden by default

DEFAULT_HTML_CHROME_STATE.toolsHidden is now true, and App seeds
htmlToolsHidden true to match so there is no flash of floating chrome before
the restore effect runs. The page gets the whole viewport; the header eye renders
its "Show tools" state from the first paint, and the compact Options menu reads
"Show tools" on a fresh session.

A fresh persisted record still wins in both directions — a reviewer who
explicitly showed the tools keeps them next session — and hidden can never strand
anyone, because the control that flips it back lives in the header (and in the
compact menu), never in the hidden chrome. That is the same reasoning that already
made restoring a hidden cookie safe.

One consequence worth knowing: the version-diff "Show changes" control lives in
that floating cluster, so on a fresh session it is behind the eye. Documented in
AGENTS.md rather than moved.

2. Toggle chords

Mod+Shift+A was already a real toggle in the app — handleHtmlAnnotateToggle
flips htmlAnnotateArmed, and the bridge forwards the chord from inside the
iframe — but the scope described it as a re-arm only and AGENTS.md said the same.
It is now described as the toggle it is and pinned by a test that presses it
twice, so the answer to "Esc dropped me to Interact, how do I get back?" is the
same chord.

New: Mod+Shift+X toggles the tools (the header eye), through the same scope
— so it reaches the in-app help modal and the generated marketing shortcuts page —
and mirrored inside the sandboxed iframe by the bridge on the capture phase
exactly like the annotate chord. The bridge message is additive
(plannotator-bridge-tools-toggle, same token/origin path), so no protocol bump.
The tools chord is deliberately not gated on documentReadOnly, matching the
eye, which renders on read-only documents.

3. Tooltips on the header controls

The eye, pen and Refresh leaned on the native title, which never appears on
keyboard focus, is unstyled, and cannot show a chord legibly. They now use the
app's existing Tooltip (the same primitive the review header and
DecisionControl use — no new dependency): two lines, the control's description
over its shortcut as keycaps rendered through the registry's platform-aware
formatter.

The description strings are the same host-overridable labels values the titles
carried, so a @plannotator/ui consumer's overrides keep working. A new
shortcuts prop overrides the bindings per control and defaults to the
html-annotate scope, so a tooltip cannot claim a chord the app does not
dispatch. Refresh has no chord and renders no keycap row (the owner did not ask
for one; adding a binding was not free of collision risk, so it was left alone).

Decisions

Live-app scope: yes, same default. isHtmlSurface covers raw-HTML files,
linked .html docs and live-app sessions (mode: "annotate-app"), and all three
now open hidden. Same header, same eye, same reasoning — the page gets the
viewport — and one default is simpler to explain than a surface-aware split. No
second cookie was introduced.

Tools binding: Mod+Shift+X, chosen for being un-mnemonic. Every mnemonic
candidate is claimed by a browser: H is Chrome's Home and Firefox's history
library, E/I/J/K/C are devtools, B/O bookmarks, V
paste-as-plain-text, T reopen-tab. Mod+Shift+. would have been the natural
sibling of focus mode (Mod+.) but the matcher compares event.key exactly, and
Shift+. reports > — it would never fire without an engine change. X is
unassigned in Chrome, Firefox, Safari and Edge on every platform, and free in
both plan registries (asserted by a single-claimant test).

Draft on toggle-off: closed, same as Esc. No new code was needed — the
bridge's set-annotate-mode(false) handler already tears down every pending
affordance (posts selection-clear, clears the pending selection, pin, hover and
multi-target). So disarming from the chord, from inside the iframe, or from the
header pen all land in the same state, and a draft can never be left floating over
a page that now takes clicks.

Test plan

  • DOM_TESTS=1 bun test packages/ui packages/editor — 1616 pass / 4 fail, the
    4 being the pre-existing order-dependent DocBadges open-in placement failures
    (they pass in isolation: 8/8; baseline on this branch point was the same 4).
    Six tests added: the two chords in App.htmlChrome.test.tsx, the bridge
    forwarding in htmlLiveProtocol.test.tsx, the single-claimant binding guard in
    shortcuts.test.ts, and two tooltip tests in HtmlSurfaceControls.test.tsx
    (hover AND keyboard focus; the keycaps are asserted from
    formatShortcutBindingTokens, never hardcoded, so the test cannot pass while
    lying to non-Mac users).
  • bun test packages/ui/shortcuts.test.ts — 18 pass.
  • bun run typecheck — clean.
  • bun run --cwd packages/ui smoke:package — packs, resolves and installs
    externally.
  • bun run --cwd apps/review build && bun run build:hook — clean.
  • bun run build:marketing — clean; the generated shortcuts page now lists
    "Toggle annotate mode" and "Show or hide tools" on both plan surfaces.

Existing tests updated rather than deleted: the htmlChrome defaults, the App
chrome contract (now "default hidden", plus a new case proving a fresh
toolsHidden: false cookie still wins), and the HtmlSurfaceControls string pin
(the deliberate copy pin survives; it now asserts the accessible name that title
used to supply, which is the regression that would otherwise slip through).

Screenshots

Captured against a live plannotator annotate sample.html session in Chromium at
1440x900 (2x):

  1. First load — the rendered page fills the viewport, no sidebar tongue tabs,
    no comment/attachments cluster, and the header eye shows its eye-off icon with
    aria-pressed="true".
  2. After Mod+Shift+X — tongue tabs on the left, Images/Comment cluster
    top-right, eye flipped to open (aria-pressed="false").
  3. Eye tooltip on hover — "Show tools" over ⌘ ⇧ X keycaps, positioned below
    the button.
  4. Pen tooltip on hover — the annotate-mode description over ⌘ ⇧ A.

Docs

AGENTS.md (the eye/pen interaction-model paragraph, the Mod+Shift+A sentence,
the tooltip contract, the htmlChrome default), packages/ui/HANDOFF.md (the
published component's props), and a new "HTML Surface Header Manual Checklist" in
tests/UI-TESTING.md.

An HTML document is authored to fill the viewport, so a raw-HTML (or
live-app) annotate session now opens with the floating chrome over the
page removed and the header eye rendering its "Show tools" state from
the first paint. DEFAULT_HTML_CHROME_STATE.toolsHidden flips to true and
App seeds htmlToolsHidden true to match, so there is no flash of tools
before the restore effect runs.

A fresh persisted record still wins in both directions: a reviewer who
explicitly showed the tools keeps them next session. Hidden can never
strand anyone — the eye lives in the header on desktop and the compact
Options menu offers "Show tools" — which is the same reasoning that
already made restoring a hidden cookie safe.
…r the tools

Mod+Shift+A already flipped annotate mode in both directions in the app;
the scope described it as a re-arm only. It is now documented as the
toggle it is — the answer to "Esc dropped me to Interact, how do I get
back?" — and pinned by a test that presses it twice.

Adds Mod+Shift+X for the header eye, wired through the same scope so it
reaches the help modal and the generated marketing shortcuts page, and
mirrored inside the sandboxed iframe by the bridge on the capture phase
exactly like the annotate chord (additive message type, no protocol
bump), so it works whichever document owns focus. The binding is
deliberately un-mnemonic: every mnemonic letter is a browser chord
(H Home/history, E/I/J/K/C devtools, B/O bookmarks, V paste-as-plain-text,
T reopen tab), and a chord the browser eats is worse than an arbitrary
one. The tools chord is not gated on documentReadOnly, matching the eye.

Disarming through either path already tears the pending draft down via
the bridge's set-annotate-mode(false) handler, so toggling to Interact
cannot leave a draft floating over a page that now takes clicks.
The eye, the pen and Refresh leaned on the native title attribute, which
never appears on keyboard focus, is unstyled, and cannot show a shortcut
legibly. They now use the app's Tooltip (the same primitive the review
header and DecisionControl use — no new dependency): two lines, the
control's description over its shortcut as keycaps rendered through the
registry's platform-aware formatter, so a Mac shows glyphs and everyone
else Ctrl rather than a hardcoded Cmd.

The description strings are the same host-overridable labels the titles
carried, so a @plannotator/ui consumer's overrides keep working; the new
shortcuts prop overrides the bindings per control and defaults to the
html-annotate scope, so a tooltip cannot claim a chord the app does not
dispatch. Because title no longer supplies the accessible name, the pen
states an aria-label (defaulting to its description), the eye keeps its
sr-only text and Refresh its aria-label, and the shortcut is attached as
a persistent aria-describedby span — this Base UI build puts no ARIA on
the popup at all, so the tooltip alone would never be announced. Refresh
has no chord and renders no keycap row.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant