From 818def822db7e3005a3b39104c6b73cc1a0a4e74 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 15 Sep 2026 10:49:58 -0400 Subject: [PATCH] =?UTF-8?q?Shell:=20tooltips=20at=20once,=20one=20or=20two?= =?UTF-8?q?=20words;=20text-size=20buttons=20are=20drawn=20=E2=88=92=20and?= =?UTF-8?q?=20+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every header and top-bar button's title becomes a tip the shell shows the moment the pointer arrives (the browser's own waits a second and looks different everywhere); it moves from `title` to data-tip so the native one never appears, and a module that sets `title` again is picked up on the next hover. The shell's own labels are "Settings" and "Remove"; the top bar's are "Fullscreen", "Add tile", "Layouts", "Admin"; Slack, PCO Plan, ProdCom and Timers shorten theirs to match. A− / A+ are a drawn − and + in Slack, PCO Plan and ProdCom, sized like every other header icon. Co-Authored-By: Claude Fable 5.1 --- docs/MODULE-GUIDE.md | 14 ++++-- modules/pco-plan/client.js | 10 ++-- modules/prodcom-transcript/client.js | 24 ++++----- modules/propresenter-timers/client.js | 2 +- modules/slack/client.js | 12 +++-- public/index.html | 8 +-- public/shell.js | 70 ++++++++++++++++++++++++++- public/style.css | 18 +++++++ 8 files changed, 127 insertions(+), 31 deletions(-) diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index bb55b86..ce6c0eb 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -330,16 +330,17 @@ bar, next to the shell's gear/close buttons: ```js const btn = moduleApi.header.addButton({ - icon: '…', // inline SVG string, and/or - label: 'A+', // short text - title: 'Larger text', // hover tooltip + icon: '…', // inline SVG string (13 px), and/or + label: 'Timers ▾', // short text + title: 'Larger text', // tooltip — one or two words; the shell shows + // it the moment the pointer arrives onClick() { … }, }); btn.classList.add('active'); // toggle styling for on/off buttons const menu = moduleApi.header.addMenu({ label: 'Channels ▾', - title: 'Choose visible channels', + title: 'Channels', build(menuEl) { // called on every open with an emptied menu element — fill it with // buttons (give them your scoped classes; the container is styled and @@ -351,7 +352,10 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time Everything you add is removed automatically when your instance stops, so a remount never duplicates controls. Keep them compact (icon buttons, short -labels): the title bar is 32 px tall and shared with the tile's name. +labels): the title bar is 32 px tall and shared with the tile's name. Draw +icons as inline SVG rather than characters — a glyph such as ✕ or + takes +its size from whatever font each browser picks; text-size buttons are a +drawn − and +. **One control per action.** Whatever has a dedicated header button is not offered again in the gear popover: text size is A− / A+ up here (persisted diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index 7bf08f4..5088322 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -10,6 +10,10 @@ const REFRESH_SVG = ''; +/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */ +const MINUS_SVG = ''; +const PLUS_SVG = ''; + export default function create({ root, moduleApi }) { let state = null; let skew = 0; // server clock − local clock, ms @@ -55,7 +59,7 @@ export default function create({ root, moduleApi }) { const refreshBtn = moduleApi.header.addButton({ icon: REFRESH_SVG, - title: 'Re-read the plan from Planning Center now', + title: 'Refresh', onClick() { refreshBtn.classList.add('active'); moduleApi.fetch('/refresh', { method: 'POST' }) @@ -69,8 +73,8 @@ export default function create({ root, moduleApi }) { moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) }); applyPrefs(); } - moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) }); - moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) }); + moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) }); + moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) }); /* ── formatting ─────────────────────────────────────────────────── */ diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js index 76bff3b..439f487 100644 --- a/modules/prodcom-transcript/client.js +++ b/modules/prodcom-transcript/client.js @@ -6,6 +6,10 @@ persists in instance settings, and all network traffic goes through moduleApi (the module's server proxy at /prodcom/*). */ +/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */ +const MINUS_SVG = ''; +const PLUS_SVG = ''; + export default function create({ root, moduleApi }) { /* ── per-instance state ─────────────────────────────────────────── */ @@ -48,7 +52,7 @@ export default function create({ root, moduleApi }) { let channelMenuEl = null; // the open menu's element (filled on each open) const channelsMenu = moduleApi.header.addMenu({ icon: CHANNELS_SVG, - title: 'Choose visible channels', + title: 'Channels', build(menu) { menu.classList.add('pt-menu'); channelMenuEl = menu; @@ -65,7 +69,7 @@ export default function create({ root, moduleApi }) { let timeBtn = null; timeBtn = moduleApi.header.addButton({ icon: CLOCK_SVG, - title: 'Show / hide timestamps', + title: 'Timestamps', onClick() { moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes }); applyInstanceSettings(); @@ -77,8 +81,8 @@ export default function create({ root, moduleApi }) { moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) }); applyInstanceSettings(); } - moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) }); - moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) }); + moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) }); + moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) }); // icon and tooltip track the current direction — applyInstanceSettings sets both const flowBtn = moduleApi.header.addButton({ @@ -93,7 +97,7 @@ export default function create({ root, moduleApi }) { moduleApi.header.addButton({ icon: JUMP_SVG, - title: 'Jump to latest', + title: 'Latest', onClick() { pinnedToLatest = true; jumpBtn.hidden = true; @@ -103,7 +107,7 @@ export default function create({ root, moduleApi }) { const clearBtn = moduleApi.header.addButton({ icon: CLEAR_SVG, - title: 'Clear this tile (does not affect ProdCom)', + title: 'Clear', onClick: clearView, }); clearBtn.classList.add('pt-clear'); @@ -120,9 +124,7 @@ export default function create({ root, moduleApi }) { wrap.classList.toggle('newest-first', up); flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG; flowBtn.classList.toggle('active', up); - flowBtn.title = up - ? 'Newest at top — click for newest at bottom' - : 'Newest at bottom — click for newest at top'; + flowBtn.title = up ? 'Newest first' : 'Oldest first'; jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages'; } applyInstanceSettings(); @@ -174,9 +176,7 @@ export default function create({ root, moduleApi }) { channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : ''; channelCountEl.hidden = !filtering; channelsMenu.button.classList.toggle('active', filtering); - channelsMenu.button.title = filtering - ? `Channels — showing ${visibleCount} of ${channels.size}` - : 'Choose visible channels'; + channelsMenu.button.title = filtering ? `Channels ${visibleCount}/${channels.size}` : 'Channels'; } function rebuildChannelMenu() { diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 5a8149e..7d81938 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -283,7 +283,7 @@ export default function create({ root, moduleApi }) { let menuEl = null; // the open menu's element (filled on each open) const itemsMenu = solo ? null : moduleApi.header.addMenu({ label: 'Timers ▾', - title: 'Choose which timers this tile shows', + title: 'Choose timers', build(menu) { // the shell hands over an emptied element on every open menu.classList.add('tm-menu'); diff --git a/modules/slack/client.js b/modules/slack/client.js index e1dcf71..59637e3 100644 --- a/modules/slack/client.js +++ b/modules/slack/client.js @@ -635,6 +635,10 @@ const emojiMenu = (() => { /* ── the factory ────────────────────────────────────────────────────── */ +/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */ +const MINUS_SVG = ''; +const PLUS_SVG = ''; + export default function create({ root, moduleApi }) { const variant = String(moduleApi.variant || ''); // The entry's preset `view` is authoritative; a tile added under 1.0.0 has @@ -680,11 +684,11 @@ function createTranscriptTile({ root, moduleApi }) { moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, textSizePx(moduleApi) + delta)) }); applyInstanceSettings(); } - moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) }); - moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) }); + moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) }); + moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) }); moduleApi.header.addButton({ icon: JUMP_SVG, - title: 'Jump to latest', + title: 'Latest', onClick() { pinnedToLatest = true; jumpBtn.hidden = true; @@ -1056,7 +1060,7 @@ function createSendTile({ root, moduleApi, mode }) { const gate = root.querySelector('.sl-gate'); const compose = root.querySelector('.sl-compose'); - const lockBtn = moduleApi.header.addButton({ icon: LOCK_SVG, title: 'Lock now', onClick: () => lock('Locked') }); + const lockBtn = moduleApi.header.addButton({ icon: LOCK_SVG, title: 'Lock', onClick: () => lock('Locked') }); lockBtn.style.display = 'none'; function applyInstanceSettings() { diff --git a/public/index.html b/public/index.html index aab5aba..a1bfa52 100644 --- a/public/index.html +++ b/public/index.html @@ -19,7 +19,7 @@ - - + diff --git a/public/shell.js b/public/shell.js index 2ba64e6..88cf4f0 100644 --- a/public/shell.js +++ b/public/shell.js @@ -283,7 +283,7 @@ function buildTile(tile) { if (hasSettings) { const gear = document.createElement('button'); gear.className = 'tile-btn'; - gear.title = 'Tile settings (this tile only)'; + gear.title = 'Settings'; gear.innerHTML = GEAR_SVG; gear.addEventListener('click', (e) => { e.stopPropagation(); @@ -294,7 +294,7 @@ function buildTile(tile) { const close = document.createElement('button'); close.className = 'tile-btn close'; - close.title = 'Remove this tile'; + close.title = 'Remove'; close.innerHTML = CLOSE_SVG; // a drawn cross, the same size in every browser (the ✕ glyph varies) close.addEventListener('click', (e) => { e.stopPropagation(); @@ -1106,6 +1106,72 @@ function tileSchema(tile, man) { }; } +/* ── tooltips: at once on hover, short, one for the whole page ────────── + Every header and top-bar button's `title` becomes a tip the shell shows + the moment the pointer arrives — the browser's own waits about a second + and looks different in each browser. The text moves from `title` to + data-tip so the native one never appears; a module that sets `title` + again later is picked up on the next hover. */ +let tipEl = null; +const TIP_TARGETS = '.tile-btn, .tb-btn, [data-tip]'; + +function tipTextFor(el) { + if (el.title) { + el.dataset.tip = el.title; + if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', el.title); + el.removeAttribute('title'); + } + return el.dataset.tip || ''; +} + +function showTip(el) { + const text = tipTextFor(el); + if (!text) return; + if (!tipEl) { + tipEl = document.createElement('div'); + tipEl.id = 'tip'; + tipEl.setAttribute('role', 'tooltip'); + document.body.appendChild(tipEl); + } + tipEl.textContent = text; + tipEl.hidden = false; + const r = el.getBoundingClientRect(); + const w = tipEl.offsetWidth; + const h = tipEl.offsetHeight; + const left = Math.max(6, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 6)); + let top = r.bottom + 6; + if (top + h > window.innerHeight - 6) top = r.top - h - 6; + tipEl.style.left = `${Math.round(left)}px`; + tipEl.style.top = `${Math.round(top)}px`; + tipEl._for = el; +} + +function hideTip(el = null) { + if (!tipEl || tipEl.hidden) return; + if (el && tipEl._for !== el) return; + tipEl.hidden = true; + tipEl._for = null; +} + +document.addEventListener('pointerover', (e) => { + const el = e.target?.closest?.(TIP_TARGETS); + if (!el || (tipEl && tipEl._for === el && !tipEl.hidden)) return; + showTip(el); +}); +document.addEventListener('pointerout', (e) => { + const el = e.target?.closest?.(TIP_TARGETS); + if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return; + hideTip(el); +}); +document.addEventListener('pointerdown', () => hideTip(), true); +document.addEventListener('focusin', (e) => { + const el = e.target?.closest?.(TIP_TARGETS); + if (el) showTip(el); +}); +document.addEventListener('focusout', () => hideTip()); +window.addEventListener('scroll', () => hideTip(), true); +window.addEventListener('resize', () => hideTip()); + function closeSettingsPopovers() { document.querySelectorAll('.tile-settings').forEach((p) => { p._cleanup?.(); diff --git a/public/style.css b/public/style.css index ddb4f95..b10f060 100644 --- a/public/style.css +++ b/public/style.css @@ -308,6 +308,24 @@ body.menu-hidden #topbar { .tile-btn.active { color: var(--accent); } .tile-btn.is-open, .tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); } + +/* the shell's tooltip: shown at once under the button the pointer is on */ +#tip { + position: fixed; + z-index: 200; + pointer-events: none; + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--panel); + color: var(--text); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); +} +#tip[hidden] { display: none; } .tile-btn svg { width: 13px; height: 13px; display: block; } /* controls a module adds to its own title bar (moduleApi.header) */