+ Among the most consequential whitehat hackers alive.
+
+
+
+ Co-founder of{' '}
+
+ Hexens
+ {' '}
+ — the cybersecurity firm whose audits have safeguarded over
+ $125B in assets.
+
+
+
+
+ Authored the Aptos critical-vulnerability research —
+ unpatched, the flaw would have erased over $1T from Web3.
+
+
+ Authored the disclosure behind the largest critical
+ vulnerability in Web3 history — $500M of instant loss and
+ $1.7T of cascade-effect damage on the table. Caught in private
+ disclosure; exploitation never landed.
+
+
+ Uncovered the first critical Solidity compiler vulnerability
+ in over a decade — the{' '}
+
+ TSTORE poison bug
+
+ .
+
= ({
number === 7 || number === 19 || number === 87,
})}
>
+ {
const router = useRouter();
const { locale } = router as TRouter;
@@ -70,6 +72,7 @@ const ViewSwitcher = ({
className={cn(styles.ViewSwitcher, {
[styles.FolderView]: isSecondView,
[styles.CoreView]: !isSecondView,
+ [styles.wide]: wide,
[className]: className,
})}
>
diff --git a/src/uxcore/components/_uxcp/LogInModal/LogInModal.tsx b/src/uxcore/components/_uxcp/LogInModal/LogInModal.tsx
index 35e2b908..dddd10e7 100644
--- a/src/uxcore/components/_uxcp/LogInModal/LogInModal.tsx
+++ b/src/uxcore/components/_uxcp/LogInModal/LogInModal.tsx
@@ -1,28 +1,19 @@
-import { useRouter } from 'next/router';
-import { signOut, useSession } from 'next-auth/react';
-import { FC, useContext } from 'react';
-
-import { TRouter } from '@uxcore/local-types/global';
-
-import { setRedirectCookie } from '@uxcore/lib/cookies';
-
-import decisionTable from '@uxcore/data/decisionTable';
-
import DiscordIcon from '@uxcore/assets/icons/DiscordIcon';
import GoogleIcon from '@uxcore/assets/icons/GoogleIcon';
import MailRuIcon from '@uxcore/assets/icons/MailRuIcon';
import XIcon from '@uxcore/assets/icons/XIcon';
import YandexIcon from '@uxcore/assets/icons/YandexIcon';
-
import Button from '@uxcore/components/Button';
import { GlobalContext } from '@uxcore/components/Context/GlobalContext';
import MagicLinkEmailForm from '@uxcore/components/LogIn/MagicLinkEmailForm';
import Modal from '@uxcore/components/Modal';
-
-import {
- handleMixpanelSignUp,
- trackLogInSource,
-} from '@uxcore/lib/mixpanel';
+import decisionTable from '@uxcore/data/decisionTable';
+import { setRedirectCookie } from '@uxcore/lib/cookies';
+import { handleMixpanelSignUp, trackLogInSource } from '@uxcore/lib/mixpanel';
+import { TRouter } from '@uxcore/local-types/global';
+import { useRouter } from 'next/router';
+import { signOut, useSession } from 'next-auth/react';
+import { FC, useContext } from 'react';
import styles from './LogInModal.module.scss';
diff --git a/src/uxcore/data/biasOffsec/attentionalBias.ts b/src/uxcore/data/biasOffsec/attentionalBias.ts
new file mode 100644
index 00000000..5f700022
--- /dev/null
+++ b/src/uxcore/data/biasOffsec/attentionalBias.ts
@@ -0,0 +1,49 @@
+// No quoted figures by policy. Two surfaces side-by-side on purpose: the
+// loud decoy is a phone push (Microsoft Defender lock-screen toast), the
+// quiet ask is an email landing in the inbox at the same minute. Mixing
+// channels makes the "your attention is the budget" point visible —
+// the attacker doesn't care which app delivers the request, only that
+// the noisy one absorbs the eye while the quiet one slides past.
+
+import type { OffsecBiasContent } from './types';
+
+const content: OffsecBiasContent = {
+ scenario:
+ 'Two pings hit you inside the same minute — one a phone push, the other an email. One is loud and demands you act right now. The other is quiet and looks routine. Your attention has a budget — the attacker chose where to spend it.',
+ visualLabel: 'Scenario',
+ visual: {
+ before: {
+ kind: 'notification',
+ tag: 'Loud decoy',
+ appName: 'Microsoft Defender',
+ timestamp: 'now',
+ title: 'Unauthorized sign-in from Moscow',
+ body: 'Confirm or lock the account before further damage. Tap to review.',
+ flagged: true,
+ },
+ after: {
+ kind: 'email',
+ tag: 'Quiet ask',
+ sender: 'approvals@acme-vendor.com',
+ timestamp: '1 min ago',
+ subject: 'Acme Vendor updated their bank details',
+ preview:
+ 'New routing + account on file. Same totals, same schedule — approve to keep payments flowing.',
+ },
+ },
+ whyItWorksLabel: 'Why it works',
+ whyItWorks:
+ 'Attentional bias plus an attacker who has read about it. Your brain does not allocate attention evenly — it sprints toward the loudest, most threat-shaped thing in your field of view. A red banner with the word “unauthorized” captures the budget; a routine bank-details change does not. So you triage the decoy, feel responsible, and never quite see the small one a minute earlier. Two notifications arrived; one paid the attacker.',
+ defenseLabel: 'Protect yourself',
+ defense: {
+ lede: 'While your security team handles the perimeter — here’s your homework.',
+ moves: [
+ 'When something loud and urgent grabs you, hold for a beat and scan the rest of your screen from the same window. The point of the noisy one might be to make you miss the quiet one.',
+ 'Anything that touches money, credentials, or vendor banking details deserves a fresh out-of-band confirmation — even when it looks routine, and especially when you are mid-fire on something else.',
+ 'Treat any “urgent sign-in alert” as a question, not an instruction. Open the affected app from your home screen — never the notification’s deep link — and check the session list yourself.',
+ 'After you have handled the noisy one, do one more sweep: anything else from that hour that asked you to do something? Decoys travel in pairs.',
+ ],
+ },
+};
+
+export default content;
diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts
new file mode 100644
index 00000000..c4864845
--- /dev/null
+++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts
@@ -0,0 +1,56 @@
+// Figures and operational windows are deliberately absent from this
+// content: any number quoted in the OffSec layer must be sourced (see
+// project memory `feedback_offsec_no_mocked_numbers`). The directional
+// pattern — that topical, news-anchored lures outperform generic ones —
+// is well documented; the specific lift is not the point of the page.
+//
+// Surface is a browser tab (lookalike-domain landing page), NOT email,
+// so the three OffSec bias cards don't all read as "another inbox".
+// Post-breach phishing increasingly arrives via sponsored search
+// results and headline-anchored URLs — fits availability heuristic
+// better than a generic vendor email anyway.
+
+import type { OffsecBiasContent } from './types';
+
+const content: OffsecBiasContent = {
+ scenario:
+ 'A major company just got breached and the news is everywhere. You go looking for answers — and the page you land on is anchored to the headline you just read.',
+ visualLabel: 'Scenario',
+ visual: {
+ before: {
+ kind: 'browser',
+ tag: 'Generic',
+ host: 'vendor-portal.acme.com',
+ path: '/billing',
+ pageHeading: 'Q3 invoice summary',
+ pageBody:
+ 'Your invoice for the previous billing period is ready. Routine summary — no action required this cycle.',
+ },
+ after: {
+ kind: 'browser',
+ tag: 'News-anchored',
+ host: 'northbank-breach-check.acme-vendor-security.com',
+ path: '/sso',
+ pageHeading: 'Confirm SSO to scope your NorthBank exposure',
+ pageBody:
+ 'Our team flagged your domain in the NorthBank dataset. Sign in with your work account so we can scope the exposure before EOD.',
+ cta: 'Sign in with SSO',
+ flagged: true,
+ },
+ },
+ whyItWorksLabel: 'Why it works',
+ whyItWorks:
+ 'Availability heuristic colliding with base-rate neglect. After a breach saturates the news, your brain stops asking “how likely is this real?” and starts asking “how easy is it to recall?” — and right now, the answer is everywhere. You substitute “I just read about this” for “I should verify this URL,” and pattern-match the landing page to the news cycle, not to phishing. Identical payload; the news desk is doing the social engineering.',
+ defenseLabel: 'Protect yourself',
+ defense: {
+ lede: 'While your security team handles the perimeter — here’s your homework.',
+ moves: [
+ 'When a page leans on today’s news to get you moving, that’s exactly when to slow down — not speed up. The urgency you feel is the attack working.',
+ 'Read the full hostname left-to-right before you type anything. Attackers stack the brand you trust as a subdomain of a domain they own — the rightmost label is the one that actually counts.',
+ 'Let your password manager be the judge. If it doesn’t autofill on a login page, that page isn’t the one you think it is — don’t override it, close the tab.',
+ 'Treat any breach reference on a landing page as a claim, not a fact. Check the company’s own status page or Have I Been Pwned before you sign in anywhere else.',
+ ],
+ },
+};
+
+export default content;
diff --git a/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts b/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts
new file mode 100644
index 00000000..a4c70015
--- /dev/null
+++ b/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts
@@ -0,0 +1,47 @@
+// No quoted figures by policy. Surface here is a chat DM (LinkedIn /
+// Slack-style), not email — multi-touch grooming is more legible as a
+// thread where the second and third messages feel like a relationship
+// you already have.
+
+import type { OffsecBiasContent } from './types';
+
+const content: OffsecBiasContent = {
+ scenario:
+ 'A new contact spent two weeks softly introducing themselves over LinkedIn — small notes, no asks. By week three, when they finally request a wire change, the name in your DMs already feels familiar enough to trust.',
+ visualLabel: 'Scenario',
+ visual: {
+ before: {
+ kind: 'chat',
+ tag: 'Cold ask',
+ senderName: 'Klaus Lange',
+ senderHandle: 'Acme Supplier · finance',
+ timestamp: 'Thu, 9:30 AM',
+ body: 'Hello — I’m Klaus from Acme Supplier finance. We’ve changed our account details, please update before the next payment run.',
+ },
+ after: {
+ kind: 'chat',
+ tag: 'Third touch',
+ senderName: 'Klaus Lange',
+ senderHandle: 'Acme Supplier · finance',
+ timestamp: 'Thu, 9:30 AM',
+ priorContext: '2 messages this month — last seen yesterday',
+ body: 'Hi again — as mentioned last week, our account moved. Sending the final details now so payment lands on the new IBAN. Appreciate the quick turnaround 🙌',
+ flagged: true,
+ },
+ },
+ whyItWorksLabel: 'Why it works',
+ whyItWorks:
+ 'Illusory truth effect — the brain treats fluency as evidence. The first time you saw this person’s name, it felt new and needed scrutiny. By the third touch, processing is cheap; cheap feels familiar; familiar feels true. The two prior messages carried no ask at all — that’s the point. They were a deposit into your credibility account. The third withdraws.',
+ defenseLabel: 'Protect yourself',
+ defense: {
+ lede: 'While your security team handles the perimeter — here’s your homework.',
+ moves: [
+ 'Thread length is not verification. Two friendly notes followed by a money ask is a pattern, not a coincidence — the prior messages were the setup.',
+ 'Any time a sender first asks for money, credentials, or bank details, treat them as new — no matter how familiar the chat history makes them feel. Verify out of band, every time, even on the fifth message.',
+ 'Watch for relationship-builders that never ask for anything. Cheerful check-ins from someone you have never met outside this app should raise the question — what is this conversation actually for?',
+ 'Cross-check the contact against records you keep elsewhere — CRM, signed contracts, a colleague who knows them. If they exist only inside this DM thread, the familiarity is staged.',
+ ],
+ },
+};
+
+export default content;
diff --git a/src/uxcore/data/biasOffsec/index.ts b/src/uxcore/data/biasOffsec/index.ts
new file mode 100644
index 00000000..f408f344
--- /dev/null
+++ b/src/uxcore/data/biasOffsec/index.ts
@@ -0,0 +1,21 @@
+import { biases } from '../biasList/biases';
+import attentionalBias from './attentionalBias';
+import availabilityHeuristics from './availabilityHeuristics';
+import illusoryTruthEffect from './illusoryTruthEffect';
+import type { OffsecBiasCard, OffsecBiasContent } from './types';
+
+const offsecBySlug: Record = {
+ 'availability-heuristics': availabilityHeuristics,
+ 'attentional-bias': attentionalBias,
+ 'illusory-truth-effect': illusoryTruthEffect,
+};
+
+export const getOffsecBiasContent = (
+ biasNumber: number,
+): OffsecBiasContent | null => {
+ const entry = biases.find(b => b.id === biasNumber);
+ if (!entry) return null;
+ return offsecBySlug[entry.slug] ?? null;
+};
+
+export type { OffsecBiasCard, OffsecBiasContent };
diff --git a/src/uxcore/data/biasOffsec/types.ts b/src/uxcore/data/biasOffsec/types.ts
new file mode 100644
index 00000000..7c7c9cba
--- /dev/null
+++ b/src/uxcore/data/biasOffsec/types.ts
@@ -0,0 +1,77 @@
+// Each bias example renders two side-by-side cards: a baseline ("before")
+// and the bias-exploiting variant ("after", marked `flagged`). The card
+// surface is picked per-bias so the OffSec section never feels like
+// "another email". When email is the natural attack surface, use it;
+// otherwise pick the surface that matches the threat (push notification,
+// chat thread, browser alert, etc.). Add new kinds here as new biases
+// arrive.
+
+interface OffsecBiasCardCommon {
+ tag: string;
+ flagged?: boolean;
+}
+
+export interface OffsecBiasEmailCard extends OffsecBiasCardCommon {
+ kind: 'email';
+ sender: string;
+ timestamp?: string;
+ subject: string;
+ preview: string;
+ attachment?: string;
+}
+
+export interface OffsecBiasNotificationCard extends OffsecBiasCardCommon {
+ kind: 'notification';
+ appName: string;
+ timestamp?: string;
+ title: string;
+ body: string;
+}
+
+export interface OffsecBiasChatCard extends OffsecBiasCardCommon {
+ kind: 'chat';
+ senderName: string;
+ senderHandle?: string;
+ timestamp?: string;
+ // Soft pre-bubble note that grounds the reader in the prior history
+ // for biases where context-building matters (e.g., illusory truth).
+ priorContext?: string;
+ body: string;
+}
+
+// Faux browser tab — used for biases where the attack surface is a web
+// page (lookalike domain, sponsored result, fake breach-checker landing).
+// The `host` field is split out so we can highlight the deceptive part
+// (e.g., the second-level domain) without forcing the data file to ship
+// inline markup.
+export interface OffsecBiasBrowserCard extends OffsecBiasCardCommon {
+ kind: 'browser';
+ protocol?: 'https' | 'http';
+ host: string;
+ path?: string;
+ pageHeading: string;
+ pageBody: string;
+ cta?: string;
+}
+
+export type OffsecBiasCard =
+ | OffsecBiasEmailCard
+ | OffsecBiasNotificationCard
+ | OffsecBiasChatCard
+ | OffsecBiasBrowserCard;
+
+export interface OffsecBiasContent {
+ scenario: string;
+ visualLabel: string;
+ visual: {
+ before: OffsecBiasCard;
+ after: OffsecBiasCard;
+ };
+ whyItWorksLabel: string;
+ whyItWorks: string;
+ defenseLabel: string;
+ defense: {
+ lede: string;
+ moves: string[];
+ };
+}
diff --git a/src/uxcore/data/biases/en.ts b/src/uxcore/data/biases/en.ts
index 6c7603d9..3d6799ca 100644
--- a/src/uxcore/data/biases/en.ts
+++ b/src/uxcore/data/biases/en.ts
@@ -7,6 +7,7 @@ const en = {
mainTitle: 'Bias environment',
browsingAsProduct: 'You are viewing Product Management use cases',
browsingAsHR: 'You are viewing People Management use cases',
+ browsingAsOffsec: 'You are viewing offensive security use cases',
sectionTitles: [
{ color: 'purple', title: 'What should we remember?' },
{ color: 'pink', title: 'Need to act fast' },
diff --git a/src/uxcore/data/biases/hy.ts b/src/uxcore/data/biases/hy.ts
index 1fbdbf6c..08383cb6 100644
--- a/src/uxcore/data/biases/hy.ts
+++ b/src/uxcore/data/biases/hy.ts
@@ -7,6 +7,7 @@ const hy = {
moto: 'Be Kind. Do Good.',
browsingAsProduct: 'Դուք դիտում եք կիրառությունները Պրոդուկտում',
browsingAsHR: 'Դուք դիտում եք կիրառությունները ՄՌԿ-ում (HR)',
+ browsingAsOffsec: 'You are viewing offensive security use cases',
sectionTitles: [
{ color: 'purple', title: 'Ի՞նչ պետք է հիշել' },
{ color: 'pink', title: 'Պետք է արագ գործել' },
diff --git a/src/uxcore/data/biases/ru.ts b/src/uxcore/data/biases/ru.ts
index 73b99e9f..cca4b8ae 100644
--- a/src/uxcore/data/biases/ru.ts
+++ b/src/uxcore/data/biases/ru.ts
@@ -7,6 +7,8 @@ const ru = {
mainTitle: 'Среда проявления искажений',
browsingAsProduct: 'Вы смотрите примеры в категории "Разработка продуктов"',
browsingAsHR: 'Вы смотрите примеры в категории Управление персоналом',
+ browsingAsOffsec:
+ 'Вы смотрите примеры в категории наступательной безопасности',
sectionTitles: [
{ color: 'purple', title: 'Когда запоминаем и вспоминаем' },
{ color: 'pink', title: 'Когда быстро реагируем' },
diff --git a/src/uxcore/data/modal/en.ts b/src/uxcore/data/modal/en.ts
index 7e22dea9..445cdb67 100644
--- a/src/uxcore/data/modal/en.ts
+++ b/src/uxcore/data/modal/en.ts
@@ -5,7 +5,7 @@ const en = {
description: 'Description',
hrText: 'People Management',
productText: 'Product Management',
- usage: 'Example of use by team',
+ usage: 'Examples of use',
mentionedIn: 'This bias answers to the following questions',
productValue: 'Product value',
usageUiUx: 'Example of use by UI/UX',
@@ -16,5 +16,10 @@ const en = {
uxeducationButtonLabel: 'Using UXCG in Education',
downloadButtonLabel: 'Download PDF',
visualExample: 'Visual Example',
+ offsecText: 'Offensive Cybersecurity',
+ offsecShortText: 'OffSec',
+ usageOffsec: 'Example of use by Offensive Cybersecurity',
+ offsecComingSoon:
+ 'Offensive Cybersecurity use cases — coming soon. We are curating attacker-side and defender-side scenarios for every bias in UX Core.',
};
export default en;
diff --git a/src/uxcore/data/modal/hy.ts b/src/uxcore/data/modal/hy.ts
index 35d0a1e8..b386174d 100644
--- a/src/uxcore/data/modal/hy.ts
+++ b/src/uxcore/data/modal/hy.ts
@@ -5,7 +5,7 @@ const hy = {
description: 'Նկարագրություն',
hrText: 'ՄՌԿ (HR)',
productText: 'Պրոդուկտ',
- usage: 'Թիմում կիրառության օրինակ',
+ usage: 'Կիրառության օրինակներ',
mentionedIn: 'Այս հակումը պատասխանում է հետևյալ հարցերին',
productValue: 'Product value',
usageUiUx: 'Example of use by UI/UX',
@@ -16,5 +16,10 @@ const hy = {
uxeducationButtonLabel: 'Using UXCG in Education',
downloadButtonLabel: 'Ներբեռնել PDF', //TODO Add to sheet
visualExample: 'Տեսողական օրինակ',
+ offsecText: 'Offensive Cybersecurity',
+ offsecShortText: 'OffSec',
+ usageOffsec: 'Example of use by Offensive Cybersecurity',
+ offsecComingSoon:
+ 'Offensive Cybersecurity use cases — coming soon. We are curating attacker-side and defender-side scenarios for every bias in UX Core.',
};
export default hy;
diff --git a/src/uxcore/data/modal/ru.ts b/src/uxcore/data/modal/ru.ts
index d43dbadb..7f8e448e 100644
--- a/src/uxcore/data/modal/ru.ts
+++ b/src/uxcore/data/modal/ru.ts
@@ -3,7 +3,7 @@ const ru = {
copied: 'Скопировано!',
share: 'Поделиться',
description: 'Описание',
- usage: ' Использование в командах',
+ usage: 'Примеры использования',
usageHr: ' Использование в командах ',
usageUiUx: 'Пример использования UI/UX',
productText: 'Продукт Менеджмент',
@@ -16,6 +16,11 @@ const ru = {
uxeducationButtonLabel: 'Использование UXCG в образовании',
downloadButtonLabel: 'Скачать PDF',
visualExample: 'Визуальный пример',
+ offsecText: 'Наступательная кибербезопасность',
+ offsecShortText: 'OffSec',
+ usageOffsec: 'Пример использования в наступательной кибербезопасности',
+ offsecComingSoon:
+ 'Сценарии для наступательной кибербезопасности — скоро. Мы готовим примеры для атакующей и защитной стороны для каждого искажения в UX Core.',
};
export default ru;
diff --git a/src/uxcore/hooks/useUXCoreGlobals.ts b/src/uxcore/hooks/useUXCoreGlobals.ts
index a18a5f76..528bf75c 100644
--- a/src/uxcore/hooks/useUXCoreGlobals.ts
+++ b/src/uxcore/hooks/useUXCoreGlobals.ts
@@ -1,10 +1,14 @@
-import { useEffect, useState } from 'react';
-
import { CustomHookType, DispatchFuntion } from '@uxcore/local-types/global';
+import { useEffect, useState } from 'react';
interface TState {
isCoreView: boolean;
isProductView?: boolean;
+ isOffsecView?: boolean;
+ // Remembers the most recent PM/HR selection so clicking the active
+ // OffSec row can revert to where the user was before they detoured
+ // into Cybersecurity. Never holds 'offsec'.
+ lastBaseUseCase?: 'product' | 'hr';
showArrows?: boolean;
}
@@ -12,6 +16,8 @@ let listeners: DispatchFuntion[] = [];
let state: TState = {
isCoreView: true,
isProductView: true,
+ isOffsecView: false,
+ lastBaseUseCase: 'product',
showArrows: true,
};
@@ -35,7 +41,40 @@ const toggleIsCoreView = () => {
};
const toggleIsProductView = () => {
localStorage.setItem('isProductView', String(!state.isProductView));
- reducer({ isProductView: !state.isProductView });
+ // Switching to a PM/HR view always exits OffSec — the three use cases
+ // are mutually exclusive.
+ if (state.isOffsecView) {
+ localStorage.setItem('isOffsecView', 'false');
+ reducer({ isProductView: !state.isProductView, isOffsecView: false });
+ } else {
+ reducer({ isProductView: !state.isProductView });
+ }
+};
+const toggleIsOffsecView = () => {
+ localStorage.setItem('isOffsecView', String(!state.isOffsecView));
+ reducer({ isOffsecView: !state.isOffsecView });
+};
+
+// Explicit setter used by the vertical Use cases panel — three mutually
+// exclusive targets. Clicking the already-active OffSec row reverts to
+// the last PM/HR state (lastBaseUseCase) so the user can declick
+// Cybersecurity and return to the canonical pair.
+const setUseCase = (target: 'product' | 'hr' | 'offsec') => {
+ let resolved: 'product' | 'hr' | 'offsec' = target;
+ if (target === 'offsec' && state.isOffsecView) {
+ resolved = state.lastBaseUseCase || 'hr';
+ }
+ const next: Partial = {
+ isProductView: resolved === 'product',
+ isOffsecView: resolved === 'offsec',
+ };
+ if (resolved === 'product' || resolved === 'hr') {
+ next.lastBaseUseCase = resolved;
+ localStorage.setItem('lastBaseUseCase', resolved);
+ }
+ localStorage.setItem('isProductView', String(next.isProductView));
+ localStorage.setItem('isOffsecView', String(next.isOffsecView));
+ reducer(next);
};
const toggleShowArrows = () => {
localStorage.setItem('showArrows', String(!state.showArrows));
@@ -47,14 +86,22 @@ const initUseUXCoreGlobals = () => {
const changeState = (localStorage.getItem('isCoreView') || true) === 'false';
const changeStateView =
(localStorage.getItem('isProductView') || true) === 'false';
+ const changeStateOffsec = localStorage.getItem('isOffsecView') === 'true';
const changeStateArrows =
(localStorage.getItem('showArrows') || true) === 'false';
+ const storedBase = localStorage.getItem('lastBaseUseCase');
+ if (storedBase === 'product' || storedBase === 'hr') {
+ reducer({ lastBaseUseCase: storedBase });
+ }
if (changeState) {
toggleIsCoreView();
}
if (changeStateView) {
toggleIsProductView();
}
+ if (changeStateOffsec) {
+ toggleIsOffsecView();
+ }
if (changeStateArrows) {
toggleShowArrows();
}
@@ -77,6 +124,8 @@ const useUXCoreGlobals = (): CustomHookType => {
initUseUXCoreGlobals,
toggleIsCoreView,
toggleIsProductView,
+ toggleIsOffsecView,
+ setUseCase,
toggleShowArrows,
},
state,
diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss
index dc5f0d3f..89e31951 100644
--- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss
+++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss
@@ -29,11 +29,122 @@
z-index: 3;
}
-.viewTeamSwitcher {
+// Vertical Use cases panel — three stacked rows (PM / HR / Cybersecurity).
+// Sits below the View type switcher on the right. Width matches the
+// View type pair (.wide variant of ViewSwitcher: 99 + 99 = 198px) so
+// the two right-hand controls line up flush.
+.useCasesPanel {
position: absolute;
top: 150px;
right: 20px;
z-index: 3;
+ display: flex;
+ flex-direction: column;
+
+ .useCasesLabel {
+ color: #333333;
+ font-size: 14px;
+ margin: 0 0 8px;
+ }
+
+ .useCaseRow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ box-sizing: border-box;
+ width: 198px;
+ height: 38px;
+ padding: 0 14px;
+ background: #f4f4f4;
+ border: 1px solid #c4c4c4;
+ border-bottom-width: 0;
+ color: #515151;
+ font-size: 14px;
+ cursor: pointer;
+ transition:
+ background 160ms ease,
+ color 160ms ease;
+
+ &:first-of-type {
+ border-top-left-radius: 6px;
+ border-top-right-radius: 6px;
+ }
+
+ &:last-of-type {
+ border-bottom-width: 1px;
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+ }
+
+ &.active {
+ background: #ffffff;
+ border-color: #000000d9;
+ color: #000000d9;
+
+ // The neighbouring row's top border should sit above this active
+ // row's dark border so the dark border wraps fully around it.
+ & + .useCaseRow {
+ border-top-color: #000000d9;
+ }
+ }
+
+ .cybersecShort {
+ display: none;
+ }
+ }
+}
+
+// Dark mode — match the dark page background and lift the panel out of
+// it with a faintly lighter tone for inactive rows, a brighter neutral
+// for the active one.
+:global(body.darkTheme) .useCasesPanel {
+ .useCasesLabel {
+ color: #d8d8d8;
+ }
+
+ .useCaseRow {
+ background: #2a2f38;
+ border-color: #3c424d;
+ color: #c2c7cf;
+
+ &.active {
+ background: #1b1e26;
+ border-color: #f4f4f4;
+ color: #f4f4f4;
+
+ & + .useCaseRow {
+ border-top-color: #f4f4f4;
+ }
+ }
+ }
+}
+
+// On narrower viewports the View type ViewSwitcher restacks into a
+// column (max-width:1360px), pushing its bottom edge well past the
+// original top:150px slot for Use cases. Drop the Use cases panel down
+// so the "Use cases" label clears the second View type icon.
+@media (max-width: 1360px) {
+ .useCasesPanel {
+ top: 220px;
+ }
+}
+
+// On narrower laptop viewports swap "Cybersecurity" for the short
+// "OffSec" so the third row's label still fits comfortably.
+@media (max-width: 1280px) {
+ .useCasesPanel .useCaseRow {
+ width: 168px;
+
+ .cybersecFull {
+ display: none;
+ }
+ .cybersecShort {
+ display: inline;
+ }
+ }
}
.Logos {
@@ -130,10 +241,6 @@
}
@media (max-width: 1359px) {
- .viewTeamSwitcher {
- top: 175px;
- align-items: flex-end;
- }
.SvgWrapper {
.BiasEnvironment {
left: 0;
diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx
index c870ef0d..0dff2dc2 100644
--- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx
+++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx
@@ -1,35 +1,34 @@
-import cn from 'classnames';
-import dynamic from 'next/dynamic';
-import { useRouter } from 'next/router';
-import React, { FC, useEffect, useState } from 'react';
-
-import type { TRouter } from '@uxcore/local-types/global';
-
-import useUXCoreGlobals from '@uxcore/hooks/useUXCoreGlobals';
-import useUCoreMobile from '@uxcore/hooks/uxcoreMobile';
-
-import biasesLocalization from '@uxcore/data/biases';
-import biasesMobile from '@uxcore/data/biasesMobile';
-
import CoreIcon from '@uxcore/assets/icons/CoreIcon';
import FolderIcon from '@uxcore/assets/icons/FolderIcon';
import { HRIconBlue } from '@uxcore/assets/icons/HRIconBlue';
import { HRIconGrey } from '@uxcore/assets/icons/HRIconGrey';
+import { OffSecIcon, OffSecIconGrey } from '@uxcore/assets/icons/OffSecIcon';
import { PMIcon } from '@uxcore/assets/icons/PMIcon';
import { PMIconGrey } from '@uxcore/assets/icons/PMIconGrey';
-
import Search from '@uxcore/components/_biases/Search';
import Logos from '@uxcore/components/Logos';
import Spinner from '@uxcore/components/Spinner';
import ToolFooter from '@uxcore/components/ToolFooter';
+import biasesLocalization from '@uxcore/data/biases';
+import biasesMobile from '@uxcore/data/biasesMobile';
+import useUXCoreGlobals from '@uxcore/hooks/useUXCoreGlobals';
+import useUCoreMobile from '@uxcore/hooks/uxcoreMobile';
+import type { TRouter } from '@uxcore/local-types/global';
+import cn from 'classnames';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+import React, { FC, useEffect, useState } from 'react';
import type { UXCoreLayoutProps } from './UXCoreLayout.types';
import styles from './UXCoreLayout.module.scss';
-const FolderViewLayout = dynamic(() => import('@uxcore/layouts/FolderViewLayout'), {
- ssr: false,
-});
+const FolderViewLayout = dynamic(
+ () => import('@uxcore/layouts/FolderViewLayout'),
+ {
+ ssr: false,
+ },
+);
const CoreViewLayout = dynamic(() => import('@uxcore/layouts/CoreViewLayout'), {
ssr: false,
});
@@ -38,16 +37,25 @@ const UXCorePopup = dynamic(() => import('@uxcore/components/UXCorePopup'), {
ssr: false,
});
-const UXCoreSnackbar = dynamic(() => import('@uxcore/components/UXCoreSnackbar'), {
- ssr: false,
-});
+const UXCoreSnackbar = dynamic(
+ () => import('@uxcore/components/UXCoreSnackbar'),
+ {
+ ssr: false,
+ },
+);
-const ViewSwitcher = dynamic(() => import('@uxcore/components/_biases/ViewSwitcher'), {
- ssr: false,
-});
-const MobileView = dynamic(() => import('@uxcore/components/_biases/MobileView'), {
- ssr: false,
-});
+const ViewSwitcher = dynamic(
+ () => import('@uxcore/components/_biases/ViewSwitcher'),
+ {
+ ssr: false,
+ },
+);
+const MobileView = dynamic(
+ () => import('@uxcore/components/_biases/MobileView'),
+ {
+ ssr: false,
+ },
+);
const UXCoreLayout: FC = ({
strapiBiases,
@@ -62,6 +70,10 @@ const UXCoreLayout: FC = ({
}) => {
const [{ toggleIsCoreView }, { isCoreView }] = useUXCoreGlobals();
const [{ toggleIsProductView }, { isProductView }] = useUXCoreGlobals();
+ const [
+ { toggleIsOffsecView, setUseCase },
+ { isOffsecView, lastBaseUseCase },
+ ] = useUXCoreGlobals();
const router = useRouter();
const { asPath } = router as TRouter;
const { isUxcoreMobile } = useUCoreMobile()[1];
@@ -72,17 +84,20 @@ const UXCoreLayout: FC = ({
const [headerPodcastOpen, setHeaderPodcastOpen] = useState(false);
const { locale } = router as TRouter;
const data = biasesLocalization[locale];
- const { browsingAsProduct, browsingAsHR } = data;
+ const { browsingAsProduct, browsingAsHR, browsingAsOffsec } = data;
const { description } = biasesMobile[locale];
useEffect(() => {
if (!mounted) return;
- const hasHr = window.location.hash === '#hr';
+ const hash = window.location.hash;
- if (hasHr && isProductView) {
+ if (hash === '#hr' && isProductView) {
toggleIsProductView();
}
+ if (hash === '#offsec' && !isOffsecView) {
+ toggleIsOffsecView();
+ }
}, [mounted]);
useEffect(() => {
@@ -96,7 +111,7 @@ const UXCoreLayout: FC = ({
const basePath = `${localePrefix}/uxcore`;
- const shouldBeHash = isProductView ? '' : '#hr';
+ const shouldBeHash = isOffsecView ? '#offsec' : isProductView ? '' : '#hr';
const targetUrl = `${basePath}${shouldBeHash}`;
@@ -105,17 +120,37 @@ const UXCoreLayout: FC = ({
if (currentUrl === targetUrl) return;
window.history.replaceState(null, '', targetUrl);
- }, [mounted, isProductView, router.locale]);
+ }, [mounted, isProductView, isOffsecView, router.locale]);
useEffect(() => {
if (isSwitched !== undefined) {
- if (isProductView) {
+ if (isOffsecView) {
+ setSnackBarText(browsingAsOffsec);
+ } else if (isProductView) {
setSnackBarText(browsingAsProduct);
} else {
setSnackBarText(browsingAsHR);
}
}
- }, [isSwitched, isProductView, locale]);
+ }, [isSwitched, isProductView, isOffsecView, locale]);
+
+ // One click handler for the three vertical Use cases rows. Sets state
+ // explicitly via setUseCase so PM/HR/OffSec are mutually exclusive
+ // without depending on the toggle semantics of the older actions.
+ // Clicking the active OffSec row reverts to lastBaseUseCase — mirror
+ // that resolution here so the snackbar pre-set lands on the correct
+ // label and the first frame doesn't flash the wrong text.
+ const handleUseCaseClick = (target: 'product' | 'hr' | 'offsec') => {
+ const resolved =
+ target === 'offsec' && isOffsecView ? lastBaseUseCase || 'hr' : target;
+ if (resolved === 'product') setSnackBarText(browsingAsProduct);
+ else if (resolved === 'hr') setSnackBarText(browsingAsHR);
+ else setSnackBarText(browsingAsOffsec);
+
+ setUseCase(target);
+ setIsSwitched(prev => !prev);
+ handleSnackbarOpening();
+ };
let snackbarTimeout: NodeJS.Timeout;
const handleSnackbarOpening = () => {
@@ -149,24 +184,48 @@ const UXCoreLayout: FC = ({
secondViewIcon={}
className={styles.viewTypeSwitcher}
labelViewType
+ wide
dataCy={'core-view-switcher'}
dataCySecondView={'folder-view-switcher'}
/>
- : }
- secondViewIcon={isProductView ? : }
- secondViewLabel={'hr'}
- secondText={'HR'}
- className={styles.viewTeamSwitcher}
- setIsSwitched={setIsSwitched}
- isSwitched={isSwitched}
- handleSnackbarOpening={handleSnackbarOpening}
- dataCy={'switch-product'}
- dataCySecondView={'switch-hr'}
- />
+
{isCoreView && }
{isCoreView && (
<>
diff --git a/src/uxcore/styles/globals.scss b/src/uxcore/styles/globals.scss
index c6fd90a2..7a0330fb 100644
--- a/src/uxcore/styles/globals.scss
+++ b/src/uxcore/styles/globals.scss
@@ -15,7 +15,6 @@ html {
/* width */
&::-webkit-scrollbar {
width: 8px;
- border-left: 1px solid #fafafa;
}
/* Track */
@@ -26,7 +25,6 @@ html {
/* Handle */
&::-webkit-scrollbar-thumb {
border-radius: 5px;
- border-left: 1px solid #fafafa;
}
}
diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx
index 23ceb0c4..d996d585 100644
--- a/widget/src/AskUxCore.tsx
+++ b/widget/src/AskUxCore.tsx
@@ -1766,7 +1766,10 @@ const applyHostHighlight = (
export function AskUxCore({ lang }: { lang: Lang }) {
const initial = typeof window !== 'undefined' ? loadState() : null;
- const [open, setOpen] = useState(initial?.open ?? false);
+ // 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);
const [text, setText] = useState('');
const [turns, setTurns] = useState(initial?.turns ?? []);
const [loading, setLoading] = useState(false);
@@ -2742,8 +2745,17 @@ export function AskUxCore({ lang }: { lang: Lang }) {
/* Articles-page experiment: when fresh cards land, flash the
matching tiles on the host page so the visitor sees "here, look
- at these" in context, not just in the widget. */
- const lastFlashedTurnIdRef = useRef(null);
+ at these" in context, not just in the widget.
+ Seed the ref with the last RESTORED turn id so the flash effect
+ only ever fires for turns the visitor produced in *this* session
+ — never for stale turns rehydrated from localStorage. Without this
+ seed, a returning visitor sees host elements light up on page load
+ with no obvious cause (the panel is closed). */
+ const lastFlashedTurnIdRef = useRef(
+ initial?.turns && initial.turns.length > 0
+ ? initial.turns[initial.turns.length - 1].id
+ : null,
+ );
useEffect(() => {
if (!isHighlightEnabledPage()) return;
const last = turns[turns.length - 1];
diff --git a/widget/src/styles.css b/widget/src/styles.css
index 91b58fa5..c36626e0 100644
--- a/widget/src/styles.css
+++ b/widget/src/styles.css
@@ -1251,6 +1251,17 @@
background: #2c2926;
}
+/* === Laptop fit (481px – 1280px) ===
+ On smaller laptops a 480px panel chews into the host page's navbar
+ and other right-aligned chrome. Narrow it without flipping into the
+ mobile near-full-screen treatment. */
+@media (min-width: 481px) and (max-width: 1280px) {
+ .ks-aux-panel {
+ width: 380px;
+ height: min(620px, calc(100dvh - 120px));
+ }
+}
+
/* === Mobile usability (≤480px) ===
Phone screens can't host a fixed 480×660 panel comfortably. Panel
becomes near-full-screen, pill anchors to the bottom-right with a