From d4171faca3aa9be547250fce78dc20c1258d7260 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 3 Aug 2026 16:38:19 +0200 Subject: [PATCH 1/4] fix(uxcat): load bias descriptions during the test uxCoreData was hardcoded to null in the UX Core context value, so every consumer of the shared bias list saw an empty array. On the ongoing test the bias name comes from the test API and rendered fine, but the description came from context and sat on a skeleton forever. Reported as a geo/VPN issue; it affected everyone. Populate it client-side from a slim Strapi query gated to /uxcat routes. The full bias payload is ~1.4 MB and those screens only read number, title, description, slug and mentionedQuestionsIds, so select just those and cache per module. mentionedQuestionsIds is required: test-result JSON.parses it to pick recommended reading and would throw on null. Also fixes QuestionAnalyse, which built its list in an effect with an empty dependency array and so never saw the data arrive. Co-Authored-By: Claude Opus 4.7 --- src/pages/_app.tsx | 23 +++++++++- src/uxcore/api/biases.ts | 44 +++++++++++++++++++ .../QuestionAnalyse/QuestionAnalyse.tsx | 20 ++++----- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index df1730b9..f0399a94 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,3 +1,4 @@ +import { getSlimBiases } from '@uxcore/api/biases'; import { getOurProjects } from '@uxcore/api/our-projects'; import { GlobalContext as UXCoreGlobalContext } from '@uxcore/components/Context/GlobalContext'; import { NewUpdateModalContainer } from '@uxcore/components/NewUpdateModal'; @@ -42,6 +43,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { const [selectedTitle, setSelectedTitle] = useState(''); const [updatedUsername, setUpdatedUsername] = useState(''); const [ourProjectsModalData, setOurProjectsModalData] = useState(null); + const [uxCoreData, setUxCoreData] = useState(null); const isIndexingOn = process.env.NEXT_PUBLIC_INDEXING === 'on'; const isProduction = process.env.NEXT_PUBLIC_ENV === 'prod'; @@ -276,6 +278,24 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { }; }, [isUxcoreRoute, router.locale]); + const isUxcatRoute = router.pathname.startsWith('/uxcat'); + + useEffect(() => { + if (!isUxcatRoute || uxCoreData) return; + let cancelled = false; + (async () => { + try { + const data = await getSlimBiases(); + if (!cancelled) setUxCoreData(data); + } catch (err) { + console.warn('[uxcat-biases] fetch failed:', err); + } + })(); + return () => { + cancelled = true; + }; + }, [isUxcatRoute, uxCoreData]); + const uxcoreContextValue = useMemo( () => ({ accountData, @@ -289,7 +309,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { setUpdatedUsername, ourProjectsModalData, setOurProjectsModalData, - uxCoreData: null, + uxCoreData, uxcgLocalizedData: null, uxcgData: null, }), @@ -299,6 +319,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { selectedTitle, updatedUsername, ourProjectsModalData, + uxCoreData, ], ); diff --git a/src/uxcore/api/biases.ts b/src/uxcore/api/biases.ts index 20cbbd26..a51ab586 100644 --- a/src/uxcore/api/biases.ts +++ b/src/uxcore/api/biases.ts @@ -1,8 +1,52 @@ let cachedBiases: any = null; +let cachedSlimBiases: any = null; const LOCALES = ['en', 'ru', 'hy']; const PAGE_SIZE = 100; const TOTAL_ITEMS_EXPECTED = 105; +const SLIM_FIELDS = [ + 'number', + 'title', + 'description', + 'slug', + // test-result.tsx JSON.parses this to pick recommended reading — omitting it + // makes that page throw once the bias list is populated. + 'mentionedQuestionsIds', +]; +const SLIM_LOCALES = ['en', 'ru']; + +// UXCAT renders bias titles/descriptions from the shared bias list, but the +// full payload (SEO, OG images, usage sections) is ~30x larger than what those +// screens read. Fetch only the fields UXCAT needs. +export const getSlimBiases = async () => { + if (cachedSlimBiases) return cachedSlimBiases; + + const fieldsQuery = SLIM_FIELDS.map( + (field, index) => `fields[${index}]=${field}`, + ).join('&'); + + const allData = { en: [], ru: [], hy: [] }; + + for (const locale of SLIM_LOCALES) { + let page = 1; + while (true) { + const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/biases?locale=${locale}&sort=number&pagination[pageSize]=${PAGE_SIZE}&pagination[page]=${page}&${fieldsQuery}`; + const res = await fetch(url); + const json = await res.json(); + + if (!json.data || json.data.length === 0) break; + + allData[locale].push(...json.data); + + if (json.data.length < PAGE_SIZE) break; + page++; + } + } + + cachedSlimBiases = allData; + return allData; +}; + export const getStrapiBiases = async () => { if (cachedBiases) return cachedBiases; diff --git a/src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx b/src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx index bddd04fc..af8a07ba 100644 --- a/src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx +++ b/src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx @@ -1,17 +1,13 @@ -import cn from 'classnames'; -import { useRouter } from 'next/router'; -import { FC, useContext, useEffect, useState } from 'react'; - -import { StrapiBiasType } from '@uxcore/local-types/data'; -import { TRouter } from '@uxcore/local-types/global'; - -import { mergeBiasesLocalization } from '@uxcore/lib/helpers'; - -import questionAnalyseData from '@uxcore/data/uxcat/questionAnalyse'; - import ContentParser from '@uxcore/components/ContentParser'; import { GlobalContext } from '@uxcore/components/Context/GlobalContext'; import Modal from '@uxcore/components/Modal'; +import questionAnalyseData from '@uxcore/data/uxcat/questionAnalyse'; +import { mergeBiasesLocalization } from '@uxcore/lib/helpers'; +import { StrapiBiasType } from '@uxcore/local-types/data'; +import { TRouter } from '@uxcore/local-types/global'; +import cn from 'classnames'; +import { useRouter } from 'next/router'; +import { FC, useContext, useEffect, useState } from 'react'; import styles from './QuestionAnalyse.module.scss'; @@ -73,7 +69,7 @@ const QuestionAnalyse: FC = ({ ), ); } - }, []); + }, [uxCoreData]); return ( Date: Mon, 3 Aug 2026 16:38:41 +0200 Subject: [PATCH 2/4] fix(uxcore): let the shared modal scroll instead of clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper had no height ceiling, so on short viewports it grew past the screen and the centred overlay clipped it at both ends — the header and the footer buttons were unreachable. Reported against the "Our projects" modal on /ru, but it is the shared UX Core modal, so every consumer had it. Cap the wrapper, stop the header shrinking, and give the body min-height:0 — without that the flex child cannot shrink below its content height and overflow:auto never engages. The mobile fullHeightMobile override still wins by source order. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/components/Modal/Modal.module.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/uxcore/components/Modal/Modal.module.scss b/src/uxcore/components/Modal/Modal.module.scss index 4fa08fba..52487e1f 100644 --- a/src/uxcore/components/Modal/Modal.module.scss +++ b/src/uxcore/components/Modal/Modal.module.scss @@ -25,6 +25,9 @@ position: relative; z-index: 80; border-radius: 4px; + // Without a ceiling the wrapper grows past the viewport and the centered + // overlay clips it at both ends, putting the header and footer out of reach. + max-height: calc(100vh - 40px); animation: wrapperIn 0.2s ease-out; &.wrapperClosing { @@ -44,6 +47,7 @@ .header { width: 100%; display: flex; + flex-shrink: 0; justify-content: space-between; padding-top: 13px; padding-bottom: 8px; @@ -74,6 +78,10 @@ .body { padding: 16px 28px; + flex: 1 1 auto; + // min-height:0 lets this flex child shrink below its content height — + // otherwise overflow:auto never engages and nothing scrolls. + min-height: 0; overflow: auto; // Slim styled scrollbar so the inside of the modal does not look From 8b2cf721157564d9b90960d8301c1e2d292b20ab Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 3 Aug 2026 16:39:02 +0200 Subject: [PATCH 3/4] fix(uxcat): emit a valid .ics so Apple Calendar accepts the reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The downloaded file was missing PRODID, UID and DTSTAMP — all required by RFC 5545 — used LF instead of CRLF, and wrote NaN for the dates: nextTestTime arrives as an epoch string and new Date('1758...') is an Invalid Date, so the timestamps never rendered. Apple Calendar rejects the file outright; more lenient clients had been hiding it. Replace react-icalendar-link with a small local builder that emits the required properties, escapes TEXT per 3.3.11, leaves URL unescaped since it is a URI value, and folds lines at 75 octets rather than characters (Cyrillic copy overruns a character-based fold). The UID is derived from the start time so re-downloading updates the event instead of stacking duplicates. Co-Authored-By: Claude Opus 4.7 --- .../AddToCalendar/AddToCalendar.tsx | 34 ++--- .../CalendarItems/CalendarItems.tsx | 13 +- src/uxcore/lib/ics.ts | 133 ++++++++++++++++++ 3 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 src/uxcore/lib/ics.ts diff --git a/src/uxcore/components/AddToCalendar/AddToCalendar.tsx b/src/uxcore/components/AddToCalendar/AddToCalendar.tsx index 0def0567..aaae7978 100644 --- a/src/uxcore/components/AddToCalendar/AddToCalendar.tsx +++ b/src/uxcore/components/AddToCalendar/AddToCalendar.tsx @@ -1,17 +1,14 @@ +import CalendarItems from '@uxcore/components/CalendarItems'; +import Modal from '@uxcore/components/Modal'; +import calendar from '@uxcore/data/uxcat/calendar'; +import { useClickOutside } from '@uxcore/hooks/useClickOutside'; +import useMobile from '@uxcore/hooks/useMobile'; +import { getEventWindow, toICalUTC } from '@uxcore/lib/ics'; +import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; import { useRouter } from 'next/router'; import { FC, useState } from 'react'; -import type { TRouter } from '@uxcore/local-types/global'; - -import { useClickOutside } from '@uxcore/hooks/useClickOutside'; -import useMobile from '@uxcore/hooks/useMobile'; - -import calendar from '@uxcore/data/uxcat/calendar'; - -import CalendarItems from '@uxcore/components/CalendarItems'; -import Modal from '@uxcore/components/Modal'; - import styles from './AddToCalendar.module.scss'; type AddToCalendarProps = { @@ -33,18 +30,23 @@ const AddToCalendar: FC = ({ const currentLocale = locale === 'ru' ? 'ru' : 'en'; const { addToCalendar, title, description } = calendar[currentLocale]; - const calendarDescription = `${description} ${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`; + const testUrl = `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`; + const calendarDescription = `${description} ${testUrl}`; + + const eventWindow = getEventWindow(startTime); + if (!eventWindow) return null; const event = { - title: title, - startTime: startTime?.toString(), + title, + start: eventWindow.start, + end: eventWindow.end, description: calendarDescription, - url: `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`, + url: testUrl, }; - const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(event.title)}&dates=${event.startTime}&details=${encodeURIComponent(calendarDescription)}`; + const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${toICalUTC(eventWindow.start)}/${toICalUTC(eventWindow.end)}&details=${encodeURIComponent(calendarDescription)}`; - const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(event.title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${event.startTime}`; + const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${eventWindow.start.toISOString()}&enddt=${eventWindow.end.toISOString()}`; return ( <> diff --git a/src/uxcore/components/CalendarItems/CalendarItems.tsx b/src/uxcore/components/CalendarItems/CalendarItems.tsx index 85dc8af4..d9d5ffd5 100644 --- a/src/uxcore/components/CalendarItems/CalendarItems.tsx +++ b/src/uxcore/components/CalendarItems/CalendarItems.tsx @@ -1,11 +1,11 @@ +import { buildICS, CalendarEvent, downloadICS } from '@uxcore/lib/ics'; import Image from 'next/image'; import { FC } from 'react'; -import ICalendarLink from 'react-icalendar-link'; import styles from './CalendarItems.module.scss'; type CalendarItemsProps = { - event: any; + event: CalendarEvent; googleCalendarUrl: string; outlookCalendarUrl: string; }; @@ -17,8 +17,11 @@ const CalendarItems: FC = ({ return (
- {/*@ts-ignore*/} - +
String(value).padStart(2, '0'); + +// nextTestTime arrives as an epoch timestamp, and `new Date('1758...')` parses +// as Invalid Date — numeric strings have to be coerced to a number first. +export const parseEventDate = ( + value?: string | number | Date | null, +): Date | null => { + if (value === null || value === undefined || value === '') return null; + + const raw = value instanceof Date ? value : String(value).trim(); + const normalized = + typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : raw; + + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const getEventWindow = (value?: string | number | Date | null) => { + const start = parseEventDate(value); + if (!start) return null; + + return { start, end: new Date(start.getTime() + DEFAULT_DURATION_MS) }; +}; + +// RFC 5545 UTC date-time: YYYYMMDDTHHMMSSZ +export const toICalUTC = (date: Date) => + [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + 'T', + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + 'Z', + ].join(''); + +// RFC 5545 §3.3.11 +const escapeText = (value: string) => + value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n'); + +// RFC 5545 §3.1 — content lines are folded at 75 octets, and Cyrillic copy +// blows past that in half the characters, so fold by byte length not length. +const foldLine = (line: string) => { + const encoder = new TextEncoder(); + if (encoder.encode(line).length <= MAX_LINE_OCTETS) return line; + + const chunks: string[] = []; + let current = ''; + let currentOctets = 0; + + for (const char of line) { + const octets = encoder.encode(char).length; + if (currentOctets + octets > MAX_LINE_OCTETS) { + chunks.push(current); + current = ''; + currentOctets = 0; + } + current += char; + currentOctets += octets; + } + chunks.push(current); + + return chunks.join(`${CRLF} `); +}; + +export const buildICS = ({ + title, + start, + end, + description, + url, +}: CalendarEvent) => { + const startUTC = toICalUTC(start); + const endUTC = toICalUTC( + end ?? new Date(start.getTime() + DEFAULT_DURATION_MS), + ); + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + `PRODID:${PRODID}`, + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + // Deterministic UID so re-downloading updates the event instead of + // stacking duplicates in the user's calendar. + `UID:uxcat-${startUTC}@keepsimple.io`, + `DTSTAMP:${toICalUTC(new Date())}`, + `DTSTART:${startUTC}`, + `DTEND:${endUTC}`, + `SUMMARY:${escapeText(title)}`, + ]; + + if (description) lines.push(`DESCRIPTION:${escapeText(description)}`); + // URL is a URI value, not TEXT — it must not be backslash-escaped. + if (url) lines.push(`URL:${url}`); + + lines.push('END:VEVENT', 'END:VCALENDAR'); + + return `${lines.map(foldLine).join(CRLF)}${CRLF}`; +}; + +export const downloadICS = (content: string, filename: string) => { + const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); + const objectUrl = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +}; From 187353593aa38190a09fdc9528d0dcc034f43d54 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 3 Aug 2026 16:39:30 +0200 Subject: [PATCH 4/4] fix(widget): lift the Copilot pill 70px on UX Core pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Those pages keep their own controls in the bottom-right corner, where the pill sits. Add clearance via a --ks-aux-lift custom property, set from the uxcorePage body class that _app.tsx already applies to /uxcore, /uxcg, /uxcp, /uxcat and /uxcore-api. The margin goes on the root rather than the pill: the panel is positioned against the root, so raising the pill alone would leave the panel anchored low and overlapping it. On mobile the panel height comes from --ks-aux-panel-h, and a JS-set custom property overrides the CSS fallback entirely, so the lift has to be subtracted there too — otherwise the panel keeps its full height and runs off the top of short screens. Co-Authored-By: Claude Opus 4.7 --- widget/src/AskUxCore.tsx | 12 +++++++++++- widget/src/styles.css | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index b98d25d1..b5579048 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -2184,9 +2184,19 @@ export function AskUxCore({ lang }: { lang: Lang }) { const occluded = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0; + // Host pages can lift the widget off the bottom edge (--ks-aux-lift). + // That clearance eats into the room the panel has, so subtract it or + // the panel overflows the top of short screens. + const widget = document.querySelector('.ks-aux-root'); + const lift = widget + ? parseFloat(getComputedStyle(widget).getPropertyValue('--ks-aux-lift')) + : 0; root.style.setProperty('--ks-aux-vh', `${h}px`); root.style.setProperty('--ks-aux-bottom-offset', `${occluded}px`); - root.style.setProperty('--ks-aux-panel-h', `${Math.max(220, h - 96)}px`); + root.style.setProperty( + '--ks-aux-panel-h', + `${Math.max(220, h - 96 - (lift || 0))}px`, + ); }; setVh(); const vv = window.visualViewport; diff --git a/widget/src/styles.css b/widget/src/styles.css index 8d027253..046f47ed 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -2,6 +2,10 @@ position: fixed; right: 24px; bottom: 24px; + /* Extra clearance for host pages that park their own controls in the + bottom-right corner. Margin (not `bottom`) so it composes with the + mobile keyboard offset below without rewriting that calc. */ + margin-bottom: var(--ks-aux-lift, 0px); z-index: 2147483600; font-family: 'Jost-Regular', 'Jost', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; @@ -24,6 +28,13 @@ } } +/* UX Core surfaces (uxcore + bias pages, uxcg + questions, uxcp, uxcat, + uxcore-api) keep their own chrome in the bottom-right corner. `_app.tsx` + sets body.uxcorePage for exactly those routes. */ +body.uxcorePage .ks-aux-root { + --ks-aux-lift: 70px; +} + @keyframes ks-aux-pulse { 0%, 100% { box-shadow: 0 4px 16px rgba(31, 29, 26, 0.16); @@ -1312,7 +1323,10 @@ right: 0; bottom: 60px; /* sits just above the pill */ width: auto; - height: var(--ks-aux-panel-h, calc(100dvh - 96px)); + height: var( + --ks-aux-panel-h, + calc(100dvh - 96px - var(--ks-aux-lift, 0px)) + ); max-height: 640px; border-radius: 14px; }