From 6faa0f4960f4692a94aa313cde465f44fcefdfa7 Mon Sep 17 00:00:00 2001 From: vickeykumar Date: Fri, 9 Oct 2026 09:06:13 +0530 Subject: [PATCH] Tips for the buttons of the workspace and of Genie Hovering a button shows a small pane by it with the button's name, its shortcut as key caps and one line, in place of the browser's tooltip. It is made from what the button has (title, and data-tip-name, -keys, -desc where they help); four buttons that change with their state are read from it. A button's title is set aside only while the mouse is on it and put back after; a title set meanwhile wins. No tips for touch, during the tour, for a button whose menu is open, or inside the editor, the terminal and the file tree. A button reached with Tab shows its tip too. Co-Authored-By: Claude Opus 5.5 --- docs/lld/06-frontend.md | 1 + src/js/src/page/20-tips.js | 247 ++++++++++++++++++ src/js/src/page/index.js | 1 + src/js/src/page/lib-tips.mjs | 57 ++++ src/js/test/lib-tips.test.mjs | 66 +++++ src/resources/chat-widget/src/index.ts | 5 +- src/resources/chat-widget/src/widget.html | 12 +- .../chat-widget/src/widgetHtmlString.ts | 2 +- src/resources/css/ui-refresh.css | 62 +++++ src/resources/index.html | 32 +-- 10 files changed, 461 insertions(+), 24 deletions(-) create mode 100644 src/js/src/page/20-tips.js create mode 100644 src/js/src/page/lib-tips.mjs create mode 100644 src/js/test/lib-tips.test.mjs diff --git a/docs/lld/06-frontend.md b/docs/lld/06-frontend.md index 42a06d1b..df44e300 100644 --- a/docs/lld/06-frontend.md +++ b/docs/lld/06-frontend.md @@ -61,6 +61,7 @@ What the parts do, together: - **Layout.** Split panes (split.js), Maximize (`ToggleFunction`, Esc restores; an icon button, the first one in the app bar, before Files), Genie pinned beside the IDE (below), Stacked or Side by side (`ToggleRotateEditor`), Hide/Show editor (`ToggleEditor` + `syncEditorLayoutUI`), and the phone layout (`applyMobileLayout`). - **Genie pinned beside the IDE** (`chat-widget/src/index.ts` "pinned beside the IDE", `02-layout.js`, `#genie-dock` in `index.html`). A pin button in the panel's header (between Peer chat and ×) docks the panel as a column at the right of the workspace row, after the editor/terminal split, like a side bar. The panel element is moved into `#genie-dock` (class `is-docked`, which drops the floating position, size and shadow); a grip on the dock's left edge resizes it (drag, arrow keys, double click for the default 380px; 300px to 680px and at most 60% of the row; `--genie-dock-w`, remembered in `localStorage` `genie-dock-width`). The pin itself is `localStorage` `genie-pinned`. While pinned, the widget fires `genie-pin` ({pinned}) on `window` and `02-layout.js` stacks the editor over the terminal (`SetEditorDirection`; a hidden editor stays hidden); unpinning puts the layout back unless the user chose one meanwhile. × hides a pinned panel and keeps the pin; Ask Genie and Ctrl+K bring it back docked. The dock is inside `#ide-shell`, so Maximize keeps it. Below 1100px or on a phone the pin button is hidden and the panel floats as before (the choice is kept, and the panel docks again when the window is wide enough). The widget tells the page to refit (a `resize` event, at most one a frame) only when the dock comes, goes or changes width: `setDockWidth` runs on every window resize, and firing one from there each time is a loop that refits the editor on every frame and closes the editor's right-click menu, which dismisses itself on a resize. A size the floating panel was dragged to does not apply in the dock (`!important`), and its corner handle is hidden there. At 800px and below the Maximize button is hidden (the phone app bar has no room); the command palette still has it. +- **Button tips** (`js/src/page/20-tips.js`, `lib-tips.mjs`, `.ide-tip` in `css/ui-refresh.css`). Hovering a button of the workspace (`#ide-shell`), of the Genie panel or the floating Ask Genie button shows a small light pane by it: the button's name, its shortcut as key caps and one line. It replaces the browser's tooltip and is made from what the button already has: `title` is the line, `data-tip-name` the name (else the button's visible words or `aria-label`), `data-tip-keys` the shortcut (`Mod` is Ctrl, or ⌘ on a Mac) and `data-tip-desc` a line written for the tip. Four buttons change with their state (Maximize or Restore, the layout switch, Pin or Unpin, Send or Stop): the `NAMES` table reads the state, so no other code keeps a tip current. While the mouse is on a button its `title` is moved to `data-tip-title`, so that the browser's tooltip does not show on top, and moved back when the mouse leaves; a title set meanwhile wins. Code that removes a title must remove `data-tip-title` too (the send button's `showAgentState` does). The pane shows 350 ms after the mouse arrives, at once when another tip was just showing, under the button or above it when there is no room, and never outside the window (`placeTip`). A press, a key, a scroll or a resize puts it away. A button reached with Tab shows its tip too (`:focus-visible`). There are no tips for a touch pointer, during the tour, for a button whose menu is open (`aria-haspopup` with `aria-expanded`), or inside the editor, the terminal and the file tree. The pane takes no pointer events and sits above the Genie panel and below the menus and the palette (`z-index` 10030). A page that loads the widget without `scribbler.js` keeps the browser's own tooltips. - **Editor.** Ace setup, theme persistence, language mode from `data-editor`, `updateEditorContent`, download and upload. `defineOpenreplAceTheme()` registers the default "OpenREPL Dark" Ace theme (`ace/theme/openrepl_dark`, the mockup's colours); every other Ace theme stays in `#select-theme`. The terminal uses the same 14px size as the editor (set in `js/src/xterm.ts`). - **Language switch.** `actionOnchange` fetches `/demo?q=` and renders the demo animation, usage table and links (LLD 04). - **Run and debug.** `CompileandRun`, `RunandDebug` dispatch `optionrun` and `optiondebug`. `ToggleReconnect` reconnects. diff --git a/src/js/src/page/20-tips.js b/src/js/src/page/20-tips.js new file mode 100644 index 00000000..aa18744b --- /dev/null +++ b/src/js/src/page/20-tips.js @@ -0,0 +1,247 @@ +// Tips for the buttons of the workspace and of the Genie panel: a small pane by +// the button with its name, its shortcut and one line about it. Part of the page +// script (T20), bundled into js/scribbler.js. +// +// A tip is made from what the button already has: +// title the line about it (the browser's own tooltip, which this replaces) +// data-tip-name its name; without one, its visible words or its aria-label +// data-tip-keys its shortcut ("Mod Enter": Mod is Ctrl, or ⌘ on a Mac) +// data-tip-desc a line written for the tip, where the title is not one +// A few buttons change with their state (Maximize or Restore, Pin or Unpin, +// Send or Stop): NAMES below reads the state, so nothing else has to keep the +// tip up to date. +// +// Nothing here changes what a button does. While the mouse is on a button its +// title is kept in data-tip-title, so that the browser does not show its own +// tooltip on top, and put back when the mouse leaves. Code that REMOVES a title +// (the Genie panel's send button does) must remove data-tip-title with it, or +// the old one would come back. +// +// Tips are for a mouse and for the keyboard (a button reached with Tab). There +// are none on a touch screen, during the tour, or for a button whose menu is open. + +import { tipContent, placeTip } from "./lib-tips.mjs"; + +const SCOPE = "#ide-shell, #chat-widget__container, .genie-fab"; +const CANDIDATE = "[title], [data-tip-title], [data-tip-name], [data-tip-desc]"; +// not the editor, the terminal or the file tree: their own titles stay as they are +const NOT_IN = "#editor, .terminal, #file-browser, .genie-menu, .ctx-menu"; +const SHOW_AFTER_MS = 350; +const QUICK_WITHIN_MS = 400; // from one button to the next, the tip follows at once + +const NAMES = { + "togglescreen-button": function (el) { + const on = el.getAttribute("aria-pressed") === "true"; + return on + ? { name: "Restore", keys: "Esc", desc: "Back to the normal size." } + : { name: "Maximize", keys: "", desc: "Fill the window with the workspace. Esc restores it." }; + }, + "rotate-button": function (el) { + return { name: visibleWords(el) || "Layout" }; + }, + "chat-widget__pin": function (el) { + return { name: el.getAttribute("aria-pressed") === "true" ? "Unpin" : "Pin" }; + }, + "chat-widget__submit": function (el) { + return el.classList.contains("is-stop") + ? { name: "Stop", keys: "", desc: "Stop the task Genie is working on." } + : { name: "Send", keys: "Enter", desc: "Send your message to Genie." }; + }, +}; + +let pane = null; +let hover = null; // { el, watch } the button the mouse is on +let shown = null; // the button the pane is shown for +let timer = 0; +let hiddenAt = 0; + +function isMac() { + return typeof window.IS_MAC === "boolean" ? window.IS_MAC : /Mac|iPhone|iPad/.test(navigator.platform || ""); +} + +function visibleWords(el) { + const copy = el.cloneNode(true); + copy.querySelectorAll("kbd, svg, .visually-hidden, .social-count, input, select").forEach(function (n) { + n.remove(); + }); + const words = (copy.textContent || "").replace(/\s+/g, " ").trim(); + return words.length <= 28 ? words : ""; +} + +function candidate(target) { + if (!(target instanceof Element)) return null; + const el = target.closest(CANDIDATE); + if (!el || !el.closest(SCOPE) || el.closest(NOT_IN)) return null; + return el; +} + +function contentOf(el) { + const fromState = NAMES[el.id] ? NAMES[el.id](el) : {}; + const label = el.getAttribute("aria-label") || ""; + return tipContent({ + name: fromState.name || el.getAttribute("data-tip-name") || visibleWords(el) || (label.length <= 28 ? label : ""), + keys: fromState.keys !== undefined ? fromState.keys : el.getAttribute("data-tip-keys"), + desc: fromState.desc || el.getAttribute("data-tip-desc") || el.getAttribute("data-tip-title") || el.getAttribute("title") || "", + isMac: isMac(), + }); +} + +function quiet(el) { + // not during the tour, not for a button whose menu or dialog is open (a button + // that only says a panel is shown, like Files, keeps its tip), not for one that is off + const menuOpen = el.hasAttribute("aria-haspopup") && el.getAttribute("aria-expanded") === "true"; + return !!document.querySelector(".introjs-overlay") || menuOpen || el.disabled === true; +} + +function ensurePane() { + if (pane) return pane; + pane = document.createElement("div"); + pane.className = "ide-tip"; + pane.setAttribute("role", "tooltip"); + pane.hidden = true; + const arrow = document.createElement("span"); + arrow.className = "ide-tip__arrow"; + const head = document.createElement("div"); + head.className = "ide-tip__head"; + const desc = document.createElement("div"); + desc.className = "ide-tip__desc"; + pane.append(arrow, head, desc); + document.body.appendChild(pane); + return pane; +} + +function show(el) { + if (!el.isConnected || quiet(el)) return; + const c = contentOf(el); + if (!c.name) return; + const p = ensurePane(); + const head = p.querySelector(".ide-tip__head"); + const desc = p.querySelector(".ide-tip__desc"); + head.textContent = ""; + const name = document.createElement("span"); + name.textContent = c.name; + head.appendChild(name); + c.keys.forEach(function (k) { + const kbd = document.createElement("kbd"); + kbd.textContent = k; + head.appendChild(kbd); + }); + desc.textContent = c.desc; + desc.hidden = !c.desc; + p.hidden = false; + p.classList.remove("is-shown"); + p.style.left = "0px"; + p.style.top = "0px"; + const at = placeTip(el.getBoundingClientRect(), { width: p.offsetWidth, height: p.offsetHeight }, { width: window.innerWidth, height: window.innerHeight }); + p.style.left = at.left + "px"; + p.style.top = at.top + "px"; + p.classList.toggle("ide-tip--above", at.above); + p.querySelector(".ide-tip__arrow").style.left = at.arrow + "px"; + void p.offsetWidth; // so that it fades in from here + p.classList.add("is-shown"); + shown = el; +} + +function hide() { + clearTimeout(timer); + if (pane && !pane.hidden) { + pane.hidden = true; + pane.classList.remove("is-shown"); + hiddenAt = Date.now(); + } + shown = null; +} + +// ---- the button's own title, while the mouse is on it --------------------------------- + +function keepTitle(el) { + const t = el.getAttribute("title"); + if (t === null) return; + el.setAttribute("data-tip-title", t); + el.removeAttribute("title"); +} + +function giveTitleBack(el) { + const kept = el.getAttribute("data-tip-title"); + if (kept === null) return; + el.removeAttribute("data-tip-title"); + if (!el.hasAttribute("title")) el.setAttribute("title", kept); +} + +function enter(el) { + leave(); + keepTitle(el); + // a title set while the mouse is still there (a click that changes the button) is kept too + const watch = new MutationObserver(function () { + if (el.hasAttribute("title")) { + keepTitle(el); + if (shown === el) show(el); + } + }); + watch.observe(el, { attributes: true, attributeFilter: ["title"] }); + hover = { el: el, watch: watch }; + clearTimeout(timer); + timer = setTimeout(function () { + if (hover && hover.el === el) show(el); + }, Date.now() - hiddenAt < QUICK_WITHIN_MS ? 0 : SHOW_AFTER_MS); +} + +function leave() { + if (!hover) return; + hover.watch.disconnect(); + giveTitleBack(hover.el); + if (shown === hover.el) hide(); + else clearTimeout(timer); + hover = null; +} + +// ---- the mouse --------------------------------------------------------------------------- + +document.addEventListener("pointerover", function (ev) { + if (ev.pointerType !== "mouse") return; + const el = candidate(ev.target); + if (hover && hover.el === el) return; + if (el) enter(el); + else leave(); +}); + +document.addEventListener("pointerout", function (ev) { + if (!hover || ev.pointerType !== "mouse") return; + const to = ev.relatedTarget; + if (to instanceof Node && hover.el.contains(to)) return; + leave(); +}); + +// a button that is gone (a panel that closed) sends no pointerout +document.addEventListener("pointermove", function () { + if (hover && !hover.el.isConnected) leave(); +}); + +// a tip is in the way of nothing: any press, key, scroll or resize puts it away +// (the mouse may still be on the button: the tip comes back when it comes back) +["pointerdown", "keydown", "wheel"].forEach(function (type) { + document.addEventListener(type, hide, { capture: true, passive: true }); +}); +window.addEventListener("scroll", hide, true); +window.addEventListener("resize", hide); +window.addEventListener("blur", hide); + +// ---- the keyboard ------------------------------------------------------------------------ + +document.addEventListener("focusin", function (ev) { + const el = candidate(ev.target); + if (!el || el !== ev.target) return; + let byKeyboard = false; + try { + byKeyboard = el.matches(":focus-visible"); + } catch (e) { + // an old browser: no tips from the keyboard + } + if (byKeyboard) show(el); +}); + +document.addEventListener("focusout", function (ev) { + if (shown && shown === ev.target && !(hover && hover.el === shown)) hide(); +}); + +export {}; // an ES module: strict mode, bundled by webpack diff --git a/src/js/src/page/index.js b/src/js/src/page/index.js index 976378c6..02eab4ab 100644 --- a/src/js/src/page/index.js +++ b/src/js/src/page/index.js @@ -20,3 +20,4 @@ import "./16-language-pages"; import "./17-share-code"; import "./18-genie-review"; import "./19-genie-actions"; +import "./20-tips"; diff --git a/src/js/src/page/lib-tips.mjs b/src/js/src/page/lib-tips.mjs new file mode 100644 index 00000000..f5ab11f5 --- /dev/null +++ b/src/js/src/page/lib-tips.mjs @@ -0,0 +1,57 @@ +// The text and the place of a button's tip (20-tips.js), without the page, so +// that node can test it (src/js/test). + +// What a tip says: the button's name, its shortcut as key caps, one line about it. +// name: what the button is called ("Run"); "" if nothing names it +// keys: its shortcut, space separated ("Mod Enter"); Mod is Ctrl, or ⌘ on a Mac +// desc: the line about it (the button's own title, unless one was written for the tip) +// A shortcut written at the end of the line, in brackets, is dropped when the +// key caps show it. A line that only repeats the name is dropped. +export function tipContent(input) { + const name = clean(input.name); + const keys = clean(input.keys) + .split(" ") + .filter(Boolean) + .map(function (k) { + return k === "Mod" ? (input.isMac ? "⌘" : "Ctrl") : k; + }); + let desc = clean(input.desc); + if (keys.length) desc = desc.replace(/\s*\([^()]*\)\s*$/, ""); + if (desc && name && sameWords(desc, name)) desc = ""; + // a tip with only a line shows it as its name + if (!name && desc) return { name: desc, keys: keys, desc: "" }; + // a line reads as a sentence, whoever wrote it + if (desc && !/[.!?\u2026]$/.test(desc)) desc += "."; + return { name: name, keys: keys, desc: desc }; +} + +function clean(s) { + return String(s == null ? "" : s).replace(/\s+/g, " ").trim(); +} + +function sameWords(a, b) { + const norm = function (s) { + return s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + }; + return norm(a) === norm(b); +} + +// Where a tip goes: under its button, centred on it; above when there is no +// room under it; never outside the window. `arrow` is where the pointer of the +// tip sits, from the tip's left edge. +// anchor: {left, top, right, bottom} of the button, in the window +// tip: {width, height} +// view: {width, height} of the window +export function placeTip(anchor, tip, view, gap, margin) { + gap = gap === undefined ? 8 : gap; + margin = margin === undefined ? 8 : margin; + const centre = (anchor.left + anchor.right) / 2; + const maxLeft = Math.max(margin, view.width - tip.width - margin); + const left = Math.round(Math.min(maxLeft, Math.max(margin, centre - tip.width / 2))); + const roomBelow = view.height - anchor.bottom - gap - margin; + const roomAbove = anchor.top - gap - margin; + const above = roomBelow < tip.height && roomAbove > roomBelow; + const top = Math.round(above ? anchor.top - gap - tip.height : anchor.bottom + gap); + const arrow = Math.round(Math.min(tip.width - 14, Math.max(14, centre - left))); + return { left: left, top: top, above: above, arrow: arrow }; +} diff --git a/src/js/test/lib-tips.test.mjs b/src/js/test/lib-tips.test.mjs new file mode 100644 index 00000000..a799060c --- /dev/null +++ b/src/js/test/lib-tips.test.mjs @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { tipContent, placeTip } from "../src/page/lib-tips.mjs"; + +test("a tip has the button's name, its keys and its line", () => { + assert.deepEqual(tipContent({ name: "Run", keys: "Mod Enter", desc: "Run the editor code (Ctrl+Enter)", isMac: false }), { + name: "Run", + keys: ["Ctrl", "Enter"], + desc: "Run the editor code.", + }); + // on a Mac the same shortcut is shown with its own key + assert.deepEqual(tipContent({ name: "Run", keys: "Mod Enter", desc: "Run the editor code (⌘↵)", isMac: true }).keys, ["⌘", "Enter"]); +}); + +test("brackets at the end of a line stay when they are not a shortcut the key caps show", () => { + const t = tipContent({ name: "New tab", keys: "", desc: "New terminal tab (up to 5)" }); + assert.equal(t.desc, "New terminal tab (up to 5)."); + assert.deepEqual(t.keys, []); + // brackets in the middle are never touched + assert.equal(tipContent({ name: "X", keys: "Esc", desc: "Close (the panel) now (Esc)" }).desc, "Close (the panel) now."); +}); + +test("a line that only repeats the name is left out", () => { + assert.equal(tipContent({ name: "Hide files", desc: "Hide files" }).desc, ""); + assert.equal(tipContent({ name: "Commands", desc: "commands." }).desc, ""); + assert.equal(tipContent({ name: "Files", desc: "Show or hide files" }).desc, "Show or hide files."); + // a line that ends a sentence already is left as it is + assert.equal(tipContent({ name: "Close", desc: "Hide Genie. A task carries on." }).desc, "Hide Genie. A task carries on."); +}); + +test("a button with only a line shows it as its name, and one with nothing has no tip", () => { + assert.deepEqual(tipContent({ desc: "Forks opened from this page" }), { name: "Forks opened from this page", keys: [], desc: "" }); + assert.equal(tipContent({}).name, ""); + assert.equal(tipContent({ name: null, keys: null, desc: null }).name, ""); +}); + +test("a tip goes under its button, centred, with its pointer on the button", () => { + const at = placeTip({ left: 400, right: 440, top: 10, bottom: 46 }, { width: 200, height: 50 }, { width: 1200, height: 800 }); + assert.deepEqual(at, { left: 320, top: 54, above: false, arrow: 100 }); +}); + +test("a tip near an edge stays in the window, and its pointer still points at the button", () => { + const left = placeTip({ left: 4, right: 40, top: 10, bottom: 46 }, { width: 200, height: 50 }, { width: 1200, height: 800 }); + assert.equal(left.left, 8); + assert.equal(left.arrow, 14); // the button's middle (22) is 14 from the tip's edge + const right = placeTip({ left: 1150, right: 1190, top: 10, bottom: 46 }, { width: 200, height: 50 }, { width: 1200, height: 800 }); + assert.equal(right.left, 992); + assert.equal(right.arrow, 178); + // a pointer never sits on the tip's rounded corner + const corner = placeTip({ left: 1196, right: 1200, top: 10, bottom: 46 }, { width: 200, height: 50 }, { width: 1200, height: 800 }); + assert.equal(corner.arrow, 186); +}); + +test("a tip with no room under its button goes above it", () => { + const at = placeTip({ left: 400, right: 440, top: 760, bottom: 790 }, { width: 200, height: 50 }, { width: 1200, height: 800 }); + assert.equal(at.above, true); + assert.equal(at.top, 702); + // with room on neither side it stays under, where it started + const cramped = placeTip({ left: 400, right: 440, top: 20, bottom: 60 }, { width: 200, height: 50 }, { width: 1200, height: 90 }); + assert.equal(cramped.above, false); +}); + +test("a window narrower than the tip does not push it off the left edge", () => { + const at = placeTip({ left: 10, right: 50, top: 10, bottom: 40 }, { width: 300, height: 50 }, { width: 200, height: 600 }); + assert.equal(at.left, 8); +}); diff --git a/src/resources/chat-widget/src/index.ts b/src/resources/chat-widget/src/index.ts index d8d3176a..f05190d3 100644 --- a/src/resources/chat-widget/src/index.ts +++ b/src/resources/chat-widget/src/index.ts @@ -1218,7 +1218,10 @@ function showAgentState(state: AgentState) { if (submitBtn) { submitBtn.classList.toggle("is-stop", state !== "idle"); submitBtn.setAttribute("aria-label", state === "idle" ? "Send" : "Stop the task"); - if (state === "idle") submitBtn.removeAttribute("title"); + if (state === "idle") { + submitBtn.removeAttribute("title"); + submitBtn.removeAttribute("data-tip-title"); // the copy the page's tips keep while the mouse is on it (20-tips.js) + } else submitBtn.setAttribute("title", "Stop the task"); if (state === "stopping") submitBtn.setAttribute("disabled", ""); else submitBtn.removeAttribute("disabled"); diff --git a/src/resources/chat-widget/src/widget.html b/src/resources/chat-widget/src/widget.html index 41a08e1c..aad83f18 100644 --- a/src/resources/chat-widget/src/widget.html +++ b/src/resources/chat-widget/src/widget.html @@ -6,7 +6,7 @@ -