From a46fc0bb9ca70af7d6ab8782d838251807e9a696 Mon Sep 17 00:00:00 2001 From: manager Date: Mon, 29 Jun 2026 08:14:06 +0000 Subject: [PATCH] feat: copilot UXCG greeting, smooth reveal, desktop-modal visibility, wallet + to-top fixes - add curated UXCG landing (EN+RU) with cards, like UX Core - replace jittery word typewriter with one smooth fade-in reveal - keep widget visible over UX Core/UXCG bias modals on desktop (mobile unchanged) - skip host-element highlight while a bias modal is open - never spend tokens while the widget is CSS-hidden (gate paid landings on visibility, not just open) - park the "to top" button left of the Copilot pill with padding, measured live per locale/state Co-Authored-By: Claude Opus 4.8 --- src/components/ScrollToTop/ScrollToTop.tsx | 45 +++++ widget/src/AskUxCore.tsx | 181 ++++++++++++++------- widget/src/styles.css | 17 +- 3 files changed, 176 insertions(+), 67 deletions(-) diff --git a/src/components/ScrollToTop/ScrollToTop.tsx b/src/components/ScrollToTop/ScrollToTop.tsx index 8e304049..52576dbd 100644 --- a/src/components/ScrollToTop/ScrollToTop.tsx +++ b/src/components/ScrollToTop/ScrollToTop.tsx @@ -8,10 +8,17 @@ import ArrowUp from '@icons/ArrowUp'; import styles from './ScrollToTop.module.scss'; const SCROLL_THRESHOLD = 300; +/* Gap between the "to top" button and the Copilot pill that sits in the + bottom-right corner (the widget is a separate DOM root injected by the + concierge bundle). */ +const COPILOT_GAP = 16; const ScrollToTop: FC = () => { const [{}, { isDarkTheme }] = useGlobals(); const [isVisible, setIsVisible] = useState(false); + /* Right offset so the button parks just LEFT of the Copilot pill. null + until measured — then the inline style overrides the SCSS default. */ + const [rightOffset, setRightOffset] = useState(null); const handleScroll = useCallback(() => { setIsVisible(window.scrollY > SCROLL_THRESHOLD); @@ -22,6 +29,43 @@ const ScrollToTop: FC = () => { return () => window.removeEventListener('scroll', handleScroll); }, [handleScroll]); + /* Measure the Copilot pill and sit to its left. The pill width varies + by locale (RU label is wider) and open/closed state, so we measure + live rather than hardcode. The widget bundle loads async, so retry + shortly and watch the pill for size changes. */ + useEffect(() => { + const compute = () => { + const el = document.querySelector( + '.ks-aux-pill, .ks-aux-root', + ) as HTMLElement | null; + if (!el) { + setRightOffset(null); + return; + } + const rect = el.getBoundingClientRect(); + if (rect.width === 0) { + setRightOffset(null); + return; + } + setRightOffset(window.innerWidth - rect.left + COPILOT_GAP); + }; + + compute(); + window.addEventListener('resize', compute); + const retry = window.setTimeout(compute, 1200); + let ro: ResizeObserver | null = null; + const pill = document.querySelector('.ks-aux-pill'); + if (pill && typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(compute); + ro.observe(pill); + } + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(retry); + ro?.disconnect(); + }; + }, [isVisible]); + const handleClick = useCallback(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, []); @@ -33,6 +77,7 @@ const ScrollToTop: FC = () => { className={cn(styles.scrollToTop, { [styles.dark]: isDarkTheme, })} + style={rightOffset != null ? { right: `${rightOffset}px` } : undefined} onClick={handleClick} aria-label="Scroll to top" title="Scroll to top" diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index 1f4a4bfa..b98d25d1 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -589,6 +589,33 @@ const PAGE_LANDINGS: Record> = { }, ], }, + '/uxcg': { + message: + "You're in **UXCG** — the UX Core Guide. Start from a real business problem and we hand you the exact biases bending it, plus concrete nudges to act on. 1000+ worked examples for product, growth, and HR teams — the applied half of UX Core.", + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'The library every case here is built on — 100+ biases', + }, + { + title: 'Awareness Test', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: 'See which biases bend your own calls first — under 7 minutes', + }, + { + title: 'Persona Map', + url: '/uxcp', + type: 'project', + nominated: true, + blurb: 'Build a persona out of the biases that actually drive people', + }, + ], + }, '/tools/longevity-protocol': { message: "You're in the **Longevity Protocol** — our take on long-haul health, distilled into a small set of practices we actually run on ourselves. Same principle as the rest of keepsimple: smart defaults beat willpower.", @@ -705,6 +732,35 @@ const PAGE_LANDINGS: Record> = { }, ], }, + '/uxcg': { + message: + 'Ты в **UXCG** — это гайд UX Core. Начинаешь с реальной бизнес-проблемы, а мы отдаём тебе те самые искажения, что её гнут, плюс конкретные нуджи, чтобы действовать. 1000+ разобранных примеров для продукта, роста и HR — прикладная половина UX Core.', + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: + 'Библиотека, на которой стоит каждый кейс здесь — 100+ искажений', + }, + { + title: 'Тест осознанности', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: + 'Сначала узнай, какие искажения гнут твои решения — меньше 7 минут', + }, + { + title: 'Persona Map', + url: '/uxcp', + type: 'project', + nominated: true, + blurb: 'Собери персону из искажений, которые реально движут людьми', + }, + ], + }, '/tools/longevity-protocol': { message: 'Ты в **Longevity Protocol** — это наш взгляд на долгое здоровье, упакованный в небольшой набор практик, которые мы сами на себе и используем. Тот же принцип что и в остальном keepsimple: умные дефолты бьют силу воли.', @@ -797,6 +853,20 @@ const PAGE_LANDINGS: Record> = { const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; const OPEN_KEY = 'ks_aux_open_v1'; const GREETED_PAGES_KEY = 'ks_aux_greeted_pages_v1'; + +/* True when the widget root is CSS-hidden (e.g. the mobile rule that + drops it while a bias modal is open). The open/closed flag follows the + visitor across pages and can stay `open` while the widget is invisible, + so it is NOT a safe gate for paid work on its own. Movement tracking + (page_view, dwell — all free/internal) keeps running while hidden; only + token-spending calls consult this so a hidden widget never burns money + by accident. */ +const isWidgetHidden = (): boolean => { + if (typeof document === 'undefined') return false; + const root = document.querySelector('.ks-aux-root'); + if (!root) return false; + return window.getComputedStyle(root).display === 'none'; +}; const curatedLandingPathKey = (rawUrl: string): string | null => { try { const u = new URL(rawUrl, window.location.origin); @@ -2256,14 +2326,16 @@ export function AskUxCore({ lang }: { lang: Lang }) { } /* Cost gate: the organic greeting is a paid AI call. Spend it - only when the panel is open — opening the pill is a deliberate - human gesture, and the open panel now follows the visitor across - pages, so an open panel marks a real user. Passers-by and - crawlers never open it; the server greeting route also drops - known-bot user-agents as a backstop. Then never pay twice for - the same page this session. Curated landings above are local - (free) and ungated. */ - if (!openRef.current) return; + only when the panel is open AND actually visible. Opening the + pill is a deliberate human gesture, and the open panel follows + the visitor across pages — but it can stay `open` while the + widget is CSS-hidden (mobile bias-modal rule), so we also bail + when hidden: a widget the visitor can't see must never burn + tokens. Passers-by and crawlers never open it; the server + greeting route also drops known-bot user-agents as a backstop. + Then never pay twice for the same page this session. Curated + landings above are local (free) and ungated. */ + if (!openRef.current || isWidgetHidden()) return; const greetKey = canonicalPathKey(rawUrl); if (hasGreetedPage(greetKey)) return; markGreetedPage(greetKey); @@ -2666,6 +2738,18 @@ export function AskUxCore({ lang }: { lang: Lang }) { } } + /* Same wallet guard as the organic greeting: a hidden widget never + spends. The card-click landing is user-initiated, but if the + destination renders the widget invisible (mobile bias-modal rule) + the line would be paid for and never seen — skip it. */ + if (isWidgetHidden()) { + if (pending.placeholderId !== undefined) { + const pid = pending.placeholderId; + setTurns(cur => cur.filter(tt => tt.id !== pid)); + } + return; + } + let cancelled = false; fetch('/api/concierge-landing', { method: 'POST', @@ -2824,6 +2908,17 @@ export function AskUxCore({ lang }: { lang: Lang }) { ); useEffect(() => { if (!isHighlightEnabledPage()) return; + /* No host highlighting while a UX Core / UXCG bias modal is open. + On desktop the widget now stays visible over the modal so the + visitor can talk about the bias they're reading — but lighting up + elements underneath the overlay is pointless (they're covered) and + noisy. The visitor can still be guided OUT to other pages via the + answer's cards; we just skip the in-page halo here. */ + if ( + typeof document !== 'undefined' && + document.querySelector('[class*="ModalOverlay"]') + ) + return; /* Host highlighting is gated on the panel being open: the Copilot lights up page elements only while it is ACTIVE. Collapsed pill = not active = no host highlight. Re-arm the flash guard on collapse @@ -2871,68 +2966,32 @@ export function AskUxCore({ lang }: { lang: Lang }) { }; }, [turns, open]); - /* Typewriter constants — every bit of bot-authored text in the - widget (concierge stream, homepage starters, landing turns) runs - through the same throttle so the panel reads at one consistent - tempo. 1 char / 22ms ≈ 45 chars/sec — smooth char-by-char reveal, - reads as deliberate rather than firehosed. */ - const STREAM_CHUNK = 1; - const STREAM_TICK = 22; + /* Settle delay before cards/suggestions attach after the text lands. */ const SETTLE_MS = 200; - /* Streaming typewriter — accepts a growing target via push() and - drips it into the named turn at the typewriter tempo. finish() - marks the target final and runs onDone once the displayed text - has caught up. Works for both live server streams (where the - target keeps growing) and pre-canned text (single push). */ + /* Bot-text reveal — every bit of bot-authored copy (concierge answer, + homepage starters, landing turns) goes through this. We deliberately + do NOT type char/word-by-char: progressive text re-parses the whole + markdown tree and reflows the line box on every step, which reads as + jittery. Instead the full message is committed once and the bubble + fades in smoothly via the `.ks-aux-a` CSS animation — one clean, + reflow-free reveal. push() keeps the latest target (server tokens + arrive in bursts; we just keep the newest), finish() paints it and + runs onDone after a short settle. */ const createTypewriter = (turnId: string) => { let target = ''; - let displayed = ''; - let timerActive = false; - let streamDone = false; - let pendingDone: (() => void) | null = null; - - const advance = () => { - if (displayed.length < target.length) { - const next = Math.min(displayed.length + STREAM_CHUNK, target.length); - displayed = target.slice(0, next); - setTurns(prev => - prev.map(tt => - tt.id === turnId ? { ...tt, answer: displayed } : tt, - ), - ); - } - if (displayed.length < target.length) { - window.setTimeout(advance, STREAM_TICK); - return; - } - timerActive = false; - if (streamDone && pendingDone) { - const cb = pendingDone; - pendingDone = null; - window.setTimeout(cb, SETTLE_MS); - } - }; - const kick = () => { - if (timerActive) return; - if (displayed.length >= target.length) return; - timerActive = true; - advance(); - }; - return { push: (next: string) => { target = next; - kick(); }, finish: (onDone: () => void) => { - streamDone = true; - if (displayed.length >= target.length) { - window.setTimeout(onDone, SETTLE_MS); - } else { - pendingDone = onDone; - kick(); - } + const finalText = target; + setTurns(prev => + prev.map(tt => + tt.id === turnId ? { ...tt, answer: finalText } : tt, + ), + ); + window.setTimeout(onDone, SETTLE_MS); }, }; }; diff --git a/widget/src/styles.css b/widget/src/styles.css index 5c82f646..8d027253 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -13,10 +13,15 @@ pointer-events: auto; } -/* Hide the whole widget while any UX Core / UXCG modal is open — the modal - owns the bottom corners (Prev/Next navigation pills live there). */ -body:has([class*='ModalOverlay']) .ks-aux-root { - display: none; +/* Hide the whole widget while any UX Core / UXCG modal is open — but only + on mobile, where the modal's Prev/Next pills live in the bottom corners + and would collide. On desktop the modal's arrows sit on the side edges, + so the bottom-right corner is free: we keep the widget visible there so + the visitor can talk to it about the bias they're reading. */ +@media (max-width: 480px) { + body:has([class*='ModalOverlay']) .ks-aux-root { + display: none; + } } @keyframes ks-aux-pulse { @@ -882,11 +887,11 @@ body:has([class*='ModalOverlay']) .ks-aux-root { line-height: 1.6; color: #1f1d1a; word-wrap: break-word; - animation: ks-aux-fade-in 320ms ease both; + animation: ks-aux-fade-in 460ms cubic-bezier(0.22, 0.61, 0.36, 1) both; } @keyframes ks-aux-fade-in { - from { opacity: 0; transform: translateY(4px); } + from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }