From 5952b25fd82762bdad4cb98d8feb31fe72629992 Mon Sep 17 00:00:00 2001 From: Joe Elstner Date: Sat, 19 Sep 2026 21:36:26 -0500 Subject: [PATCH] feat(concierge): a decorative leading slot in the command bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrimmage wants a brand sprite inside its concierge bar. The widget styles entirely inline — no class names, no data attributes, no slots — so a host site cannot reach into the bar from its own stylesheet, and inline styles beat anything it could write anyway. The alternative was a Scrimmage-local fork of the bar, which would fork the guardrail, disclosure and lead-form logic too; the 2026-09-17 disclosure fix had to be chased across seven sites precisely because that logic lives in one place. It should stay there. `leading` is an optional ReactNode rendered ahead of the input. Omitted — which is every other site — the bar renders exactly as before, 24px inset and all. Supplied, the inset drops to 12px so the text starts where it always did, and the node sits in the space the padding gave up. The node is wrapped `aria-hidden` and `pointerEvents: none`. The input already carries this control's accessible name; a second announced element inside the same bar reads as a separate widget, and a focusable one would put a tab stop in front of the text field for decoration. This ships to every consumer on a `^` range, so the load-bearing claim is not "the slot works" but "a bar that passes no slot is unchanged." Eight tests pin both halves, and all three mutations of the guarantee are caught: forcing the padding unconditionally (1 fail), dropping the aria-hidden (2 fails), rendering the slot unconditionally (2 fails). Suite 40/40 · typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- src/concierge.test.tsx | 116 +++++++++++++++++++++++++++++++++++++++++ src/concierge.tsx | 39 +++++++++++++- 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 src/concierge.test.tsx diff --git a/package.json b/package.json index ca3e3d0..ef15ff5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@isimplifyme/ui", - "version": "1.3.0", + "version": "1.4.0", "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 new file mode 100644 index 0000000..bf70594 --- /dev/null +++ b/src/concierge.test.tsx @@ -0,0 +1,116 @@ +/** + * The `leading` slot — and, more importantly, its absence. + * + * This prop ships to every site consuming @isimplifyme/ui on a `^` range, so + * the load-bearing claim is not "the slot works" but "a bar that passes no + * slot is unchanged." That claim is what these tests pin. A prop whose + * default path is untested is a fleet-wide change disguised as an addition. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import ConciergeWidget from './concierge'; + +let container: HTMLDivElement; +let root: Root; + +// The widget bootstraps a session on mount. Nothing here exercises the +// 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(() => { + // 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, + media: query, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + onchange: null, + dispatchEvent: () => false, + })); + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }), + ), + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function render(props: Record = {}) { + act(() => { + root.render(); + }); + const input = container.querySelector('input[type="text"]') as HTMLInputElement; + // The command bar is the input's flex parent — found by structure rather + // than by a class, because the widget styles entirely inline and a test + // that invented a hook would be pinning the test's own fiction. + return { input, bar: input.parentElement as HTMLElement }; +} + +describe('no leading node — the path every other site takes', () => { + it('keeps the original 24px optical inset', () => { + const { bar } = render(); + expect(bar.style.padding).toBe('10px 12px 10px 24px'); + }); + + it('renders nothing before the input', () => { + const { input, bar } = render(); + expect(bar.firstElementChild).toBe(input); + }); + + it('adds no aria-hidden element to the bar', () => { + const { bar } = render(); + expect(bar.querySelectorAll('[aria-hidden="true"]').length).toBe(0); + }); +}); + +describe('with a leading node', () => { + const sprite = ; + + it('renders it ahead of the input', () => { + const { input, bar } = render({ leading: sprite }); + const slot = bar.firstElementChild as HTMLElement; + expect(slot.querySelector('[data-testid="sprite"]')).not.toBeNull(); + expect(slot.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + }); + + it('drops the inset to 12px so the text starts where it always did', () => { + const { bar } = render({ leading: sprite }); + expect(bar.style.padding).toBe('10px 12px'); + }); + + it('hides it from assistive tech', () => { + const { bar } = render({ leading: sprite }); + expect((bar.firstElementChild as HTMLElement).getAttribute('aria-hidden')).toBe('true'); + }); + + it('leaves the input the only accessible name in the bar', () => { + // A second announced element here would read as a separate control. The + // input's own label is the one the user is meant to hear. + const { input, bar } = render({ leading: sprite }); + expect(input.getAttribute('aria-label')).toBe('Chat with concierge'); + const named = [...bar.querySelectorAll('[aria-label]')]; + expect(named.filter((el) => el !== input && !el.closest('[aria-hidden="true"]'))) + .toHaveLength(1); // the send button, which was always there + }); + + it('takes no tab stop', () => { + const { bar } = render({ leading: }); + const slot = bar.firstElementChild as HTMLElement; + expect(slot.getAttribute('aria-hidden')).toBe('true'); + expect(slot.style.pointerEvents).toBe('none'); + }); +}); diff --git a/src/concierge.tsx b/src/concierge.tsx index 3989181..f200b12 100644 --- a/src/concierge.tsx +++ b/src/concierge.tsx @@ -23,7 +23,7 @@ * apex-portal /api/leads/submit. */ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'; // ── Types ────────────────────────────────────────────────────────────── @@ -93,6 +93,19 @@ export interface ConciergeWidgetProps { * (e.g. ['911', '988']). Pure presentation — no behavior. Only * applied when `disclaimerOpener` is set. */ disclaimerHotlines?: string[]; + /** Decorative node rendered at the head of the command bar, before + * the input — a brand sprite, mark, or avatar. Omit it and the bar + * renders exactly as it always has, including its 24px left padding; + * supply one and the padding drops to 12px so the node sits where + * the text used to start. + * + * PURELY DECORATIVE. It is wrapped in `aria-hidden` and is not + * focusable, because the input already carries the accessible name + * for this control and a second announced element here would read + * as a separate widget. Do not pass anything interactive, and do + * not pass anything that carries meaning the sighted user gets and + * the screen-reader user does not. */ + leading?: ReactNode; } // ── SSE event shapes ─────────────────────────────────────────────────── @@ -202,6 +215,7 @@ export default function ConciergeWidget({ theme = 'dark', disclaimerOpener, disclaimerHotlines, + leading, }: ConciergeWidgetProps) { // Conversation + session const [messages, setMessages] = useState([]); @@ -1303,7 +1317,10 @@ export default function ConciergeWidget({ WebkitBackdropFilter: 'blur(32px) saturate(180%)', border: `${barBorderWidth} solid ${isFocused ? barBorderFocused : barBorder}`, borderRadius: '28px', - padding: '10px 12px 10px 24px', + // 24px is the bar's own optical inset for text starting at the + // edge. A leading node supplies its own visual mass, so it takes + // the smaller inset and the text lands where it already was. + padding: leading ? '10px 12px' : '10px 12px 10px 24px', boxShadow: isFocused ? `inset 0 1px 0 ${barTopHighlightFocused}, ${barShadowFocused}${focusAccentRing}` : `inset 0 1px 0 ${barTopHighlight}, ${barShadow}`, @@ -1311,6 +1328,24 @@ export default function ConciergeWidget({ transform: isFocused ? 'scale(1.005)' : 'scale(1)', }} > + {leading && ( + + )} +