diff --git a/package.json b/package.json index 0f2e2cf..6051161 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@isimplifyme/ui", - "version": "1.4.1", + "version": "1.4.2", "description": "React/Next.js UI library for iSimplifyMe properties \u2014 design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.", "homepage": "https://isimplifyme.com", "type": "module", diff --git a/src/concierge.test.tsx b/src/concierge.test.tsx index bd7cf0c..5a8d77d 100644 --- a/src/concierge.test.tsx +++ b/src/concierge.test.tsx @@ -11,6 +11,9 @@ import { createRoot, type Root } from 'react-dom/client'; import { act } from 'react'; import ConciergeWidget from './concierge'; +/** Flips the stubbed matchMedia so a test can render at phone width. */ +const NARROW = { value: false }; + let container: HTMLDivElement; let root: Root; @@ -18,11 +21,12 @@ let root: Root; // network, so a stub keeps the render deterministic — a real fetch would make // these tests flaky for reasons that have nothing to do with the slot. beforeEach(() => { + NARROW.value = false; // jsdom ships no matchMedia; the widget reads it to decide the mobile // footer collision. Reporting "no match" gives the desktop bar, which is // the layout these assertions are about. vi.stubGlobal('matchMedia', (query: string) => ({ - matches: false, + matches: NARROW.value && query.includes('max-width'), media: query, addEventListener() {}, removeEventListener() {}, @@ -151,3 +155,33 @@ describe('auto theme — the colour parsing these refactors touched', () => { expect(bg).not.toBe(''); }); }); + +describe('the keyboard-shortcut chip', () => { + const chipText = (root: HTMLElement) => + [...root.querySelectorAll('span')].map((el) => el.textContent ?? '').join('|'); + + it('shows on a wide viewport', () => { + const { bar } = render(); + expect(chipText(bar)).toMatch(/K$/); + }); + + it('is hidden below 768px — it costs ~55px of the input for a key no phone has', () => { + NARROW.value = true; + const { bar } = render(); + expect(chipText(bar)).not.toMatch(/K$/); + }); + + it('gives the freed width back to the input', () => { + // The point of the guard, stated as the property that matters: the same + // bar yields a wider text field once the chip is gone. + const wide = render().bar.querySelectorAll('span').length; + act(() => root.unmount()); + container.remove(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + NARROW.value = true; + const narrow = render().bar.querySelectorAll('span').length; + expect(narrow).toBeLessThan(wide); + }); +}); diff --git a/src/concierge.tsx b/src/concierge.tsx index 0521e05..479cabf 100644 --- a/src/concierge.tsx +++ b/src/concierge.tsx @@ -228,6 +228,11 @@ export default function ConciergeWidget({ const [isFocused, setIsFocused] = useState(false); const [error, setError] = useState(null); const [isMac, setIsMac] = useState(true); + // Narrow viewports get no keyboard shortcut chip. Starts false so the + // server render and the first client paint agree; the effect below + // corrects it. (Deriving it inline from matchMedia would hydrate-mismatch, + // which is why isMac is shaped this way too.) + const [isNarrow, setIsNarrow] = useState(false); // Lead form state — fires on session 3-turn cap OR tenant cap OR // immediately when the server signals `hv_matched`. Persists until @@ -270,6 +275,13 @@ export default function ConciergeWidget({ useEffect(() => { setIsMac(navigator.platform?.toUpperCase().includes('MAC') ?? true); + + // Same breakpoint the footer-collision check uses, kept in sync with it. + const mq = window.matchMedia('(max-width: 767px)'); + const sync = () => setIsNarrow(mq.matches); + sync(); + mq.addEventListener('change', sync); + return () => mq.removeEventListener('change', sync); }, []); // ── Chameleon theme detection — two-pass (data-theme + luminance) ── @@ -660,7 +672,13 @@ export default function ConciergeWidget({ useHvEarly, useElaborated, hvForm, leadForm, requireAddress, showTranscriptCheckbox, ]); - const showShortcut = !isFocused && !input && !isLoading; + // ⚡ Not decoration: the chip costs ~55px of the input's visible width, and + // on a phone it buys nothing — there is no keyboard to press it with, and + // `isMac` reads navigator.platform, so a touch device can render the wrong + // modifier for a shortcut it cannot use either way. On a 390px screen that + // 55px is the difference between a placeholder that reads and one that + // clips mid-word. + const showShortcut = !isFocused && !input && !isLoading && !isNarrow; // ── Theme tokens ── // Light-mode bar + panel opacities intentionally kept low (<=0.75) so