+ {displayedTags.length === 0 && (
+
No tags yet.
+ )}
{displayedTags.map(tag => (
))}
diff --git a/src/layouts/library/Home/Home.module.scss b/src/layouts/library/Home/Home.module.scss
index 0a14f38d..0590437e 100644
--- a/src/layouts/library/Home/Home.module.scss
+++ b/src/layouts/library/Home/Home.module.scss
@@ -60,6 +60,18 @@
}
}
+ .searchGroup {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+
+ @media (max-width: 590px) {
+ width: 100%;
+ flex-direction: column;
+ align-items: stretch;
+ }
+ }
+
.input {
max-width: 348px;
border-radius: 6px;
@@ -77,6 +89,17 @@
top: 9px;
}
}
+
+ .createButton {
+ flex-shrink: 0;
+ white-space: nowrap;
+
+ // The shared plus icon ships a hardcoded brown fill, which disappears on
+ // the brown primary button — force it (and the label) white.
+ svg path {
+ fill: var(--white);
+ }
+ }
}
.content {
diff --git a/src/layouts/library/Home/Home.tsx b/src/layouts/library/Home/Home.tsx
index aaa63195..40c8a29d 100644
--- a/src/layouts/library/Home/Home.tsx
+++ b/src/layouts/library/Home/Home.tsx
@@ -1,10 +1,15 @@
import { mapStrapiLibrariesResponseToCards } from '@utils/library/mapStrapiLibraries';
+import { useRouter } from 'next/router';
import React, { useEffect, useMemo, useState } from 'react';
import type { HomeLibraryCardView } from '@local-types/library/library';
import { getLibrariesPaginated } from '@api/library/getLibrariesPaginated';
+import { getMyLibrary } from '@api/library/getMyLibrary';
+import PlusIcon from '@icons/library/svg/plus.svg';
+
+import { useAuth } from '@components/Context/library/AuthContext';
import { Text, TypographyVariant } from '@components/library/atoms/Text';
import { AboutLibraryModal } from '@components/library/molecules/AboutLibraryModal';
import {
@@ -26,6 +31,41 @@ const sectionId = 'libraries-section';
const perPage = 6;
export function HomeTemplate({ data: dataOverride }: HomeTemplateProps) {
+ const router = useRouter();
+ const { accountData } = useAuth();
+
+ // Creating a library is gated by the `can-create-library` feature flag from
+ // GET /api/users/me — the same gate the user dropdown's "Create library" item
+ // uses. A library has no standalone create step: it's bootstrapped on the
+ // owner's own page, so the button just routes there when the flag is present.
+ const canCreateLibrary =
+ accountData?.featureNames?.includes('can-create-library') ?? false;
+
+ // A user may create at most one library, so the button is also disabled once
+ // they already own one. Check via the owner-scoped lookup the library page
+ // uses, not the home grid (which is paginated and may not include theirs).
+ const [hasLibrary, setHasLibrary] = useState(false);
+ useEffect(() => {
+ if (!accountData?.id) {
+ setHasLibrary(false);
+ return;
+ }
+ let cancelled = false;
+ getMyLibrary(accountData.id).then(lib => {
+ if (!cancelled) setHasLibrary(lib !== null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [accountData?.id]);
+
+ const createDisabled = !canCreateLibrary || hasLibrary;
+
+ const handleCreateLibrary = () => {
+ if (createDisabled || !accountData?.username) return;
+ router.push(`/library/${accountData.username}`);
+ };
+
const [value, setValue] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
@@ -199,15 +239,26 @@ export function HomeTemplate({ data: dataOverride }: HomeTemplateProps) {
>
Libraries
-
setValue('')}
- wrapperClassName={styles.input}
- />
+
+ setValue('')}
+ wrapperClassName={styles.input}
+ />
+ }
+ onClick={handleCreateLibrary}
+ disabled={createDisabled}
+ className={styles.createButton}
+ />
+
diff --git a/src/layouts/library/Library/Library.module.scss b/src/layouts/library/Library/Library.module.scss
index d53f7abe..0613f127 100644
--- a/src/layouts/library/Library/Library.module.scss
+++ b/src/layouts/library/Library/Library.module.scss
@@ -3,7 +3,6 @@
min-width: 0;
@media (max-width: 1024px) {
- padding: 16px;
min-height: calc(100vh - 168px);
}
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/styles/globals.scss b/src/styles/globals.scss
index 8056fa45..650ac572 100644
--- a/src/styles/globals.scss
+++ b/src/styles/globals.scss
@@ -267,6 +267,15 @@ body {
font-display: block;
}
+@font-face {
+ font-family: 'Source Sans Pro';
+ src: url('/keepsimple_/fonts/SourceSansPro/SourceSansPro-Regular.woff2')
+ format('woff2');
+ font-weight: 400;
+ font-style: normal;
+ font-display: block;
+}
+
// Aldrich is being used in Company Management(new)
@font-face {
font-family: 'Aldrich';
diff --git a/src/styles/library/variables.scss b/src/styles/library/variables.scss
index 2be45c5f..5d0d8f46 100644
--- a/src/styles/library/variables.scss
+++ b/src/styles/library/variables.scss
@@ -113,5 +113,7 @@
/* Fonts — the upstream library injected these via next/font on ;
here we declare them directly with web-safe fallbacks. */
--font-source-serif: 'Source Serif 4', Georgia, 'Times New Roman', serif;
+ --font-source-sans:
+ 'Source Sans Pro', system-ui, -apple-system, Arial, sans-serif;
--font-lato: 'Lato', system-ui, -apple-system, Arial, sans-serif;
}
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/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*/}
-
+
+
= ({
),
);
}
- }, []);
+ }, [uxCoreData]);
return (
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);
+};
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;
}