From e9442b59ca44f5888f0f52d736609a23da14579e Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 2 Jun 2026 16:28:15 +0000 Subject: [PATCH 1/8] fix(widget): gate paid organic greeting behind open + engaged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-navigation "you're on X" landing line fires a paid Claude call on every page hop, even when the pill is closed and the visitor never interacts — so cost scaled with raw visitor count. Now the organic greeting only spends when the panel is open AND the visitor has already engaged (asked a question / clicked a card / picked a suggestion) this session. Curated landings stay local/free; card-click landings already imply both signals and are unchanged. Co-Authored-By: Claude Opus 4.7 --- widget/src/AskUxCore.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index d996d585..81e248af 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -795,6 +795,7 @@ const PAGE_LANDINGS: Record> = { }; const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; +const ENGAGED_KEY = 'ks_aux_engaged_v1'; const curatedLandingPathKey = (rawUrl: string): string | null => { try { const u = new URL(rawUrl, window.location.origin); @@ -833,6 +834,25 @@ const markCuratedLandingFired = (key: string) => { } }; +/* Engagement flag — set once the visitor actually uses the widget + (asks a question / clicks a card / picks a suggestion). Gates the + paid organic greeting so we only spend on visitors who've shown + interest, never on pure passers-by. Per-tab; clears on tab close. */ +const hasEngaged = (): boolean => { + try { + return sessionStorage.getItem(ENGAGED_KEY) === '1'; + } catch { + return false; + } +}; +const markEngaged = () => { + try { + sessionStorage.setItem(ENGAGED_KEY, '1'); + } catch { + /* sessionStorage disabled — engagement not persisted across nav */ + } +}; + /* ────────────────────────────────────────────────────────────────── Identity query triggers — works on any page. ────────────────────────────────────────────────────────────────── @@ -1770,6 +1790,10 @@ export function AskUxCore({ lang }: { lang: Lang }) { // effects (host-page highlights, etc.) until the visitor explicitly // opens the pill — even if the previous session ended with it open. const [open, setOpen] = useState(false); + /* Fresh mirror of `open` for the once-mounted nav effect, whose + closure would otherwise capture the initial (always-false) value. */ + const openRef = useRef(open); + openRef.current = open; const [text, setText] = useState(''); const [turns, setTurns] = useState(initial?.turns ?? []); const [loading, setLoading] = useState(false); @@ -2202,6 +2226,14 @@ export function AskUxCore({ lang }: { lang: Lang }) { return; } + /* Cost gate: the organic greeting is a paid AI call. Spend it + only when the visitor is actually in the widget — panel open + AND already engaged (asked something / picked a card) this + session. Passers-by with the pill closed cost nothing, so the + bill no longer scales with raw visitor count. Curated landings + above are local (free) and stay ungated. */ + if (!openRef.current || !hasEngaged()) return; + const ctrl = new AbortController(); organicAbortRef.current = ctrl; fetch('/api/concierge-landing', { @@ -2905,6 +2937,7 @@ export function AskUxCore({ lang }: { lang: Lang }) { }; const runQuery = async (query: string, replaceTurnId?: string) => { + markEngaged(); setLoading(true); const id = replaceTurnId ?? `${Date.now()}`; const newTurn: Turn = { @@ -3076,6 +3109,7 @@ export function AskUxCore({ lang }: { lang: Lang }) { const onCardClick = (citation: Citation) => { if (!citation.url) return; + markEngaged(); trackEvent('card_click', { url: citation.url, type: citation.type }); const tier: 'high' | 'mid' | 'low' = citation.nominated ? 'high' From a681aab8c4ac1d1312e08c894bba5c13bcb69a77 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 2 Jun 2026 16:45:19 +0000 Subject: [PATCH 2/8] fix(widget): typed-input-only greeting with 30-min expiry + per-page cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the organic greeting cost gate per product decision: - Engagement is now set ONLY on a manually typed message. Card and suggestion clicks no longer count — clicking existing buttons is navigation, not a conversation, and must not incur paid greetings. - Engagement expires after 30 min of no typed input (timestamp + TTL), so a long-idle tab starts neutral again. - Never pay twice for the same page in a session: the organic greeting is cached per canonical path; revisits and back/forth are free. Curated (local) landings and the chat answer path are unchanged. Co-Authored-By: Claude Opus 4.7 --- widget/src/AskUxCore.tsx | 60 ++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index 81e248af..23b1cda3 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -796,6 +796,7 @@ const PAGE_LANDINGS: Record> = { const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; const ENGAGED_KEY = 'ks_aux_engaged_v1'; +const GREETED_PAGES_KEY = 'ks_aux_greeted_pages_v1'; const curatedLandingPathKey = (rawUrl: string): string | null => { try { const u = new URL(rawUrl, window.location.origin); @@ -834,22 +835,49 @@ const markCuratedLandingFired = (key: string) => { } }; -/* Engagement flag — set once the visitor actually uses the widget - (asks a question / clicks a card / picks a suggestion). Gates the - paid organic greeting so we only spend on visitors who've shown - interest, never on pure passers-by. Per-tab; clears on tab close. */ +/* Engagement — set ONLY when the visitor types a message themselves + (manual input). Clicking cards/suggestions/buttons does NOT count: + those are navigation, not a conversation. Gates the paid organic + greeting so we spend only on people who've actually talked to the + Copilot. Stored as a timestamp and treated as expired after 30 min + of no further typed input, so a long-idle tab starts neutral again. + Per-tab; clears on tab close. */ +const ENGAGED_TTL_MS = 30 * 60 * 1000; +const markEngaged = () => { + try { + sessionStorage.setItem(ENGAGED_KEY, String(Date.now())); + } catch { + /* sessionStorage disabled — engagement not persisted across nav */ + } +}; const hasEngaged = (): boolean => { try { - return sessionStorage.getItem(ENGAGED_KEY) === '1'; + const ts = Number(sessionStorage.getItem(ENGAGED_KEY) || '0'); + return ts > 0 && Date.now() - ts <= ENGAGED_TTL_MS; } catch { return false; } }; -const markEngaged = () => { + +/* Per-page greeting cache — once the organic greeting has fired for a + page in this tab session, never pay for it again on that page (revisits + and back/forth are free). Keyed by canonical path. */ +const hasGreetedPage = (key: string): boolean => { try { - sessionStorage.setItem(ENGAGED_KEY, '1'); + const raw = sessionStorage.getItem(GREETED_PAGES_KEY) || '{}'; + return !!JSON.parse(raw)[key]; } catch { - /* sessionStorage disabled — engagement not persisted across nav */ + return false; + } +}; +const markGreetedPage = (key: string) => { + try { + const raw = sessionStorage.getItem(GREETED_PAGES_KEY) || '{}'; + const obj = JSON.parse(raw); + obj[key] = Date.now(); + sessionStorage.setItem(GREETED_PAGES_KEY, JSON.stringify(obj)); + } catch { + /* sessionStorage disabled — greeting may re-fire on revisit */ } }; @@ -2228,11 +2256,14 @@ export function AskUxCore({ lang }: { lang: Lang }) { /* Cost gate: the organic greeting is a paid AI call. Spend it only when the visitor is actually in the widget — panel open - AND already engaged (asked something / picked a card) this - session. Passers-by with the pill closed cost nothing, so the - bill no longer scales with raw visitor count. Curated landings - above are local (free) and stay ungated. */ + AND they've typed a message themselves within the last 30 min. + Passers-by, button-only clickers and bots never type, so they + cost nothing. Then never pay twice for the same page this + session. Curated landings above are local (free) and ungated. */ if (!openRef.current || !hasEngaged()) return; + const greetKey = canonicalPathKey(rawUrl); + if (hasGreetedPage(greetKey)) return; + markGreetedPage(greetKey); const ctrl = new AbortController(); organicAbortRef.current = ctrl; @@ -2937,7 +2968,6 @@ export function AskUxCore({ lang }: { lang: Lang }) { }; const runQuery = async (query: string, replaceTurnId?: string) => { - markEngaged(); setLoading(true); const id = replaceTurnId ?? `${Date.now()}`; const newTurn: Turn = { @@ -3103,13 +3133,15 @@ export function AskUxCore({ lang }: { lang: Lang }) { if (e) e.preventDefault(); const query = text.trim(); if (!query || loading) return; + /* Manual typed input is the ONLY thing that enables the paid + organic greeting — and it refreshes the 30-min engagement clock. */ + markEngaged(); setText(''); await runQuery(query); }; const onCardClick = (citation: Citation) => { if (!citation.url) return; - markEngaged(); trackEvent('card_click', { url: citation.url, type: citation.type }); const tier: 'high' | 'mid' | 'low' = citation.nominated ? 'high' From 6eca85f5da609f51d4e84d7d8cf2b0c0c7912716 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 2 Jun 2026 17:12:10 +0000 Subject: [PATCH 3/8] fix(widget): persist panel open-state per tab; gate greeting on open + bot filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remember the widget's open/closed panel in sessionStorage so it follows the visitor across page loads (incl. hard reloads into UX Core), instead of always booting closed. Switch the paid organic-greeting gate from "typed within 30 min" to simply "panel open" — opening the pill is a deliberate human gesture — and add a known-bot user-agent backstop on /api/concierge-landing so we never pay a crawler that reaches the route. Co-Authored-By: Claude Opus 4.7 --- src/pages/api/concierge-landing.ts | 21 +++++++++++ widget/src/AskUxCore.tsx | 60 +++++++++++++++--------------- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/src/pages/api/concierge-landing.ts b/src/pages/api/concierge-landing.ts index 2a2db368..13ebcace 100644 --- a/src/pages/api/concierge-landing.ts +++ b/src/pages/api/concierge-landing.ts @@ -17,6 +17,19 @@ import { type LandingPayload = { text: string; suggestions: string[] }; +/* Human backstop: the organic greeting is a paid call, gated client-side + on the panel being open (a real click). This rejects known crawlers and + header-less scripted requests so we never pay a machine that reaches the + route anyway. Greeting is non-critical — a blocked caller just gets no + line, never an error. */ +const BOT_UA_RE = + /bot|crawl|spider|slurp|mediapartners|ahrefs|semrush|mj12|dotbot|bingpreview|facebookexternalhit|embedly|slackbot|telegrambot|whatsapp|headless|phantomjs|python-requests|curl\/|wget|go-http-client|scrapy|yandex(?:bot)?|baidu|duckduckbot/i; + +function isBotUserAgent(ua: string | undefined): boolean { + if (!ua || !ua.trim()) return true; + return BOT_UA_RE.test(ua); +} + async function callClaude( system: string, user: string, @@ -268,6 +281,14 @@ export default async function handler( return res.status(200).json({ text: '' }); } + const ua = + typeof req.headers['user-agent'] === 'string' + ? req.headers['user-agent'] + : undefined; + if (isBotUserAgent(ua)) { + return res.status(200).json({ text: '' }); + } + const { url, title, prevQuery, prevAnswer, lang, mode } = (req.body ?? {}) as { url?: string; diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index 23b1cda3..52a894f0 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -795,7 +795,7 @@ const PAGE_LANDINGS: Record> = { }; const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; -const ENGAGED_KEY = 'ks_aux_engaged_v1'; +const OPEN_KEY = 'ks_aux_open_v1'; const GREETED_PAGES_KEY = 'ks_aux_greeted_pages_v1'; const curatedLandingPathKey = (rawUrl: string): string | null => { try { @@ -835,27 +835,23 @@ const markCuratedLandingFired = (key: string) => { } }; -/* Engagement — set ONLY when the visitor types a message themselves - (manual input). Clicking cards/suggestions/buttons does NOT count: - those are navigation, not a conversation. Gates the paid organic - greeting so we spend only on people who've actually talked to the - Copilot. Stored as a timestamp and treated as expired after 30 min - of no further typed input, so a long-idle tab starts neutral again. - Per-tab; clears on tab close. */ -const ENGAGED_TTL_MS = 30 * 60 * 1000; -const markEngaged = () => { +/* Widget open/closed — remembered per tab so the panel follows the + visitor across page loads (incl. hard reloads into UX Core / other + route groups). Opening the pill is a deliberate human gesture, so an + open panel is what gates the paid organic greeting. Clears on tab + close; a brand-new visit always starts closed. */ +const readOpenFlag = (): boolean => { try { - sessionStorage.setItem(ENGAGED_KEY, String(Date.now())); + return sessionStorage.getItem(OPEN_KEY) === '1'; } catch { - /* sessionStorage disabled — engagement not persisted across nav */ + return false; } }; -const hasEngaged = (): boolean => { +const writeOpenFlag = (isOpen: boolean) => { try { - const ts = Number(sessionStorage.getItem(ENGAGED_KEY) || '0'); - return ts > 0 && Date.now() - ts <= ENGAGED_TTL_MS; + sessionStorage.setItem(OPEN_KEY, isOpen ? '1' : '0'); } catch { - return false; + /* sessionStorage disabled — open state not remembered across nav */ } }; @@ -1814,14 +1810,19 @@ const applyHostHighlight = ( export function AskUxCore({ lang }: { lang: Lang }) { const initial = typeof window !== 'undefined' ? loadState() : null; - // Always boot closed. The widget should never reveal itself or its - // effects (host-page highlights, etc.) until the visitor explicitly - // opens the pill — even if the previous session ended with it open. - const [open, setOpen] = useState(false); + // Restore the open/closed panel per tab so it follows the visitor + // across page loads (incl. hard reloads into UX Core). A brand-new + // visit (fresh tab) has no flag and boots closed. + const [open, setOpen] = useState(() => + typeof window !== 'undefined' ? readOpenFlag() : false, + ); /* Fresh mirror of `open` for the once-mounted nav effect, whose - closure would otherwise capture the initial (always-false) value. */ + closure would otherwise capture the initial render's value. */ const openRef = useRef(open); openRef.current = open; + useEffect(() => { + writeOpenFlag(open); + }, [open]); const [text, setText] = useState(''); const [turns, setTurns] = useState(initial?.turns ?? []); const [loading, setLoading] = useState(false); @@ -2255,12 +2256,14 @@ export function AskUxCore({ lang }: { lang: Lang }) { } /* Cost gate: the organic greeting is a paid AI call. Spend it - only when the visitor is actually in the widget — panel open - AND they've typed a message themselves within the last 30 min. - Passers-by, button-only clickers and bots never type, so they - cost nothing. Then never pay twice for the same page this - session. Curated landings above are local (free) and ungated. */ - if (!openRef.current || !hasEngaged()) return; + 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; const greetKey = canonicalPathKey(rawUrl); if (hasGreetedPage(greetKey)) return; markGreetedPage(greetKey); @@ -3133,9 +3136,6 @@ export function AskUxCore({ lang }: { lang: Lang }) { if (e) e.preventDefault(); const query = text.trim(); if (!query || loading) return; - /* Manual typed input is the ONLY thing that enables the paid - organic greeting — and it refreshes the 30-min engagement clock. */ - markEngaged(); setText(''); await runQuery(query); }; From 3ae0a5c3ecbe968c0b9487c73235cd5ad1df88ec Mon Sep 17 00:00:00 2001 From: manager Date: Fri, 12 Jun 2026 06:07:22 +0000 Subject: [PATCH 4/8] feat: mobile UX Core modal polish, swipe nav, dark widget pill, offsec case rework - bias modal on mobile is a full-height bottom sheet (dvh-safe on iOS), header icons aligned, vertical use-case switcher - restore swipe left/right bias navigation directly in UXCoreModal (legacy UXCoreModalMobile stopped rendering after repo merge) - rating block moved inside the scrollable body instead of a sticky footer - Ask widget pill: dark surface in dark theme, no pulse on touch devices - offsec cases #2-#4 reworked for distinct scenarios; left/right wording replaced with first/second for stacked mobile layout --- .../OffsecBiasView/OffsecBiasView.module.scss | 42 ++++---- .../UXCoreModal/UXCoreModal.module.scss | 100 ++++++++++++++++-- .../components/UXCoreModal/UXCoreModal.tsx | 30 +++++- .../UXCoreModalHeader.module.scss | 20 ++-- src/uxcore/data/biasOffsec/attentionalBias.ts | 23 ++-- .../data/biasOffsec/availabilityHeuristics.ts | 15 +-- .../data/biasOffsec/illusoryTruthEffect.ts | 38 ++++--- src/uxcore/data/biasOffsec/index.ts | 2 + .../data/biasOffsec/mereExposureEffect.ts | 52 +++++++++ widget/src/styles.css | 36 ++++++- 10 files changed, 285 insertions(+), 73 deletions(-) create mode 100644 src/uxcore/data/biasOffsec/mereExposureEffect.ts diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss index 51268e46..b0e624a3 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -71,8 +71,8 @@ $ks-crimson-deep: #7a2618; .scenario { margin: 0 0 4px; - font-size: 14px; - line-height: 1.5; + font-size: 16px; + line-height: 150%; color: $ks-ink-soft; } } @@ -163,7 +163,7 @@ $ks-crimson-deep: #7a2618; display: flex; align-items: center; gap: 6px; - font-size: 14px; + font-size: 15px; font-weight: 700; color: $ks-ink; line-height: 1.3; @@ -185,8 +185,8 @@ $ks-crimson-deep: #7a2618; } .cardPreview { - font-size: 12.5px; - line-height: 1.45; + font-size: 14px; + line-height: 1.5; color: $ks-ink-soft; } @@ -292,7 +292,7 @@ $ks-crimson-deep: #7a2618; align-items: center; gap: 6px; padding: 12px 14px 0; - font-size: 14px; + font-size: 15px; font-weight: 700; color: $ks-ink; line-height: 1.3; @@ -304,8 +304,8 @@ $ks-crimson-deep: #7a2618; .browserPageBody { padding: 8px 14px 0; - font-size: 12.5px; - line-height: 1.45; + font-size: 14px; + line-height: 1.5; color: $ks-ink-soft; } @@ -372,15 +372,15 @@ $ks-crimson-deep: #7a2618; display: flex; align-items: center; gap: 6px; - font-size: 14px; + font-size: 15px; font-weight: 700; line-height: 1.3; color: $ks-ink; } .notifBody { - font-size: 12.5px; - line-height: 1.45; + font-size: 14px; + line-height: 1.5; color: $ks-ink-soft; } } @@ -441,7 +441,7 @@ $ks-crimson-deep: #7a2618; } .chatPrior { - font-size: 11.5px; + font-size: 13px; font-style: italic; color: $ks-ink-soft; padding-left: 4px; @@ -456,7 +456,7 @@ $ks-crimson-deep: #7a2618; border-radius: 12px; border-top-left-radius: 4px; padding: 10px 12px; - font-size: 13px; + font-size: 14px; line-height: 1.5; color: $ks-ink; } @@ -490,8 +490,8 @@ $ks-crimson-deep: #7a2618; p { margin: 0; - font-size: 14px; - line-height: 1.55; + font-size: 16px; + line-height: 150%; color: $ks-ink; } } @@ -548,8 +548,8 @@ $ks-crimson-deep: #7a2618; li { position: relative; padding-left: 18px; - font-size: 14px; - line-height: 1.55; + font-size: 16px; + line-height: 150%; color: $ks-ink; &::before { @@ -582,8 +582,12 @@ $ks-crimson-deep: #7a2618; color: $dk-text; } - .eyebrow { - color: $dk-crimson; + // Class stacked + !important to out-specify the modal's dark-theme + // span neutralizer (`body.darkTheme .ModalOverlay … .ModalBodyContent + // span:not(.switcherItemText)` — 4 classes + 2 elements), which would + // otherwise grey these section titles out. + .root span.eyebrow.eyebrow.eyebrow { + color: $dk-crimson !important; } .visualBlock { diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index a78a1d7a..90d3e768 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -13,6 +13,9 @@ display: flex; width: 100vw; height: 100vh; + /* iOS Safari: 100vh includes the area behind the URL bar; dvh tracks the + actually visible viewport so the modal bottom is never cut off. */ + height: 100dvh; position: fixed; top: 0; left: 0; @@ -123,17 +126,18 @@ border-radius: 8px; background-color: #fff; isolation: isolate; + overflow: hidden; &::before { content: ''; position: absolute; - top: -1px; - bottom: -1px; - left: -1px; - width: calc(33.3333% + 1px); + top: 0; + bottom: 0; + left: 0; + width: 33.3333%; background-color: #e8f0fb; border: 1px solid #e0e0e0; - border-radius: 8px; + border-radius: 7px; transform: translateX(0); transition: transform 450ms cubic-bezier(0.22, 0.95, 0.35, 1), @@ -145,13 +149,13 @@ } &:has(.activeHr)::before { - transform: translateX(calc(100% - 1px)); + transform: translateX(100%); } // OffSec uses Hexens-crimson for the sliding indicator so the // mode shift reads as "different domain", not just "different tab". &:has(.activeOffsec)::before { - transform: translateX(calc(200% - 2px)); + transform: translateX(200%); background-color: #fdecea; border-color: #c8412a; } @@ -174,6 +178,18 @@ svg { transition: fill 300ms ease; } + + & + .switcherItem::after { + content: ''; + position: absolute; + left: 0; + top: 9px; + bottom: 9px; + width: 1px; + background-color: #e0e0e0; + z-index: -1; + pointer-events: none; + } } .activeProduct, @@ -590,6 +606,10 @@ ol { color: rgba(218, 218, 218, 0.7) !important; } + .ModalBodyContent .switcher .switcherItem + .switcherItem::after { + background-color: #303338 !important; + } + .ModalBodyContent .switcher .switcherItem svg { fill: rgba(218, 218, 218, 0.7) !important; } @@ -631,3 +651,69 @@ ol { :global(body.darkTheme) .span { border-left-color: #4d5566; } + +// Mobile: three long labels (RU "Наступательная кибербезопасность" etc.) +// can't share one horizontal row — stack the use-case switcher vertically +// and slide the pill on the Y axis instead. Selector chain mirrors the +// base nesting so this later block wins at equal specificity. +@media (max-width: 800px) { + // Bottom-sheet: modal fills the viewport edge-to-edge down to the bottom, + // leaving only the site navbar strip visible above. Kills the "cut modal + // with page background peeking underneath" effect. + .ModalOverlay { + align-items: flex-end; + + .Modal { + width: 100%; + height: calc(100% - 52px); + display: flex; + flex-direction: column; + border: none; + border-radius: 0; + + .ModalBody { + max-height: none; + flex: 1 1 auto; + } + } + } + + .ModalOverlay .Modal .ModalBody .ModalBodyContent .switcher { + flex-direction: column; + + &::before { + width: 100%; + height: 33.3333%; + transform: translateY(0); + } + + &:has(.activeHr)::before { + transform: translateY(100%); + } + + &:has(.activeOffsec)::before { + transform: translateY(200%); + } + + .switcherItem { + width: 100%; + justify-content: flex-start; + padding: 10px 14px; + gap: 10px; + text-align: left; + + svg { + flex-shrink: 0; + } + + & + .switcherItem::after { + top: 0; + bottom: auto; + left: 12px; + right: 12px; + width: auto; + height: 1px; + } + } + } +} diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index afaa7903..c3a0c14f 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -19,6 +19,7 @@ import { useRouter } from 'next/router'; import { FC, KeyboardEvent, + TouchEvent, useCallback, useEffect, useRef, @@ -121,6 +122,31 @@ const UXCoreModal: FC = ({ setIsModalClosed(false); }, []); + // Touch swipe left/right navigates next/prev bias (replaces the gesture + // the retired UXCoreModalMobile slider used to provide). + const touchStart = useRef<{ x: number; y: number } | null>(null); + + const handleTouchStart = useCallback((e: TouchEvent) => { + const t = e.touches[0]; + touchStart.current = { x: t.clientX, y: t.clientY }; + }, []); + + const handleTouchEnd = useCallback( + (e: TouchEvent) => { + const start = touchStart.current; + touchStart.current = null; + if (!start) return; + const t = e.changedTouches[0]; + const dx = t.clientX - start.x; + const dy = t.clientY - start.y; + // Require a clearly horizontal gesture so vertical scrolling never + // triggers navigation. + if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return; + handleArrowClick({ active: 'true', dir: dx < 0 ? 'next' : 'prev' }); + }, + [handleArrowClick], + ); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) return; @@ -182,6 +208,8 @@ const UXCoreModal: FC = ({ [styles.hyLang]: locale === 'hy', })} onClick={handleModalClick} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} data-cy="modal-body" > = ({ )} + -
= { 'availability-heuristics': availabilityHeuristics, 'attentional-bias': attentionalBias, 'illusory-truth-effect': illusoryTruthEffect, + 'mere-exposure-effect': mereExposureEffect, }; export const getOffsecBiasContent = ( diff --git a/src/uxcore/data/biasOffsec/mereExposureEffect.ts b/src/uxcore/data/biasOffsec/mereExposureEffect.ts new file mode 100644 index 00000000..e828a5b9 --- /dev/null +++ b/src/uxcore/data/biasOffsec/mereExposureEffect.ts @@ -0,0 +1,52 @@ +// Surface is a chat thread — mere exposure is a build-up effect, so the +// attack only reads if you can see that the sender has been around for a +// while. The `priorContext` line carries that warm-up history. The +// baseline is the same ask arriving cold from a stranger (easy to refuse); +// the flagged variant is the identical ask after weeks of harmless, +// friendly pings. No quoted figures by policy (see project memory +// `feedback_offsec_no_mocked_numbers`) — familiarity-buys-trust is the +// pattern, not a fabricated conversion rate. + +import type { OffsecBiasContent } from './types'; + +const content: OffsecBiasContent = { + scenario: + 'Someone you’ve been chatting with for weeks finally asks for a small favour. Nothing about the ask changed — but your read on it did, because by now the name feels like a colleague, not a stranger. That warmth is the whole operation.', + visualLabel: 'Scenario', + visual: { + before: { + kind: 'chat', + tag: 'Cold ask', + senderName: 'Dana K.', + senderHandle: '@dana_k', + timestamp: 'now', + body: 'Hey! Quick one — can you open this shared doc and sign in with your work account so I can give you edit access?', + }, + after: { + kind: 'chat', + tag: 'Warmed-up ask', + senderName: 'Dana K.', + senderHandle: '@dana_k', + timestamp: 'now', + priorContext: + 'You and “Dana” have traded friendly, low-stakes messages for three weeks — a meme, a “how was your weekend,” a harmless question about the team offsite.', + body: 'Hey! Quick one — can you open this shared doc and sign in with your work account so I can give you edit access?', + flagged: true, + }, + }, + whyItWorksLabel: 'Why it works', + whyItWorks: + 'In the first chat, the ask is easy to refuse — a stranger wants your work login, case closed. In the second, the identical words read like a colleague needing a hand. That’s the mere-exposure effect: the more often we encounter someone, the more we like and trust them — no new evidence required, just repeated contact. The attacker spends the early messages buying nothing but familiarity; each harmless ping nudges “stranger” toward “someone I know,” so when the real ask lands, your brain files it under “a contact asking a favour” instead of “an unknown account requesting credentials.” Same words, same link — only the history changed, and the history was manufactured.', + defenseLabel: 'Protect yourself', + defense: { + lede: 'While your security team handles the perimeter — here’s your homework.', + moves: [ + 'Familiarity is not identity. Weeks of friendly chat don’t verify who’s on the other end — a known-feeling name still has to clear the same bar as a stranger before it gets a credential or a click.', + 'Judge the ask, not the rapport. Strip away the relationship and ask: would I sign in with my work account because a random account told me to? If no cold, then no warm either.', + 'When a long-running contact suddenly needs you to log in somewhere, confirm it through a channel you set up yourself — not by replying in the same thread that built the trust.', + 'Be wary of relationships that exist only to stay pleasant. A contact who never asks for anything for weeks, then asks for exactly one sensitive thing, is following a script.', + ], + }, +}; + +export default content; diff --git a/widget/src/styles.css b/widget/src/styles.css index c36626e0..2a31b7e6 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -1380,12 +1380,14 @@ } .ks-aux-dark .ks-aux-pill { - background: #ece3d2; - color: #1f1d1a; - box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45); + background: #29251f; + color: #ece3d2; + box-shadow: + 0 0 0 1px #3a342d, + 0 4px 18px rgba(0, 0, 0, 0.45); } .ks-aux-dark .ks-aux-pill-open { - background: #d6cdb9; + background: #1f1c19; } .ks-aux-dark .ks-aux-pill-icon { color: #c75d3e; @@ -1600,3 +1602,29 @@ .ks-aux-dark .ks-aux-card-dot { background: #3a342d; } + +/* ===== Touch devices (Android/iOS): no pulsation ===== The pulse glow + competes with reading on small screens; keep the pill static and the + shadow quiet. `hover:none + pointer:coarse` catches phones/tablets + regardless of viewport width; the width fallback covers desktop-mode + mobile browsers that lie about pointer capabilities. */ +@media (hover: none) and (pointer: coarse), (max-width: 768px) { + .ks-aux-pill, + .ks-aux-pill-relevance:not(.ks-aux-pill-open) { + animation: none; + } + + .ks-aux-pill { + box-shadow: 0 3px 12px rgba(31, 29, 26, 0.18); + } + + .ks-aux-pill-relevance:not(.ks-aux-pill-open) { + box-shadow: 0 3px 12px rgba(199, 93, 62, 0.3); + } + + .ks-aux-dark .ks-aux-pill { + box-shadow: + 0 0 0 1px #3a342d, + 0 3px 12px rgba(0, 0, 0, 0.4); + } +} From 51314251dc5a68abd67d95459c958d7f00e185c0 Mon Sep 17 00:00:00 2001 From: manager Date: Sat, 13 Jun 2026 11:49:58 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20UXCG/UX=20Core=20mobile=20modal=20po?= =?UTF-8?q?lish=20=E2=80=94=20fullscreen=20sheet,=20theme=20toggle,=20bias?= =?UTF-8?q?=20popup,=20route=20loaders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revive the orphaned uxcore useMobile hook (dead since the UXCoreOSS fold-in, so phones got desktop layout) and pin slug pages to the new modals. UXCG question modal goes fullscreen on mobile with sticky rating row and nav pills; add theme toggle to its header; restyle the mobile bias popup as a centered card with dark-theme support and same-tab bias links. Route loading overlay on UXCG/Table navigation, tooltip viewport clamp, dark widget pill, UXCAT guest-profile clipping fixes. Co-Authored-By: Claude Opus 4.7 --- src/pages/uxcg/[slug].tsx | 70 +++------- src/pages/uxcore/[slug].tsx | 60 +++----- .../BiasPopupContent.module.scss | 104 +++++++++----- .../BiasPopupContent/BiasPopupContent.tsx | 11 +- .../MobileBiasModal.module.scss | 14 +- .../MobileBiasModal/MobileBiasModal.tsx | 7 +- .../LanguageSwitcher.module.scss | 48 ++++--- .../LanguageSwitcher/LanguageSwitcher.tsx | 21 ++- .../RouteLoadingOverlay.module.scss | 44 ++++++ .../RouteLoadingOverlay.tsx | 41 ++++++ .../components/RouteLoadingOverlay/index.ts | 2 + src/uxcore/components/Table/Table.tsx | 21 +-- .../components/Tooltip/Tooltip.module.scss | 4 +- .../UXCGModal/UXCGModal.module.scss | 103 +++++++++++++- src/uxcore/components/UXCGModal/UXCGModal.tsx | 95 +++++++++---- .../UXCoreModal/UXCoreModal.module.scss | 131 +++++++++++++++++- .../components/UXCoreModal/UXCoreModal.tsx | 95 +++++++++---- .../UserProfile/UserProfile.module.scss | 7 +- .../components/UserProfile/UserProfile.tsx | 2 +- .../_biases/MobileHeader/MobileHeader.tsx | 18 --- .../_biases/MobileView/MobileView.module.scss | 89 ++++++++++++ .../_biases/MobileView/MobileView.tsx | 81 ++++++----- src/uxcore/data/modal/en.ts | 2 + src/uxcore/data/modal/hy.ts | 2 + src/uxcore/data/modal/ru.ts | 2 + src/uxcore/hooks/useMobile.ts | 11 +- .../UXCatLayout/UXCatLayout.module.scss | 6 +- .../layouts/UXCoreLayout/UXCoreLayout.tsx | 3 - src/uxcore/lib/helpers.ts | 25 +++- widget/src/styles.css | 6 + 30 files changed, 819 insertions(+), 306 deletions(-) create mode 100644 src/uxcore/components/RouteLoadingOverlay/RouteLoadingOverlay.module.scss create mode 100644 src/uxcore/components/RouteLoadingOverlay/RouteLoadingOverlay.tsx create mode 100644 src/uxcore/components/RouteLoadingOverlay/index.ts diff --git a/src/pages/uxcg/[slug].tsx b/src/pages/uxcg/[slug].tsx index 99fa8285..0b6ec79e 100644 --- a/src/pages/uxcg/[slug].tsx +++ b/src/pages/uxcg/[slug].tsx @@ -4,8 +4,6 @@ import { getStrapiQuestions } from '@uxcore/api/questions'; import { getTags } from '@uxcore/api/tags'; import SeoGenerator from '@uxcore/components/SeoGenerator'; import UXCGModal from '@uxcore/components/UXCGModal'; -import UXCGModalMobile from '@uxcore/components/UXCGModalMobile'; -import useMobile from '@uxcore/hooks/useMobile'; import UXCGLayout from '@uxcore/layouts/UXCGLayout'; import { getUXCGRedirects } from '@uxcore/lib/getUXCGRedirects'; import { @@ -67,11 +65,9 @@ const Slug: FC = ({ : typeof searchQuery === 'string' ? searchQuery : undefined; - const { isMobile } = useMobile()[1]; const [isModalClosed, setIsModalClosed] = useState(true); const [questionId, setQuestionId] = useState(id); - const [clickedQuestionId, setClickedQuestionId] = useState(null); const [answerId, setAnswerId] = useState(null); const [searchValue, setSearchValue] = useState(searchTerm as string); const [isCopyTooltipVisible, setIsCopyTooltipVisible] = useState(false); @@ -112,7 +108,7 @@ const Slug: FC = ({ router.push(`/uxcg/${newSlug}`, undefined, { scroll: false }); setQuestionId(Number(newId)); }, - [router, isMobile], + [router], ); // Copy link to clipboard @@ -130,7 +126,6 @@ const Slug: FC = ({ const handleQuestionClick = useCallback( (number, slug) => { handleSelectedQuestion(Number(number), slug); - setClickedQuestionId(Number(number)); }, [handleSelectedQuestion], ); @@ -179,48 +174,27 @@ const Slug: FC = ({ createdDate={'2021-07-16'} />

{title}

- {isMobile ? ( - - ) : ( - - )} + {/* UXCGModal carries its own mobile layout now (pills, dark theme, + loaders) — the legacy UXCGModalMobile swap is retired. */} + = ({ }) => { const [activeBiasNumber, setActiveBiasNumber] = useState(null); const [isModalClosed, setIsModalClosed] = useState(true); - const [{ toggleIsProductView }, { isProductView }] = useUXCoreGlobals(); + const [, { isProductView }] = useUXCoreGlobals(); const router = useRouter(); - const { isMobile } = useMobile()[1]; const { locale } = router as TRouter; const slugs = { @@ -149,42 +146,25 @@ const UXCoreIds: FC = ({ modifiedDate={currentActiveBias.updatedAt} />

{currentActiveBias.title}

- {isMobile ? ( - - ) : ( - - )} + {/* UXCoreModal carries its own mobile layout now (pills, dark theme, + loaders) — the legacy UXCoreModalMobile swap is retired. */} + {biases[locale] ? ( = ({ > {!customTip && ( - + {text} diff --git a/src/uxcore/components/AnswerBiasLink/Mobile/MobileBiasModal/MobileBiasModal.module.scss b/src/uxcore/components/AnswerBiasLink/Mobile/MobileBiasModal/MobileBiasModal.module.scss index 69552693..bb31e361 100644 --- a/src/uxcore/components/AnswerBiasLink/Mobile/MobileBiasModal/MobileBiasModal.module.scss +++ b/src/uxcore/components/AnswerBiasLink/Mobile/MobileBiasModal/MobileBiasModal.module.scss @@ -3,11 +3,19 @@ position: fixed; top: 0; left: 0; - background-color: rgba(0, 0, 0, 0.5); width: 100vw; height: 100vh; - z-index: 10; + height: 100dvh; + background-color: rgba(0, 0, 0, 0.55); + // Must sit above the UXCG modal overlay (z-index 10). + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + box-sizing: border-box; } - width: calc(100% - 10px); + width: 100%; + max-width: 420px; } diff --git a/src/uxcore/components/AnswerBiasLink/Mobile/MobileBiasModal/MobileBiasModal.tsx b/src/uxcore/components/AnswerBiasLink/Mobile/MobileBiasModal/MobileBiasModal.tsx index 6361e06c..e9ce688d 100644 --- a/src/uxcore/components/AnswerBiasLink/Mobile/MobileBiasModal/MobileBiasModal.tsx +++ b/src/uxcore/components/AnswerBiasLink/Mobile/MobileBiasModal/MobileBiasModal.tsx @@ -25,8 +25,11 @@ const MobileBiasModal: FC = ({ return ( <> {createPortal( -
-
+
+
e.stopPropagation()} + > = ({ withText = true, }) => { const router = useRouter(); + // Touch devices open via this state (hover-open is desktop-only in CSS), + // so the dropdown reliably closes after a language is picked. + const [isOpen, setIsOpen] = useState(false); const locales = ['en', 'hy', 'ru']; const { asPath } = router; @@ -62,6 +64,7 @@ const LanguageSwitcher: FC = ({ }; const closeDropdown = () => { + setIsOpen(false); detectingLangSwitch && handleDetectingLangSwitch(); }; @@ -72,11 +75,19 @@ const LanguageSwitcher: FC = ({ return (
-
+ ); }; diff --git a/src/uxcore/components/Tooltip/Tooltip.module.scss b/src/uxcore/components/Tooltip/Tooltip.module.scss index 516ec001..57fa5e2a 100644 --- a/src/uxcore/components/Tooltip/Tooltip.module.scss +++ b/src/uxcore/components/Tooltip/Tooltip.module.scss @@ -70,7 +70,9 @@ .Popup { width: 100%; left: 50%; - max-width: 380px; + // 380px barely fits a 390px phone — leave breathing room for the + // JS clamp (8px margins) so the popup never touches the screen edge. + max-width: calc(100vw - 16px); } } diff --git a/src/uxcore/components/UXCGModal/UXCGModal.module.scss b/src/uxcore/components/UXCGModal/UXCGModal.module.scss index 41aac734..87a5e3eb 100644 --- a/src/uxcore/components/UXCGModal/UXCGModal.module.scss +++ b/src/uxcore/components/UXCGModal/UXCGModal.module.scss @@ -123,11 +123,11 @@ } & .ModalHeaderCloseButtonContainer { - width: 75px; + flex-shrink: 0; display: flex; justify-content: flex-end; - align-items: flex-end; - gap: 24px; + align-items: center; + gap: 16px; & .ModalHeaderCloseButton { text-align: right; @@ -325,6 +325,86 @@ } } +.MobileNavButtons { + display: none; +} + +@media (max-width: 800px) { + // Full-screen sheet: the centered-card layout leaked the page behind the + // modal at the bottom edge and let the browser bar clip the nav pills. + .ModalOverlay { + height: 100vh; + height: 100dvh; + align-items: stretch; + justify-content: stretch; + } + + .ModalOverlay .Modal { + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; + height: 100dvh; + border: none; + border-radius: 0; + box-sizing: border-box; + // Desktop arrows sit at ±100px outside the modal — off-screen on phones. + // Reserve a bottom strip for the pills so they never cover the rating row. + padding-bottom: 56px; + + .ModalHeader { + flex: 0 0 auto; + } + + // Body takes all remaining height, so the rating row + pill strip stay + // pinned to the bottom regardless of content length. + .ModalBody { + flex: 1 1 auto; + min-height: 0; + height: auto !important; + } + + .ModalButtons { + display: none; + } + + .MobileNavButtons { + display: flex; + justify-content: space-between; + position: absolute; + bottom: 8px; + left: 16px; + right: 16px; + z-index: 5; + pointer-events: none; + + .MobileNavButton { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 18px; + border: none; + border-radius: 20px; + background: #ffffff; + color: #1f1d1a; + font-size: 14px; + font-weight: 600; + cursor: pointer; + pointer-events: auto; + box-shadow: + 0 0 0 1px #d9d2c5, + 0 3px 12px rgba(31, 29, 26, 0.18); + + img { + height: 14px; + filter: invert(1) brightness(0.4); + } + } + } + } +} + @media (max-width: 1440px) { .ModalOverlay { & .Modal { @@ -431,3 +511,20 @@ } } } + +// Specificity must beat the 4-class mobile rule above. +:global(body.darkTheme) + .ModalOverlay + .Modal + .MobileNavButtons + .MobileNavButton { + background: #262b36; + color: #e8eaf0; + box-shadow: + 0 0 0 1px #3a4150, + 0 3px 12px rgba(0, 0, 0, 0.45); + + img { + filter: none; + } +} diff --git a/src/uxcore/components/UXCGModal/UXCGModal.tsx b/src/uxcore/components/UXCGModal/UXCGModal.tsx index f27e7689..b269b0ff 100644 --- a/src/uxcore/components/UXCGModal/UXCGModal.tsx +++ b/src/uxcore/components/UXCGModal/UXCGModal.tsx @@ -1,3 +1,26 @@ +import AnswerContentGenerator from '@uxcore/components/AnswerContentGenerator'; +import LanguageSwitcher from '@uxcore/components/LanguageSwitcher'; +import ModalRaiting from '@uxcore/components/ModalRaiting'; +import RouteLoadingOverlay, { + useRouteLoading, +} from '@uxcore/components/RouteLoadingOverlay'; +import Tag from '@uxcore/components/Tag'; +import Tooltip from '@uxcore/components/Tooltip'; +import Share from '@uxcore/components/UXCGModalSubComponents/Share'; +import modalIntl from '@uxcore/data/modal'; +import useMobile from '@uxcore/hooks/useMobile'; +import useTooltip from '@uxcore/hooks/useTooltip'; +import { + copyToClipboard, + generateSocialLinks, + updateVH, +} from '@uxcore/lib/helpers'; +import type { + QuestionType, + StrapiBiasType, + TagType, +} from '@uxcore/local-types/data'; +import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; import Image from 'next/image'; import { useRouter } from 'next/router'; @@ -12,22 +35,7 @@ import React, { useState, } from 'react'; -import type { QuestionType, StrapiBiasType, TagType } from '@uxcore/local-types/data'; -import type { TRouter } from '@uxcore/local-types/global'; - -import useMobile from '@uxcore/hooks/useMobile'; -import useTooltip from '@uxcore/hooks/useTooltip'; - -import { copyToClipboard, generateSocialLinks, updateVH } from '@uxcore/lib/helpers'; - -import modalIntl from '@uxcore/data/modal'; - -import AnswerContentGenerator from '@uxcore/components/AnswerContentGenerator'; -import LanguageSwitcher from '@uxcore/components/LanguageSwitcher'; -import ModalRaiting from '@uxcore/components/ModalRaiting'; -import Tag from '@uxcore/components/Tag'; -import Tooltip from '@uxcore/components/Tooltip'; -import Share from '@uxcore/components/UXCGModalSubComponents/Share'; +import ThemeToggle from '@components/ThemeToggle'; import styles from './UXCGModal.module.scss'; @@ -85,6 +93,7 @@ const UXCGModal: FC = ({ const [highlightAnswer, setHighlightAnswer] = useState(false); const [headerHeight, setHeaderHeight] = useState(105); + const [isNavigating, startNavigation] = useRouteLoading(); const modalBodyRef = useRef(null); const modalHeaderRef = useRef(null); @@ -103,12 +112,18 @@ const UXCGModal: FC = ({ nextQuestionId = id === 1 ? 63 : id - 1; } const questionSlug = dir === 'next' ? nextQuestion : prevQuestion; + startNavigation(); onChangeQuestionId(nextQuestionId, questionSlug); } }, - [onChangeQuestionId, id, nextQuestion, prevQuestion], + [onChangeQuestionId, id, nextQuestion, prevQuestion, startNavigation], ); + const handleClose = useCallback(() => { + startNavigation(); + closeModal(); + }, [closeModal, startNavigation]); + const handleHeightCalc = useCallback(() => { updateVH(); @@ -158,7 +173,7 @@ const UXCGModal: FC = ({ if (isOpen) { const arrowClickData: any = {}; - if (e.key === 'Escape') closeModal(); + if (e.key === 'Escape') handleClose(); if (e.key === 'ArrowLeft') { arrowClickData.active = String(id >= 1); arrowClickData.dir = 'prev'; @@ -264,8 +279,15 @@ const UXCGModal: FC = ({ hy: '/hy', }; - const { copyLink, copied, share, answersLabel, relatedQuestionsLabel } = - modalIntl[locale]; + const { + copyLink, + copied, + share, + answersLabel, + relatedQuestionsLabel, + nextLabel, + prevLabel, + } = modalIntl[locale]; const { number, @@ -298,7 +320,7 @@ const UXCGModal: FC = ({ ); return ( -
+
= ({ })}
+ = ({
modal close button
@@ -420,9 +443,10 @@ const UXCGModal: FC = ({ key={index} className={styles.QuestionLink} data-cy={'related-question'} - onClick={() => - handleQuestionClick(question.number, slugs[locale]) - } + onClick={() => { + startNavigation(); + handleQuestionClick(question.number, slugs[locale]); + }} > #{question.number}. @@ -436,6 +460,26 @@ const UXCGModal: FC = ({ )}
+
+ + +
= ({
+
); }; diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index 90d3e768..c07c8871 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -41,6 +41,7 @@ max-height: calc(100vh - 315px); overflow: auto; overflow-y: overlay; + overscroll-behavior: contain; /* width */ &::-webkit-scrollbar { @@ -259,6 +260,88 @@ } } +// Crossfade while a Prev/Next navigation is in flight: opacity only — cheap, +// GPU-composited, reliable on every mobile browser. Nav controls stay solid. +.Modal > *:not(.MobileNavButtons):not(.ModalButtons) { + transition: opacity 0.18s ease; +} + +.Modal.biasSwitching > *:not(.MobileNavButtons):not(.ModalButtons) { + opacity: 0.15; +} + +// Mobile-only Prev/Next pills; desktop keeps the side arrows. +.MobileNavButtons { + display: none; +} + +// full chain to outweigh the base .ModalOverlay .Modal … rule in the media query +// Cool blue-grey to match the dark modal palette (#1b1e26 surfaces, #303338 +// borders) — the widget's warm brown pill looks alien inside this modal. +:global(body.darkTheme) + .ModalOverlay + .Modal + .MobileNavButtons + .MobileNavButton { + background: #262b36; + color: #e8eaf0; + box-shadow: + 0 0 0 1px #3a4150, + 0 3px 12px rgba(0, 0, 0, 0.45); + + img { + // caret SVGs are white already — keep them white on the dark pill + filter: none; + } +} + +// Closing the modal navigates to the list route; cover the modal while that +// hop resolves so a slow connection doesn't read as a dead tap. +.ClosingOverlay { + position: absolute; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.55); + backdrop-filter: blur(1.5px); + animation: modalCloseOverlayIn 0.15s ease; +} + +.ClosingSpinner { + width: 38px; + height: 38px; + border-radius: 50%; + border: 3px solid rgba(40, 88, 123, 0.25); + border-top-color: #28587b; + animation: modalCloseSpin 0.7s linear infinite; +} + +@keyframes modalCloseSpin { + to { + transform: rotate(360deg); + } +} + +@keyframes modalCloseOverlayIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +:global(body.darkTheme) .ModalOverlay .ClosingOverlay { + background: rgba(15, 17, 22, 0.6); +} + +:global(body.darkTheme) .ModalOverlay .ClosingSpinner { + border-color: rgba(127, 179, 213, 0.25); + border-top-color: #7fb3d5; +} + .hyLang { * { font-family: 'NotoSansArmenian-Regular', sans-serif; @@ -657,15 +740,14 @@ ol { // and slide the pill on the Y axis instead. Selector chain mirrors the // base nesting so this later block wins at equal specificity. @media (max-width: 800px) { - // Bottom-sheet: modal fills the viewport edge-to-edge down to the bottom, - // leaving only the site navbar strip visible above. Kills the "cut modal - // with page background peeking underneath" effect. + // Full-screen modal: covers the whole viewport including the navbar strip, + // so no page background peeks through above or below. .ModalOverlay { align-items: flex-end; .Modal { width: 100%; - height: calc(100% - 52px); + height: 100%; display: flex; flex-direction: column; border: none; @@ -674,6 +756,45 @@ ol { .ModalBody { max-height: none; flex: 1 1 auto; + // room for the floating Prev/Next pills + padding-bottom: 76px; + } + + .MobileNavButtons { + display: flex; + justify-content: space-between; + position: absolute; + bottom: 16px; + left: 16px; + right: 16px; + z-index: 5; + pointer-events: none; + + .MobileNavButton { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 18px; + border: none; + border-radius: 20px; + background: #ffffff; + color: #1f1d1a; + font-family: inherit; + font-size: 14px; + font-weight: 600; + box-shadow: + 0 0 0 1px #d9d2c5, + 0 3px 12px rgba(31, 29, 26, 0.18); + cursor: pointer; + + img { + height: 14px; + // caret SVGs are white — ink them dark for the light pill + filter: invert(1) brightness(0.4); + } + } } } } @@ -697,6 +818,8 @@ ol { .switcherItem { width: 100%; + // width:100% + side padding spills past the viewport without border-box + box-sizing: border-box; justify-content: flex-start; padding: 10px 14px; gap: 10px; diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index c3a0c14f..a53d5f75 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -19,7 +19,6 @@ import { useRouter } from 'next/router'; import { FC, KeyboardEvent, - TouchEvent, useCallback, useEffect, useRef, @@ -68,12 +67,19 @@ const UXCoreModal: FC = ({ const [isCopyTooltipVisible, setIsCopyTooltipVisible] = useState(false); const [isQuestionHovered, setIsQuestionHovered] = useState(false); const [isLoading, setIsLoading] = useState(false); + // Closing the modal navigates back to the list route; on a slow connection + // that hop is visibly delayed, so dim + spin until the route settles. + const [isClosing, setIsClosing] = useState(false); const tooltipTimer: { current: any } = useRef(); const modalBodyRef = useRef(null); const shareUrl = typeof window !== 'undefined' ? window.location.href : ''; const { locale } = router as TRouter; const isOpen = !!biasNumber && data; + // biasNumber flips instantly on Prev/Next tap, while data (and the title) + // arrives only after the route change — dim everything until both agree so + // the swap reads as a single motion instead of a staggered repaint. + const isBiasSwitching = !!data && Number(data.number) !== Number(biasNumber); const handleUseCaseClick = useCallback( e => { @@ -122,37 +128,42 @@ const UXCoreModal: FC = ({ setIsModalClosed(false); }, []); - // Touch swipe left/right navigates next/prev bias (replaces the gesture - // the retired UXCoreModalMobile slider used to provide). - const touchStart = useRef<{ x: number; y: number } | null>(null); + const handleClose = useCallback(() => { + setIsClosing(true); + onClose(); + }, [onClose]); - const handleTouchStart = useCallback((e: TouchEvent) => { - const t = e.touches[0]; - touchStart.current = { x: t.clientX, y: t.clientY }; - }, []); + useEffect(() => { + const stop = () => setIsClosing(false); + router.events.on('routeChangeComplete', stop); + router.events.on('routeChangeError', stop); + return () => { + router.events.off('routeChangeComplete', stop); + router.events.off('routeChangeError', stop); + }; + }, [router.events]); - const handleTouchEnd = useCallback( - (e: TouchEvent) => { - const start = touchStart.current; - touchStart.current = null; - if (!start) return; - const t = e.changedTouches[0]; - const dx = t.clientX - start.x; - const dy = t.clientY - start.y; - // Require a clearly horizontal gesture so vertical scrolling never - // triggers navigation. - if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return; - handleArrowClick({ active: 'true', dir: dx < 0 ? 'next' : 'prev' }); - }, - [handleArrowClick], - ); + // Each bias starts reading from the top; keep the page behind the modal + // from scrolling along (iOS scroll chaining). + useEffect(() => { + modalBodyRef.current?.scrollTo?.(0, 0); + }, [biasNumber]); + + useEffect(() => { + if (!isOpen) return; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prevOverflow; + }; + }, [isOpen]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) return; const arrowClickData: any = {}; - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape') handleClose(); if (e.key === 'ArrowLeft') { arrowClickData.active = String(biasNumber >= 1); arrowClickData.dir = 'prev'; @@ -171,7 +182,7 @@ const UXCoreModal: FC = ({ // @ts-ignore document.removeEventListener('keydown', handleKeyDown); }; - }, [biasNumber, handleArrowClick, isOpen]); + }, [biasNumber, handleArrowClick, handleClose, isOpen]); useEffect(() => { data ? setIsLoading(false) : setIsLoading(true); @@ -192,6 +203,8 @@ const UXCoreModal: FC = ({ hrText, offsecText, offsecComingSoon, + nextLabel, + prevLabel, } = modalIntl[locale]; const { linkedIn, facebook, tweeter } = generateSocialLinks( @@ -202,14 +215,13 @@ const UXCoreModal: FC = ({ return isLoading ? ( ) : ( -
+
= ({ number={data.number} productValue={productValue} managementValue={managementValue} - onClose={onClose} + onClose={handleClose} linkedIn={linkedIn} facebook={facebook} tweeter={tweeter} @@ -328,6 +340,26 @@ const UXCoreModal: FC = ({ )}
+
+ + +
= ({
+ {isClosing && ( +
+ +
+ )}
); }; diff --git a/src/uxcore/components/UserProfile/UserProfile.module.scss b/src/uxcore/components/UserProfile/UserProfile.module.scss index 87791469..a2d5f3c0 100644 --- a/src/uxcore/components/UserProfile/UserProfile.module.scss +++ b/src/uxcore/components/UserProfile/UserProfile.module.scss @@ -161,6 +161,8 @@ @media (max-width: 901px) { .userProfile { width: 100%; + // width:100% + side padding overflows the wrapper without border-box. + box-sizing: border-box; background-size: cover; background-position: center; background-repeat: no-repeat; @@ -187,8 +189,6 @@ @media (max-width: 800px) { .userProfile { - max-width: unset; - .wrapper { .userInfo { justify-content: center; @@ -222,7 +222,8 @@ } .btnWrapperMobile { - width: 97vw; + // 97vw ignored the side margins and ran flush into the right screen edge. + width: auto; margin: 0 12px; } } diff --git a/src/uxcore/components/UserProfile/UserProfile.tsx b/src/uxcore/components/UserProfile/UserProfile.tsx index 70c32011..091c6c4c 100644 --- a/src/uxcore/components/UserProfile/UserProfile.tsx +++ b/src/uxcore/components/UserProfile/UserProfile.tsx @@ -137,7 +137,7 @@ const UserProfile: FC = ({ {`${ levelTitle ? levelTitle : guestLevel.attributes.lavelName } (${lvl} ${userLevel || 0}) ${ - !!userLevel ? '' : guestLevel?.attributes?.description + !!userLevel ? '' : guestLevel?.attributes?.description || '' } `} {!loggedIn ? ( diff --git a/src/uxcore/components/_biases/MobileHeader/MobileHeader.tsx b/src/uxcore/components/_biases/MobileHeader/MobileHeader.tsx index a1d58dda..9f2e82fd 100644 --- a/src/uxcore/components/_biases/MobileHeader/MobileHeader.tsx +++ b/src/uxcore/components/_biases/MobileHeader/MobileHeader.tsx @@ -2,7 +2,6 @@ import { getMyInfo } from '@uxcore/api/strapi'; import { userInfoUpdate } from '@uxcore/api/uxcat/settings'; import { getUserInfo } from '@uxcore/api/uxcat/users-me'; import MoonIcon from '@uxcore/assets/icons/MoonIcon'; -import PodcastIcon from '@uxcore/assets/icons/PodcastIcon'; import SunIcon from '@uxcore/assets/icons/SunIcon'; import { GlobalContext } from '@uxcore/components/Context/GlobalContext'; import LanguageSwitcher from '@uxcore/components/LanguageSwitcher'; @@ -21,9 +20,7 @@ import { FC, useContext, useEffect, useMemo, useState } from 'react'; import styles from './MobileHeader.module.scss'; type MobileHeaderProps = { - setHeaderPodcastOpen?: (updater: (prev: boolean) => boolean) => void; setUpdatedSettingsInfo?: (data: UserTypes) => void; - isPodcastOpen?: boolean; changeUserUrl?: boolean; instantSave?: boolean; isUserProfile?: boolean; @@ -35,8 +32,6 @@ type MobileHeaderProps = { blockLanguageSwitcher?: boolean; }; const MobileHeader: FC = ({ - setHeaderPodcastOpen, - isPodcastOpen, changeUserUrl, setSelectedTitle, isUserProfile, @@ -89,9 +84,6 @@ const MobileHeader: FC = ({ }; const title = changedTitle ? userInfo?.title : userInfo?.title; - const openPodcast = () => { - setHeaderPodcastOpen(prev => !prev); - }; const handleOpenSettings = () => { setOpenSettings(true); @@ -168,16 +160,6 @@ const MobileHeader: FC = ({
- {router.pathname === '/uxcore' && locale !== 'hy' && ( -
- -
- )}
@@ -281,9 +285,7 @@ const MobileView: FC = ({

UX CORE

uxcore.io

{description}

- - {explanationLink.title} - + )} +
-
-
- {isOffsecView ? : } - {offsecText} -
+ {offsecEnabled && ( +
+ {offsecActive ? : } + {offsecText} +
+ )}
- {isOffsecView ? ( + {offsecActive ? ( (() => { const offsecContent = getOffsecBiasContent(biasNumber); return offsecContent ? ( @@ -312,7 +321,7 @@ const UXCoreModal: FC = ({ )}
- {!isOffsecView && data.title && ( + {!offsecActive && data.title && ( )} {questions.length > 0 && (