From 024e932f9419970fb25c2472187680bce56570a8 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 21:10:48 +0000 Subject: [PATCH 01/51] docs(claude): add voice for user-facing copy + UX Core canonical guard Codifies the copy voice reference ("Rise of the Choice Architect") and the "never fabricate UX Core bias data" rule into the agent-facing CLAUDE.md so future agents land on it before writing user-facing copy or content. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08a54a1..2abec2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,26 @@ This repo is indexed by **CodeGraph** (MCP server `codegraph`, registered global - `codegraph_impact` — blast radius before a rename or refactor - `codegraph_files` — what's in a directory + per-file symbol counts -Use **Grep / Glob only when** the query is a *concept* with no symbol name ("where do we handle the Cohere fallback?"), or when a CodeGraph query returned nothing. Index lags writes ~500ms; if you just edited a file, give it a turn before re-querying. +Use **Grep / Glob only when** the query is a _concept_ with no symbol name ("where do we handle the Cohere fallback?"), or when a CodeGraph query returned nothing. Index lags writes ~500ms; if you just edited a file, give it a turn before re-querying. + +## Voice for user-facing copy + +When writing copy that ships to users (microcopy, page headings, marketing blurbs, articles, error messages): + +- First-person, direct, no filler. +- Em-dashes and semicolons over staccato fragments — let sentences breathe; reserve short fragments for deliberate punctuation, never as default rhythm. +- Cross-disciplinary framing welcome when it actually fits (behavioral science × product × longevity × AI). +- Sparse profanity is fine when it lands; default to clean. +- No AI-isms — no "let me know if…", no "happy to help", no preamble before the answer. +- Reference piece: **"The Rise of the Choice Architect"** (article on keepsimple.io). Match its register. + +## UX Core data is canonical + +The 100+ cognitive biases in UX Core are the product of 5+ years of curation and are referenced by Duke, Harvard, MIT, Google, Yandex, Amazon, and others. + +- Never fabricate bias names, slugs, citation indices, or source URLs. +- If you need structured bias data, pull from `/uxcore-api` (see AGENTS.md → Public data API). Don't scrape, don't paraphrase from memory. +- Schema changes to UX Core data require explicit approval. ## Everything else From 7e745f47e4ec38a4604d0f05009453721c0b0963 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 21:17:29 +0000 Subject: [PATCH 02/51] =?UTF-8?q?fix(widget):=20narrow=20panel=20on=20lapt?= =?UTF-8?q?op=20viewports=20(481=E2=80=931280px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ask widget panel was a fixed 480×660 between the mobile breakpoint (≤480px) and infinity, so on 13–14" laptops it overlapped the host navbar and felt too heavy. Adds an intermediate breakpoint that drops the panel to 380px wide and caps height to fit shorter viewports. Co-Authored-By: Claude Opus 4.7 --- widget/src/styles.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/widget/src/styles.css b/widget/src/styles.css index 91b58fa..c36626e 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 From b86f224517dd10fde859bde1361d41619ba3099b Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 21:29:27 +0000 Subject: [PATCH 03/51] feat(uxcore): add Offensive Cybersecurity use-case switch (scaffold) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OffSec as a third UX Core use-case alongside Product Management and HR. Mutually exclusive with PM/HR — switching to PM/HR exits OffSec and vice versa. - New isOffsecView state in useUXCoreGlobals with localStorage persistence - "Offensive Cybersecurity" button on the UX Core landing below the PM/HR switcher, with a placeholder shield icon (TODO: swap for the hexens.io logo SVG when the asset lands) - Bias modal: matching OffSec toggle row below the PM/HR switcher and a "coming soon" panel for the use-case section while per-bias OffSec scenarios are being authored - URL hash extended with #offsec - Localized OffSec strings for en / ru / hy Mobile (MobileView, UXCoreModalMobile) keeps current 2-state behaviour; mirroring there is a follow-up step. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/assets/icons/OffSecIcon.tsx | 46 +++++++++ .../UXCoreModal/UXCoreModal.module.scss | 49 ++++++++++ .../components/UXCoreModal/UXCoreModal.tsx | 62 +++++++----- src/uxcore/data/modal/en.ts | 5 + src/uxcore/data/modal/hy.ts | 5 + src/uxcore/data/modal/ru.ts | 5 + src/uxcore/hooks/useUXCoreGlobals.ts | 23 ++++- .../UXCoreLayout/UXCoreLayout.module.scss | 54 +++++++++++ .../layouts/UXCoreLayout/UXCoreLayout.tsx | 95 ++++++++++++------- 9 files changed, 287 insertions(+), 57 deletions(-) create mode 100644 src/uxcore/assets/icons/OffSecIcon.tsx diff --git a/src/uxcore/assets/icons/OffSecIcon.tsx b/src/uxcore/assets/icons/OffSecIcon.tsx new file mode 100644 index 0000000..addba09 --- /dev/null +++ b/src/uxcore/assets/icons/OffSecIcon.tsx @@ -0,0 +1,46 @@ +// TODO(hexens-logo): replace this placeholder shield with the official +// hexens.io company logo SVG. Wolf to drop the asset; this component +// just needs its body swapped. +export const OffSecIcon = () => ( + + + + + +); + +export const OffSecIconGrey = () => ( + + + + + +); diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index 51229d3..dcbd8ef 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -162,6 +162,55 @@ fill: #1e56a0; } } + + &.dimmed { + opacity: 0.5; + } + } + + .offsecSwitcher { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + margin-bottom: 12px; + padding: 0 14px; + background-color: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + color: #000000a6; + font-size: 14px; + cursor: pointer; + opacity: 0.7; + transition: + opacity 200ms ease, + color 200ms ease; + + &:hover { + opacity: 1; + } + + &.active { + background-color: #fdecea; + border-color: #c8412a; + color: #c8412a; + opacity: 1; + + svg { + fill: #c8412a; + } + } + } + + .offsecComingSoon { + padding: 16px 18px; + border: 1px dashed #c8412a; + border-radius: 8px; + background-color: #fff6f4; + color: #5a1f12; + font-size: 14px; + line-height: 1.5; } } } diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index 8dbb6f5..7a0b652 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -1,3 +1,17 @@ +import HrIcon from '@uxcore/assets/icons/HrIcon'; +import { OffSecIcon, OffSecIconGrey } from '@uxcore/assets/icons/OffSecIcon'; +import ProductIcon from '@uxcore/assets/icons/ProductIcon'; +import BiasBody from '@uxcore/components/_biases/BiasBody'; +import ContentParser from '@uxcore/components/ContentParser'; +import ModalRaiting from '@uxcore/components/ModalRaiting'; +import Spinner from '@uxcore/components/Spinner'; +import Table from '@uxcore/components/Table'; +import UXCoreModalHeader from '@uxcore/components/UXCoreModalParts/UXCoreModalHeader'; +import modalIntl from '@uxcore/data/modal'; +import useUXCoreGlobals from '@uxcore/hooks/useUXCoreGlobals'; +import { copyToClipboard, generateSocialLinks } from '@uxcore/lib/helpers'; +import type { QuestionType, TagType } from '@uxcore/local-types/data'; +import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; import { useRouter } from 'next/router'; import { @@ -9,23 +23,6 @@ import { useState, } from 'react'; -import type { QuestionType, TagType } from '@uxcore/local-types/data'; -import type { TRouter } from '@uxcore/local-types/global'; - -import { copyToClipboard, generateSocialLinks } from '@uxcore/lib/helpers'; - -import modalIntl from '@uxcore/data/modal'; - -import HrIcon from '@uxcore/assets/icons/HrIcon'; -import ProductIcon from '@uxcore/assets/icons/ProductIcon'; - -import BiasBody from '@uxcore/components/_biases/BiasBody'; -import ContentParser from '@uxcore/components/ContentParser'; -import ModalRaiting from '@uxcore/components/ModalRaiting'; -import Spinner from '@uxcore/components/Spinner'; -import Table from '@uxcore/components/Table'; -import UXCoreModalHeader from '@uxcore/components/UXCoreModalParts/UXCoreModalHeader'; - import styles from './UXCoreModal.module.scss'; type UXCoreModalProps = { @@ -66,6 +63,7 @@ const UXCoreModal: FC = ({ slugs, }) => { const router = useRouter(); + const [{ toggleIsOffsecView }, { isOffsecView }] = useUXCoreGlobals(); const [isCopyTooltipVisible, setIsCopyTooltipVisible] = useState(false); const [isQuestionHovered, setIsQuestionHovered] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -168,6 +166,8 @@ const UXCoreModal: FC = ({ managementValue, productText, hrText, + offsecShortText, + offsecComingSoon, } = modalIntl[locale]; const { linkedIn, facebook, tweeter } = generateSocialLinks( @@ -216,7 +216,11 @@ const UXCoreModal: FC = ({ {usage}
-
+
= ({ {hrText}
- +
+ {isOffsecView ? : } + {offsecShortText} +
+ {isOffsecView ? ( +
{offsecComingSoon}
+ ) : ( + + )}
{data.title && } {questions.length > 0 && ( diff --git a/src/uxcore/data/modal/en.ts b/src/uxcore/data/modal/en.ts index 7e22dea..18749e4 100644 --- a/src/uxcore/data/modal/en.ts +++ b/src/uxcore/data/modal/en.ts @@ -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 35d0a1e..30bee9c 100644 --- a/src/uxcore/data/modal/hy.ts +++ b/src/uxcore/data/modal/hy.ts @@ -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 d43dbad..21f8665 100644 --- a/src/uxcore/data/modal/ru.ts +++ b/src/uxcore/data/modal/ru.ts @@ -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 a18a5f7..12f6f7e 100644 --- a/src/uxcore/hooks/useUXCoreGlobals.ts +++ b/src/uxcore/hooks/useUXCoreGlobals.ts @@ -1,10 +1,10 @@ -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; showArrows?: boolean; } @@ -12,6 +12,7 @@ let listeners: DispatchFuntion[] = []; let state: TState = { isCoreView: true, isProductView: true, + isOffsecView: false, showArrows: true, }; @@ -35,7 +36,18 @@ 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 }); }; const toggleShowArrows = () => { localStorage.setItem('showArrows', String(!state.showArrows)); @@ -47,6 +59,7 @@ 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'; if (changeState) { @@ -55,6 +68,9 @@ const initUseUXCoreGlobals = () => { if (changeStateView) { toggleIsProductView(); } + if (changeStateOffsec) { + toggleIsOffsecView(); + } if (changeStateArrows) { toggleShowArrows(); } @@ -77,6 +93,7 @@ const useUXCoreGlobals = (): CustomHookType => { initUseUXCoreGlobals, toggleIsCoreView, toggleIsProductView, + toggleIsOffsecView, toggleShowArrows, }, state, diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss index dc5f0d3..f3c3e9a 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss @@ -36,6 +36,60 @@ z-index: 3; } +.useCaseSwitcher { + position: absolute; + top: 235px; + right: 20px; + z-index: 3; + display: flex; + flex-direction: column; + align-items: flex-start; + + .useCaseLabel { + color: #333333; + font-size: 14px; + margin: 0 0 8px; + } + + .useCaseButton { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 12px; + background: #f4f4f4; + border: 1px solid #c4c4c4; + border-radius: 5px; + font-size: 14px; + text-transform: uppercase; + color: #000000d9; + cursor: pointer; + + &.active { + background: #ffffff; + border-color: #000000d9; + } + } + + // When PM/HR is active (OffSec inactive), dim the OffSec button to + // reinforce mutual exclusivity. When OffSec turns on, the + // viewTeamSwitcher above is dimmed by .dimmed. + &.dimmed { + opacity: 0.5; + } +} + +.viewTeamSwitcher.dimmed { + opacity: 0.5; +} + +@media (max-width: 1359px) { + .useCaseSwitcher { + top: 260px; + } +} + .Logos { position: absolute; top: 75%; diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx index c870ef0..6f47bde 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 ViewSwitcher = dynamic(() => import('@uxcore/components/_biases/ViewSwitcher'), { - ssr: false, -}); -const MobileView = dynamic(() => import('@uxcore/components/_biases/MobileView'), { - 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 UXCoreLayout: FC = ({ strapiBiases, @@ -62,6 +70,7 @@ const UXCoreLayout: FC = ({ }) => { const [{ toggleIsCoreView }, { isCoreView }] = useUXCoreGlobals(); const [{ toggleIsProductView }, { isProductView }] = useUXCoreGlobals(); + const [{ toggleIsOffsecView }, { isOffsecView }] = useUXCoreGlobals(); const router = useRouter(); const { asPath } = router as TRouter; const { isUxcoreMobile } = useUCoreMobile()[1]; @@ -78,11 +87,14 @@ const UXCoreLayout: FC = ({ 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 +108,7 @@ const UXCoreLayout: FC = ({ const basePath = `${localePrefix}/uxcore`; - const shouldBeHash = isProductView ? '' : '#hr'; + const shouldBeHash = isOffsecView ? '#offsec' : isProductView ? '' : '#hr'; const targetUrl = `${basePath}${shouldBeHash}`; @@ -105,7 +117,7 @@ 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) { @@ -160,13 +172,32 @@ const UXCoreLayout: FC = ({ secondViewIcon={isProductView ? : } secondViewLabel={'hr'} secondText={'HR'} - className={styles.viewTeamSwitcher} + className={cn(styles.viewTeamSwitcher, { + [styles.dimmed]: isOffsecView, + })} setIsSwitched={setIsSwitched} isSwitched={isSwitched} handleSnackbarOpening={handleSnackbarOpening} dataCy={'switch-product'} dataCySecondView={'switch-hr'} /> +
+

Use case

+
+ {isOffsecView ? : } + Offensive Cybersecurity +
+
{isCoreView && } {isCoreView && ( <> From 5e1af1887be49bc7a09b2eede69ea734a332bd62 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 21:39:08 +0000 Subject: [PATCH 04/51] =?UTF-8?q?feat(uxcore):=20finish=20OffSec=20button?= =?UTF-8?q?=20=E2=80=94=20Hexens=20logo,=20attached=20block,=20snackbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from Wolf's review of the OffSec scaffold: - Replaces the placeholder shield icon with the Hexens brand mark (bold X framed by top-left + bottom-right corner brackets), reproduced inline as SVG. - Landing-page button label shortens to "Cybersecurity" and tucks directly under the PM/HR strip with shared borders and matching width, so the three buttons read as one block. - Switching to OffSec now fires the same snackbar pattern used by PM/HR ("You are viewing offensive security use cases"), with a pre-set text so the first frame doesn't flash the previous PM/HR label. - Bias modal uses the full name "Offensive Security" instead of the "OffSec" short form. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/assets/icons/OffSecIcon.tsx | 55 ++++++------------- .../components/UXCoreModal/UXCoreModal.tsx | 4 +- src/uxcore/data/biases/en.ts | 1 + src/uxcore/data/biases/hy.ts | 1 + src/uxcore/data/biases/ru.ts | 2 + src/uxcore/data/modal/en.ts | 2 +- src/uxcore/data/modal/hy.ts | 2 +- src/uxcore/data/modal/ru.ts | 2 +- .../UXCoreLayout/UXCoreLayout.module.scss | 32 +++++------ .../layouts/UXCoreLayout/UXCoreLayout.tsx | 27 +++++++-- 10 files changed, 63 insertions(+), 65 deletions(-) diff --git a/src/uxcore/assets/icons/OffSecIcon.tsx b/src/uxcore/assets/icons/OffSecIcon.tsx index addba09..0f825ea 100644 --- a/src/uxcore/assets/icons/OffSecIcon.tsx +++ b/src/uxcore/assets/icons/OffSecIcon.tsx @@ -1,46 +1,25 @@ -// TODO(hexens-logo): replace this placeholder shield with the official -// hexens.io company logo SVG. Wolf to drop the asset; this component -// just needs its body swapped. -export const OffSecIcon = () => ( +// Hexens logo — bold X framed by top-left and bottom-right corner +// brackets. Reproduced as SVG from the hexens.io brand mark; uses +// currentColor so the parent's CSS color controls active/inactive +// shading. Two named exports kept for parity with the PM/HR icon +// pair (active vs greyed). +const HexensMark = ({ color }: { color: string }) => ( - - - + {/* top-left bracket */} + + {/* bottom-right bracket */} + + {/* X — two diagonal bars meeting at center */} + + ); -export const OffSecIconGrey = () => ( - - - - - -); +export const OffSecIcon = () => ; +export const OffSecIconGrey = () => ; diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index 7a0b652..e78d1e6 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -166,7 +166,7 @@ const UXCoreModal: FC = ({ managementValue, productText, hrText, - offsecShortText, + offsecText, offsecComingSoon, } = modalIntl[locale]; @@ -252,7 +252,7 @@ const UXCoreModal: FC = ({ })} > {isOffsecView ? : } - {offsecShortText} + {offsecText}
{isOffsecView ? (
{offsecComingSoon}
diff --git a/src/uxcore/data/biases/en.ts b/src/uxcore/data/biases/en.ts index 6c7603d..3d6799c 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 1fbdbf6..08383cb 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 73b99e9..cca4b8a 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 18749e4..5fd58d5 100644 --- a/src/uxcore/data/modal/en.ts +++ b/src/uxcore/data/modal/en.ts @@ -16,7 +16,7 @@ const en = { uxeducationButtonLabel: 'Using UXCG in Education', downloadButtonLabel: 'Download PDF', visualExample: 'Visual Example', - offsecText: 'Offensive Cybersecurity', + offsecText: 'Offensive Security', offsecShortText: 'OffSec', usageOffsec: 'Example of use by Offensive Cybersecurity', offsecComingSoon: diff --git a/src/uxcore/data/modal/hy.ts b/src/uxcore/data/modal/hy.ts index 30bee9c..7ef2177 100644 --- a/src/uxcore/data/modal/hy.ts +++ b/src/uxcore/data/modal/hy.ts @@ -16,7 +16,7 @@ const hy = { uxeducationButtonLabel: 'Using UXCG in Education', downloadButtonLabel: 'Ներբեռնել PDF', //TODO Add to sheet visualExample: 'Տեսողական օրինակ', - offsecText: 'Offensive Cybersecurity', + offsecText: 'Offensive Security', offsecShortText: 'OffSec', usageOffsec: 'Example of use by Offensive Cybersecurity', offsecComingSoon: diff --git a/src/uxcore/data/modal/ru.ts b/src/uxcore/data/modal/ru.ts index 21f8665..e6f7b47 100644 --- a/src/uxcore/data/modal/ru.ts +++ b/src/uxcore/data/modal/ru.ts @@ -16,7 +16,7 @@ const ru = { uxeducationButtonLabel: 'Использование UXCG в образовании', downloadButtonLabel: 'Скачать PDF', visualExample: 'Визуальный пример', - offsecText: 'Наступательная кибербезопасность', + offsecText: 'Наступательная безопасность', offsecShortText: 'OffSec', usageOffsec: 'Пример использования в наступательной кибербезопасности', offsecComingSoon: diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss index f3c3e9a..bbaca40 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss @@ -36,45 +36,45 @@ z-index: 3; } +// The PM/HR ViewSwitcher renders a label ("Viewer's team") + a 32px +// two-button row. Its top:150px + ~26px label/gap + 32px button puts +// its bottom edge at ~208px. The Cybersecurity button slots in +// immediately below, sharing borders so the three buttons read as a +// single block. .useCaseSwitcher { position: absolute; - top: 235px; + top: 208px; right: 20px; z-index: 3; - display: flex; - flex-direction: column; - align-items: flex-start; - - .useCaseLabel { - color: #333333; - font-size: 14px; - margin: 0 0 8px; - } .useCaseButton { display: flex; align-items: center; justify-content: center; gap: 6px; + width: 158px; // PM (79) + HR (79) — match the strip above height: 32px; - padding: 0 12px; + padding: 0 8px; background: #f4f4f4; border: 1px solid #c4c4c4; - border-radius: 5px; + border-top: 0; // merge with PM/HR bottom edge + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; font-size: 14px; text-transform: uppercase; color: #000000d9; cursor: pointer; + > span { + line-height: 1; + } + &.active { background: #ffffff; border-color: #000000d9; } } - // When PM/HR is active (OffSec inactive), dim the OffSec button to - // reinforce mutual exclusivity. When OffSec turns on, the - // viewTeamSwitcher above is dimmed by .dimmed. &.dimmed { opacity: 0.5; } @@ -86,7 +86,7 @@ @media (max-width: 1359px) { .useCaseSwitcher { - top: 260px; + top: 233px; } } diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx index 6f47bde..5c33194 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx @@ -81,7 +81,7 @@ 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(() => { @@ -121,13 +121,29 @@ const UXCoreLayout: FC = ({ 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]); + + const handleOffsecClick = () => { + // Pre-set the snackbar text from the next state — the global hook's + // listener updates asynchronously, so reading isOffsecView in the + // text-effect alone briefly flashes the previous PM/HR label. + if (!isOffsecView) { + setSnackBarText(browsingAsOffsec); + } else { + setSnackBarText(isProductView ? browsingAsProduct : browsingAsHR); + } + toggleIsOffsecView(); + setIsSwitched(prev => !prev); + handleSnackbarOpening(); + }; let snackbarTimeout: NodeJS.Timeout; const handleSnackbarOpening = () => { @@ -186,16 +202,15 @@ const UXCoreLayout: FC = ({ [styles.dimmed]: !isOffsecView, })} > -

Use case

{isOffsecView ? : } - Offensive Cybersecurity + Cybersecurity
{isCoreView && } From 5e1a79ab1ed764f304f99d99feaa75b1e882c433 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 21:47:37 +0000 Subject: [PATCH 05/51] fix(uxcore): widen Cybersecurity button to 160.94px Matches the actual rendered width of the PM/HR strip so the three buttons line up flush as one block. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss index bbaca40..cb2bdb5 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss @@ -52,7 +52,7 @@ align-items: center; justify-content: center; gap: 6px; - width: 158px; // PM (79) + HR (79) — match the strip above + width: 160.94px; // matches the rendered PM+HR strip width height: 32px; padding: 0 8px; background: #f4f4f4; From 09e3ff73d942e22ef416ee68a432c6ec46f54b1b Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 21:51:30 +0000 Subject: [PATCH 06/51] fix(uxcore): match Cybersecurity button width to PM+HR strip (158px) PM and HR are 79px each (border-box), so the strip is 158px wide. Sets the Cybersecurity button to the same with box-sizing forced, so the three buttons render as a flush block regardless of global box model. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss index cb2bdb5..d18d7ac 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss @@ -52,7 +52,8 @@ align-items: center; justify-content: center; gap: 6px; - width: 160.94px; // matches the rendered PM+HR strip width + box-sizing: border-box; + width: 158px; // PM (79) + HR (79) — matches the strip above exactly height: 32px; padding: 0 8px; background: #f4f4f4; From 158ec01e2615c694a4140fced19078f8fe883fb5 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 21:56:06 +0000 Subject: [PATCH 07/51] =?UTF-8?q?feat(uxcore):=20vertical=20Use=20cases=20?= =?UTF-8?q?panel=20=E2=80=94=20three=20same-width=20stacked=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshapes the right-hand control on the UX Core landing so PM, HR, and Cybersecurity render as one stacked block, all the same width, each row a full button with icon + label aligned left. Replaces the side-by- side PM/HR ViewSwitcher and the separate Cybersecurity strip. - New setUseCase action in useUXCoreGlobals — sets PM/HR/OffSec atomically so clicks are explicit instead of toggling. - Single click handler picks the right snackbar text and calls setUseCase. Every click fires the chip, matching prior PM/HR behaviour. - Cybersecurity label shortens to "OffSec" below 1280px so the row still fits comfortably; the full name stays on primary desktop. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/hooks/useUXCoreGlobals.ts | 16 ++++ .../UXCoreLayout/UXCoreLayout.module.scss | 88 +++++++++++-------- .../layouts/UXCoreLayout/UXCoreLayout.tsx | 79 +++++++++-------- 3 files changed, 109 insertions(+), 74 deletions(-) diff --git a/src/uxcore/hooks/useUXCoreGlobals.ts b/src/uxcore/hooks/useUXCoreGlobals.ts index 12f6f7e..f2c1e34 100644 --- a/src/uxcore/hooks/useUXCoreGlobals.ts +++ b/src/uxcore/hooks/useUXCoreGlobals.ts @@ -49,6 +49,21 @@ 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. Persists both flags atomically so any consumer +// reading the next state gets a consistent snapshot. +const setUseCase = (target: 'product' | 'hr' | 'offsec') => { + const next = { + isProductView: target === 'product', + isOffsecView: target === 'offsec', + }; + // 'hr' leaves isProductView=false, isOffsecView=false. + if (target === 'hr') next.isProductView = false; + localStorage.setItem('isProductView', String(next.isProductView)); + localStorage.setItem('isOffsecView', String(next.isOffsecView)); + reducer(next); +}; const toggleShowArrows = () => { localStorage.setItem('showArrows', String(!state.showArrows)); reducer({ showArrows: !state.showArrows }); @@ -94,6 +109,7 @@ const useUXCoreGlobals = (): CustomHookType => { 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 d18d7ac..7f9f5c5 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss @@ -29,65 +29,81 @@ z-index: 3; } -.viewTeamSwitcher { +// Vertical Use cases panel — three stacked rows (PM / HR / Cybersecurity). +// Sits below the View type switcher on the right. +.useCasesPanel { position: absolute; top: 150px; right: 20px; z-index: 3; -} + display: flex; + flex-direction: column; -// The PM/HR ViewSwitcher renders a label ("Viewer's team") + a 32px -// two-button row. Its top:150px + ~26px label/gap + 32px button puts -// its bottom edge at ~208px. The Cybersecurity button slots in -// immediately below, sharing borders so the three buttons read as a -// single block. -.useCaseSwitcher { - position: absolute; - top: 208px; - right: 20px; - z-index: 3; + .useCasesLabel { + color: #333333; + font-size: 14px; + margin: 0 0 8px; + } - .useCaseButton { + .useCaseRow { display: flex; align-items: center; - justify-content: center; - gap: 6px; + gap: 10px; box-sizing: border-box; - width: 158px; // PM (79) + HR (79) — matches the strip above exactly - height: 32px; - padding: 0 8px; + width: 200px; + height: 38px; + padding: 0 14px; background: #f4f4f4; border: 1px solid #c4c4c4; - border-top: 0; // merge with PM/HR bottom edge - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; + border-bottom-width: 0; + color: #515151; font-size: 14px; - text-transform: uppercase; - color: #000000d9; cursor: pointer; + transition: + background 160ms ease, + color 160ms ease; + + &:first-of-type { + border-top-left-radius: 6px; + border-top-right-radius: 6px; + } - > span { - line-height: 1; + &:last-of-type { + border-bottom-width: 1px; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; } &.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; + } } - } - &.dimmed { - opacity: 0.5; + .cybersecShort { + display: none; + } } } -.viewTeamSwitcher.dimmed { - opacity: 0.5; -} +// 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; -@media (max-width: 1359px) { - .useCaseSwitcher { - top: 233px; + .cybersecFull { + display: none; + } + .cybersecShort { + display: inline; + } } } @@ -185,10 +201,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 5c33194..e88393c 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx @@ -70,7 +70,8 @@ const UXCoreLayout: FC = ({ }) => { const [{ toggleIsCoreView }, { isCoreView }] = useUXCoreGlobals(); const [{ toggleIsProductView }, { isProductView }] = useUXCoreGlobals(); - const [{ toggleIsOffsecView }, { isOffsecView }] = useUXCoreGlobals(); + const [{ toggleIsOffsecView, setUseCase }, { isOffsecView }] = + useUXCoreGlobals(); const router = useRouter(); const { asPath } = router as TRouter; const { isUxcoreMobile } = useUCoreMobile()[1]; @@ -131,16 +132,17 @@ const UXCoreLayout: FC = ({ } }, [isSwitched, isProductView, isOffsecView, locale]); - const handleOffsecClick = () => { - // Pre-set the snackbar text from the next state — the global hook's - // listener updates asynchronously, so reading isOffsecView in the - // text-effect alone briefly flashes the previous PM/HR label. - if (!isOffsecView) { - setSnackBarText(browsingAsOffsec); - } else { - setSnackBarText(isProductView ? browsingAsProduct : browsingAsHR); - } - toggleIsOffsecView(); + // 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. + // Snackbar text is pre-set so the first frame doesn't flash the + // previous label while the hook listener catches up. + const handleUseCaseClick = (target: 'product' | 'hr' | 'offsec') => { + if (target === 'product') setSnackBarText(browsingAsProduct); + else if (target === 'hr') setSnackBarText(browsingAsHR); + else setSnackBarText(browsingAsOffsec); + + setUseCase(target); setIsSwitched(prev => !prev); handleSnackbarOpening(); }; @@ -180,37 +182,42 @@ const UXCoreLayout: FC = ({ dataCy={'core-view-switcher'} dataCySecondView={'folder-view-switcher'} /> - : } - secondViewIcon={isProductView ? : } - secondViewLabel={'hr'} - secondText={'HR'} - className={cn(styles.viewTeamSwitcher, { - [styles.dimmed]: isOffsecView, - })} - setIsSwitched={setIsSwitched} - isSwitched={isSwitched} - handleSnackbarOpening={handleSnackbarOpening} - dataCy={'switch-product'} - dataCySecondView={'switch-hr'} - /> -
+
+

Use cases

+
handleUseCaseClick('product')} + className={cn(styles.useCaseRow, { + [styles.active]: isProductView && !isOffsecView, + })} + > + {isProductView && !isOffsecView ? : } + PM +
+
handleUseCaseClick('hr')} + className={cn(styles.useCaseRow, { + [styles.active]: !isProductView && !isOffsecView, + })} + > + {!isProductView && !isOffsecView ? ( + + ) : ( + + )} + HR +
handleUseCaseClick('offsec')} + className={cn(styles.useCaseRow, { [styles.active]: isOffsecView, })} > {isOffsecView ? : } - Cybersecurity + Cybersecurity + OffSec
{isCoreView && } From 39eb53bfa9ccb8ab66c798b3a388b78186aa86d8 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 22:05:20 +0000 Subject: [PATCH 08/51] fix(uxcore): align View type to Use cases, OFFSEC caps, dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - View type switcher gets a `wide` variant (99px buttons → 198px total) so the Core/Folder strip and the Use cases column line up flush on both edges on the primary desktop view. - The Cybersecurity / OFFSEC row renders all-caps with light letter spacing — applies regardless of source string so localized labels keep the same visual weight. - Adds dark-mode rules for the new Use cases panel (rows + label) and for the modal's OffSec switcher + coming-soon panel so both surfaces read correctly against the dark page background. Co-Authored-By: Claude Opus 4.7 --- .../UXCoreModal/UXCoreModal.module.scss | 26 ++++++++++++++ .../ViewSwitcher/ViewSwitcher.module.scss | 13 +++++++ .../_biases/ViewSwitcher/ViewSwitcher.tsx | 3 ++ .../UXCoreLayout/UXCoreLayout.module.scss | 34 +++++++++++++++++-- .../layouts/UXCoreLayout/UXCoreLayout.tsx | 1 + 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index dcbd8ef..c4ff2cc 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -600,6 +600,32 @@ ol { .ModalBodyContent .switcher .activeHr svg { fill: #bbe4f2 !important; } + + .ModalBodyContent .offsecSwitcher { + background-color: #151a26 !important; + border-color: #303338 !important; + color: #c2c7cf !important; + + svg { + fill: #c2c7cf !important; + } + + &.active { + background-color: rgba(200, 65, 42, 0.18) !important; + border-color: #e57254 !important; + color: #ffd4c7 !important; + + svg { + fill: #ffd4c7 !important; + } + } + } + + .ModalBodyContent .offsecComingSoon { + background-color: #1f1419 !important; + border-color: #c8412a !important; + color: #ffd4c7 !important; + } } :global(body.darkTheme) .h1 { diff --git a/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss b/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss index 2b0fcb1..a740a8a 100644 --- a/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss +++ b/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss @@ -94,6 +94,19 @@ $active-bg-color: #ffffff; } } +// Wide variant — used by the Use cases column on the UX Core landing so +// the View type pair lines up flush with the 200px-wide Use cases rows +// underneath. Each button bumps from 79px to 99px so the two-button +// strip totals 198px (≈ matches the Use cases column width). +.wide { + .ViewSwitcherButtons { + .ViewSwitcherButton { + box-sizing: border-box; + width: 99px; + } + } +} + @media (max-width: 1360px) { .ViewSwitcher { margin-bottom: 10px; diff --git a/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.tsx b/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.tsx index 3db8e3e..be3b8a0 100644 --- a/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.tsx +++ b/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.tsx @@ -25,6 +25,7 @@ type PropTypes = { secondText?: string; dataCy?: string; dataCySecondView?: string; + wide?: boolean; }; const ViewSwitcher = ({ @@ -40,6 +41,7 @@ const ViewSwitcher = ({ handleSnackbarOpening, dataCy, dataCySecondView, + wide, }: PropTypes) => { 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/layouts/UXCoreLayout/UXCoreLayout.module.scss b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss index 7f9f5c5..90b4a61 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss @@ -30,7 +30,9 @@ } // Vertical Use cases panel — three stacked rows (PM / HR / Cybersecurity). -// Sits below the View type switcher on the right. +// 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; @@ -50,7 +52,7 @@ align-items: center; gap: 10px; box-sizing: border-box; - width: 200px; + width: 198px; height: 38px; padding: 0 14px; background: #f4f4f4; @@ -72,6 +74,9 @@ 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 { @@ -92,6 +97,31 @@ } } +// 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 laptop viewports swap "Cybersecurity" for the short // "OffSec" so the third row's label still fits comfortably. @media (max-width: 1280px) { diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx index e88393c..5d1ae3f 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx @@ -179,6 +179,7 @@ const UXCoreLayout: FC = ({ secondViewIcon={} className={styles.viewTypeSwitcher} labelViewType + wide dataCy={'core-view-switcher'} dataCySecondView={'folder-view-switcher'} /> From a174a854b0a6aaf0f3ebda45235ea6a136d96b5d Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 22:11:06 +0000 Subject: [PATCH 09/51] fix(uxcore): widen View type on small screens; Hexens follows theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ViewSwitcher.wide overrides the 1360px column-mode breakpoint so the Core/Folder pair stays the same 168px width as the Use cases column underneath on 13–14" laptops, not the default 63px. - Hexens mark now renders with `fill: currentColor`, so its colour cascades from the row's CSS. The dark-mode active row already sets white text, so the icon inverts to white automatically without a second asset or theme prop. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/assets/icons/OffSecIcon.tsx | 18 ++++++++++-------- .../ViewSwitcher/ViewSwitcher.module.scss | 12 ++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/uxcore/assets/icons/OffSecIcon.tsx b/src/uxcore/assets/icons/OffSecIcon.tsx index 0f825ea..861421a 100644 --- a/src/uxcore/assets/icons/OffSecIcon.tsx +++ b/src/uxcore/assets/icons/OffSecIcon.tsx @@ -1,14 +1,16 @@ // Hexens logo — bold X framed by top-left and bottom-right corner -// brackets. Reproduced as SVG from the hexens.io brand mark; uses -// currentColor so the parent's CSS color controls active/inactive -// shading. Two named exports kept for parity with the PM/HR icon -// pair (active vs greyed). -const HexensMark = ({ color }: { color: string }) => ( +// brackets. Reproduced as SVG from the hexens.io brand mark. Uses +// `currentColor` so the host row's `color` cascade decides the fill, +// which lets dark mode invert it to white without a second asset. +// Two exports kept for parity with the PM/HR icon pair, but they +// share the same body — the wrapping span on the "grey" variant lets +// callers visually flag the inactive state if needed. +const HexensMark = () => ( {/* top-left bracket */} @@ -21,5 +23,5 @@ const HexensMark = ({ color }: { color: string }) => ( ); -export const OffSecIcon = () => ; -export const OffSecIconGrey = () => ; +export const OffSecIcon = () => ; +export const OffSecIconGrey = () => ; diff --git a/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss b/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss index a740a8a..c8de936 100644 --- a/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss +++ b/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss @@ -105,6 +105,18 @@ $active-bg-color: #ffffff; width: 99px; } } + + // At the column-mode breakpoint the base rule shrinks each button to + // 63×28. Override so the View type pair stays the same width as the + // Use cases column underneath (168px / 38px tall on small screens). + @media (max-width: 1360px) { + .ViewSwitcherButtons { + .ViewSwitcherButton { + width: 168px; + height: 38px; + } + } + } } @media (max-width: 1360px) { From 8bd58858ad8f113db30fe55361fbba1a8ba73388 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 22:17:17 +0000 Subject: [PATCH 10/51] fix(uxcore): View type wide override needs chained selector to win The standalone @media (max-width:1360px) block targeting .ViewSwitcher .ViewSwitcherButton was source-ordered after the .wide override and tied on specificity, so on Wolf's 1272px viewport the column-mode 63x28 shrink was still winning. Re-anchors the override on .ViewSwitcher.wide (chained, 4-class) and moves it after the legacy media blocks so both specificity and source order favour it. Co-Authored-By: Claude Opus 4.7 --- .../ViewSwitcher/ViewSwitcher.module.scss | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss b/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss index c8de936..a3f4132 100644 --- a/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss +++ b/src/uxcore/components/_biases/ViewSwitcher/ViewSwitcher.module.scss @@ -94,31 +94,6 @@ $active-bg-color: #ffffff; } } -// Wide variant — used by the Use cases column on the UX Core landing so -// the View type pair lines up flush with the 200px-wide Use cases rows -// underneath. Each button bumps from 79px to 99px so the two-button -// strip totals 198px (≈ matches the Use cases column width). -.wide { - .ViewSwitcherButtons { - .ViewSwitcherButton { - box-sizing: border-box; - width: 99px; - } - } - - // At the column-mode breakpoint the base rule shrinks each button to - // 63×28. Override so the View type pair stays the same width as the - // Use cases column underneath (168px / 38px tall on small screens). - @media (max-width: 1360px) { - .ViewSwitcherButtons { - .ViewSwitcherButton { - width: 168px; - height: 38px; - } - } - } -} - @media (max-width: 1360px) { .ViewSwitcher { margin-bottom: 10px; @@ -179,6 +154,27 @@ $active-bg-color: #ffffff; } } +// Wide variant — used by the Use cases column on the UX Core landing so +// the View type pair lines up flush with the Use cases rows underneath. +// Chained selector (.ViewSwitcher.wide) outranks the standalone +// max-width:1360px override above (3 classes vs 4), so the column-mode +// shrink to 63×28 doesn't reach the wide instance. +.ViewSwitcher.wide { + .ViewSwitcherButtons .ViewSwitcherButton { + box-sizing: border-box; + width: 99px; + } +} + +@media (max-width: 1360px) { + .ViewSwitcher.wide { + .ViewSwitcherButtons .ViewSwitcherButton { + width: 168px; + height: 38px; + } + } +} + // Dark theme: repaint switcher buttons. Inactive buttons sit on page bg, // active button mirrors header surface for clear contrast. Restores the // right wall the legacy row-layout CSS strips off (`border-right-width: 0` From f27db9f392b624994dbc14d812720a044d18b7a5 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 22:24:43 +0000 Subject: [PATCH 11/51] fix(uxcore): drop Use cases below View type column; kill scrollbar line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small-screen quality fixes: - When the View type ViewSwitcher restacks into a column at <=1360px, its bottom edge sits below 150px, which is where the Use cases panel was anchored — so the "Use cases" label overlapped the Folder icon. Drops the panel to top:220px in the same breakpoint so the label clears the second View type button. - Removes the 1px #fafafa border-left on the html scrollbar and thumb. Combined with scrollbar-gutter:stable, it was painting a thin white line down the right edge of every UX Core page in dark mode. Co-Authored-By: Claude Opus 4.7 --- .../layouts/UXCoreLayout/UXCoreLayout.module.scss | 10 ++++++++++ src/uxcore/styles/globals.scss | 2 -- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss index 90b4a61..89e3195 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.module.scss @@ -122,6 +122,16 @@ } } +// 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) { diff --git a/src/uxcore/styles/globals.scss b/src/uxcore/styles/globals.scss index c6fd90a..7a0330f 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; } } From d4da2ae6806bbcae2006633294eab22891a9fe14 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 22:35:38 +0000 Subject: [PATCH 12/51] feat(uxcore): Offensive Cybersecurity content for Availability Heuristics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First worked example under the Offensive Cybersecurity use case — when the visitor toggles to OffSec on the Availability Heuristics bias they get a full attacker-side breakdown plus defender takeaway, in a Hexens visual register (crimson accents, monospace metadata, sharp corner brackets on the visual card). - New OffsecBiasView component renders prose intro, scenario, side-by- side inbox mock, with/without-bias outcome contrast, why-it-works paragraph, and the blue-team countermeasure. - Themed light + dark variants; collapses to single-column visual + a stacked outcome grid below 720px. - Modal looks up the bias slug via the new biasOffsec data module; if no OffSec content exists yet for a given bias the existing "coming soon" panel still renders. - Modal label now reads "Offensive Cybersecurity" (full form) again in the per-bias OffSec row, matching the landing-page block. Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/OffsecBiasView.module.scss | 441 ++++++++++++++++++ .../OffsecBiasView/OffsecBiasView.tsx | 92 ++++ src/uxcore/components/OffsecBiasView/index.ts | 3 + .../components/UXCoreModal/UXCoreModal.tsx | 13 +- .../data/biasOffsec/availabilityHeuristics.ts | 43 ++ src/uxcore/data/biasOffsec/index.ts | 18 + src/uxcore/data/modal/en.ts | 2 +- src/uxcore/data/modal/hy.ts | 2 +- src/uxcore/data/modal/ru.ts | 2 +- 9 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss create mode 100644 src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx create mode 100644 src/uxcore/components/OffsecBiasView/index.ts create mode 100644 src/uxcore/data/biasOffsec/availabilityHeuristics.ts create mode 100644 src/uxcore/data/biasOffsec/index.ts diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss new file mode 100644 index 0000000..303a566 --- /dev/null +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -0,0 +1,441 @@ +// Offensive Cybersecurity view for a bias modal. Visual language: +// sharp angular cards, monospace for sender/subject, crimson Hexens +// accent on the "with bias" side. Tuned for both light and dark +// themes via :global(body.darkTheme) overrides at the bottom. + +$ks-paper: #fbf8f1; +$ks-ink: #1b1e26; +$ks-ink-soft: #4a5060; +$ks-rule: #d8d0bf; +$ks-rule-soft: #ece2d0; +$ks-crimson: #c8412a; +$ks-crimson-soft: #fdecea; +$ks-crimson-deep: #7a2618; + +.root { + display: flex; + flex-direction: column; + gap: 18px; + padding: 4px 0 8px; + color: $ks-ink; + font-family: 'Lato', sans-serif; +} + +.eyebrow { + display: inline-block; + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: $ks-crimson; + background: transparent; + padding: 2px 0; +} + +.intro { + margin: 0; + font-size: 15.5px; + line-height: 1.55; + color: $ks-ink; + font-style: italic; + border-left: 3px solid $ks-crimson; + padding-left: 14px; +} + +.scenarioBlock { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + background: $ks-paper; + border: 1px solid $ks-rule; + border-radius: 6px; + + .scenario { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: $ks-ink-soft; + } +} + +.visualBlock { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + background: #ffffff; + border: 1px solid $ks-rule; + border-radius: 8px; + position: relative; + + &::before { + content: ''; + position: absolute; + top: -1px; + left: -1px; + width: 28px; + height: 28px; + border-top: 2px solid $ks-crimson; + border-left: 2px solid $ks-crimson; + border-top-left-radius: 8px; + pointer-events: none; + } + + &::after { + content: ''; + position: absolute; + bottom: -1px; + right: -1px; + width: 28px; + height: 28px; + border-bottom: 2px solid $ks-crimson; + border-right: 2px solid $ks-crimson; + border-bottom-right-radius: 8px; + pointer-events: none; + } +} + +.visualHeader { + display: flex; + align-items: center; + gap: 8px; + + .markWrap { + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + color: $ks-crimson; + } +} + +.cards { + display: grid; + grid-template-columns: 1fr 36px 1fr; + align-items: stretch; + gap: 8px; +} + +.card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px; + background: $ks-paper; + border: 1px solid $ks-rule; + border-radius: 6px; + min-width: 0; + + .cardTag { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: $ks-ink-soft; + } + + .cardSender { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 11.5px; + color: $ks-ink-soft; + word-break: break-all; + } + + .cardSubject { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 700; + color: $ks-ink; + line-height: 1.3; + } + + .cardUrgencyDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: $ks-crimson; + flex-shrink: 0; + box-shadow: 0 0 0 3px rgba(200, 65, 42, 0.18); + } + + .cardPreview { + font-size: 12.5px; + line-height: 1.4; + color: $ks-ink-soft; + } + + .cardStat { + display: flex; + align-items: baseline; + gap: 4px; + margin-top: 4px; + padding-top: 8px; + border-top: 1px dashed $ks-rule; + + .cardStatValue { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 16px; + font-weight: 700; + color: $ks-ink; + } + + .cardStatLabel { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: $ks-ink-soft; + } + } +} + +.cardFlagged { + background: linear-gradient(180deg, $ks-crimson-soft 0%, #ffffff 110%); + border-color: $ks-crimson; + + .cardTag { + color: $ks-crimson-deep; + } + + .cardStatValue { + color: $ks-crimson; + } +} + +.cardDivider { + display: flex; + align-items: center; + justify-content: center; + + .cardArrow { + font-size: 22px; + color: $ks-crimson; + font-weight: 600; + } +} + +.outcomeBlock { + display: flex; + flex-direction: column; + gap: 1px; + background: $ks-rule; + border: 1px solid $ks-rule; + border-radius: 6px; + overflow: hidden; +} + +.outcomeRow { + display: grid; + grid-template-columns: 160px 1fr; + gap: 14px; + padding: 12px 16px; + background: #ffffff; + font-size: 14px; + line-height: 1.45; + align-items: start; + + .outcomeKey { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: $ks-ink-soft; + } + + .outcomeValue { + color: $ks-ink; + } +} + +.outcomeRowAccent { + background: $ks-crimson-soft; + + .outcomeKey { + color: $ks-crimson-deep; + } + + .outcomeValue { + color: $ks-crimson-deep; + font-weight: 600; + } +} + +.proseBlock { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + background: $ks-paper; + border: 1px solid $ks-rule; + border-radius: 6px; + + p { + margin: 0; + font-size: 14px; + line-height: 1.55; + color: $ks-ink; + } +} + +.defenderBlock { + background: #f4f1e8; + border-left: 3px solid $ks-crimson; +} + +// === Dark theme ============================================================ + +:global(body.darkTheme) { + $dk-bg: #14171f; + $dk-card: #1b1f29; + $dk-card-soft: #20242f; + $dk-rule: #2f3441; + $dk-rule-soft: #262a36; + $dk-text: #e6e8ee; + $dk-text-soft: #a5acba; + $dk-crimson: #e57254; + $dk-crimson-soft: rgba(200, 65, 42, 0.18); + $dk-crimson-glow: rgba(229, 114, 84, 0.4); + + .root { + color: $dk-text; + } + + .eyebrow { + color: $dk-crimson; + } + + .intro { + color: $dk-text; + border-left-color: $dk-crimson; + } + + .scenarioBlock { + background: $dk-card; + border-color: $dk-rule; + + .scenario { + color: $dk-text-soft; + } + } + + .visualBlock { + background: $dk-card-soft; + border-color: $dk-rule; + + &::before, + &::after { + border-color: $dk-crimson; + } + } + + .visualHeader .markWrap { + color: $dk-crimson; + } + + .card { + background: $dk-card; + border-color: $dk-rule; + + .cardTag, + .cardSender, + .cardPreview, + .cardStatLabel { + color: $dk-text-soft; + } + + .cardSubject, + .cardStatValue { + color: $dk-text; + } + + .cardUrgencyDot { + background: $dk-crimson; + box-shadow: 0 0 0 3px $dk-crimson-glow; + } + + .cardStat { + border-top-color: $dk-rule; + } + } + + .cardFlagged { + background: linear-gradient(180deg, $dk-crimson-soft 0%, $dk-card 110%); + border-color: $dk-crimson; + + .cardTag { + color: $dk-crimson; + } + + .cardStatValue { + color: $dk-crimson; + } + } + + .cardDivider .cardArrow { + color: $dk-crimson; + } + + .outcomeBlock { + background: $dk-rule; + border-color: $dk-rule; + } + + .outcomeRow { + background: $dk-card; + + .outcomeKey { + color: $dk-text-soft; + } + + .outcomeValue { + color: $dk-text; + } + } + + .outcomeRowAccent { + background: $dk-crimson-soft; + + .outcomeKey, + .outcomeValue { + color: #ffc8b8; + } + } + + .proseBlock { + background: $dk-card; + border-color: $dk-rule; + + p { + color: $dk-text; + } + } + + .defenderBlock { + background: $dk-card-soft; + border-left-color: $dk-crimson; + } +} + +// === Responsive ============================================================ + +@media (max-width: 720px) { + .cards { + grid-template-columns: 1fr; + gap: 12px; + } + + .cardDivider { + transform: rotate(90deg); + justify-self: center; + } + + .outcomeRow { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx new file mode 100644 index 0000000..d19dead --- /dev/null +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -0,0 +1,92 @@ +import { OffSecIcon } from '@uxcore/assets/icons/OffSecIcon'; +import { OffsecBiasContent } from '@uxcore/data/biasOffsec'; + +import styles from './OffsecBiasView.module.scss'; + +interface OffsecBiasViewProps { + content: OffsecBiasContent; +} + +const OffsecBiasView = ({ content }: OffsecBiasViewProps) => { + const { before, after } = content.visual; + + return ( +
+

{content.intro}

+ +
+ {content.scenarioLabel} +

{content.scenario}

+
+ +
+
+ + + + {content.visualLabel} +
+ +
+
+
{before.tag}
+
{before.sender}
+
{before.subject}
+
{before.preview}
+
+ {before.stat.value} + {before.stat.label} +
+
+ +
+ +
+ +
+
{after.tag}
+
{after.sender}
+
+ + {after.subject} +
+
{after.preview}
+
+ {after.stat.value} + {after.stat.label} +
+
+
+
+ +
+
+ + {content.outcome.withoutLabel} + + + {content.outcome.withoutText} + +
+
+ {content.outcome.withLabel} + + {content.outcome.withText} + +
+
+ +
+ {content.whyItWorksLabel} +

{content.whyItWorks}

+
+ +
+ {content.blueTeamLabel} +

{content.blueTeam}

+
+
+ ); +}; + +export default OffsecBiasView; diff --git a/src/uxcore/components/OffsecBiasView/index.ts b/src/uxcore/components/OffsecBiasView/index.ts new file mode 100644 index 0000000..86e53a7 --- /dev/null +++ b/src/uxcore/components/OffsecBiasView/index.ts @@ -0,0 +1,3 @@ +import OffsecBiasView from './OffsecBiasView'; + +export default OffsecBiasView; diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index e78d1e6..fa0b628 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -4,9 +4,11 @@ import ProductIcon from '@uxcore/assets/icons/ProductIcon'; import BiasBody from '@uxcore/components/_biases/BiasBody'; import ContentParser from '@uxcore/components/ContentParser'; import ModalRaiting from '@uxcore/components/ModalRaiting'; +import OffsecBiasView from '@uxcore/components/OffsecBiasView'; import Spinner from '@uxcore/components/Spinner'; import Table from '@uxcore/components/Table'; import UXCoreModalHeader from '@uxcore/components/UXCoreModalParts/UXCoreModalHeader'; +import { getOffsecBiasContent } from '@uxcore/data/biasOffsec'; import modalIntl from '@uxcore/data/modal'; import useUXCoreGlobals from '@uxcore/hooks/useUXCoreGlobals'; import { copyToClipboard, generateSocialLinks } from '@uxcore/lib/helpers'; @@ -255,7 +257,16 @@ const UXCoreModal: FC = ({ {offsecText}
{isOffsecView ? ( -
{offsecComingSoon}
+ (() => { + const offsecContent = getOffsecBiasContent(biasNumber); + return offsecContent ? ( + + ) : ( +
+ {offsecComingSoon} +
+ ); + })() ) : ( = { + 'availability-heuristics': availabilityHeuristics, +}; + +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 { OffsecBiasContent }; diff --git a/src/uxcore/data/modal/en.ts b/src/uxcore/data/modal/en.ts index 5fd58d5..18749e4 100644 --- a/src/uxcore/data/modal/en.ts +++ b/src/uxcore/data/modal/en.ts @@ -16,7 +16,7 @@ const en = { uxeducationButtonLabel: 'Using UXCG in Education', downloadButtonLabel: 'Download PDF', visualExample: 'Visual Example', - offsecText: 'Offensive Security', + offsecText: 'Offensive Cybersecurity', offsecShortText: 'OffSec', usageOffsec: 'Example of use by Offensive Cybersecurity', offsecComingSoon: diff --git a/src/uxcore/data/modal/hy.ts b/src/uxcore/data/modal/hy.ts index 7ef2177..30bee9c 100644 --- a/src/uxcore/data/modal/hy.ts +++ b/src/uxcore/data/modal/hy.ts @@ -16,7 +16,7 @@ const hy = { uxeducationButtonLabel: 'Using UXCG in Education', downloadButtonLabel: 'Ներբեռնել PDF', //TODO Add to sheet visualExample: 'Տեսողական օրինակ', - offsecText: 'Offensive Security', + offsecText: 'Offensive Cybersecurity', offsecShortText: 'OffSec', usageOffsec: 'Example of use by Offensive Cybersecurity', offsecComingSoon: diff --git a/src/uxcore/data/modal/ru.ts b/src/uxcore/data/modal/ru.ts index e6f7b47..21f8665 100644 --- a/src/uxcore/data/modal/ru.ts +++ b/src/uxcore/data/modal/ru.ts @@ -16,7 +16,7 @@ const ru = { uxeducationButtonLabel: 'Использование UXCG в образовании', downloadButtonLabel: 'Скачать PDF', visualExample: 'Визуальный пример', - offsecText: 'Наступательная безопасность', + offsecText: 'Наступательная кибербезопасность', offsecShortText: 'OffSec', usageOffsec: 'Пример использования в наступательной кибербезопасности', offsecComingSoon: From 3699aba1ad855f5848b4a1a859d8491f48590868 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 22:57:38 +0000 Subject: [PATCH 13/51] fix(uxcore): strip unsourced numbers from OffSec example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the fabricated open-rate badges (2.8% / 21.4%), the made-up ~7x lift claim, and the false-precision operational windows (14 days / 72 hours) from the Availability Heuristics OffSec example. Keeps the directional contrast — the visual still shows generic vs breach-themed framing side by side, and the outcome rows now describe the effect qualitatively rather than quoting unprovable percentages. Rule going forward: zero mocked numbers in the OffSec layer; if a figure cannot be sourced, it does not ship. Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/OffsecBiasView.tsx | 8 -------- .../data/biasOffsec/availabilityHeuristics.ts | 18 +++++++++++------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index d19dead..0f69529 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -33,10 +33,6 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => {
{before.sender}
{before.subject}
{before.preview}
-
- {before.stat.value} - {before.stat.label} -
@@ -51,10 +47,6 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => { {after.subject}
{after.preview}
-
- {after.stat.value} - {after.stat.label} -
diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index a2b9e5e..852bc20 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -1,9 +1,15 @@ +// 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. + const content = { intro: - 'Brains shortcut "how likely" with "how easy to recall." After a breach hits the news, every employee has the threat one neuron away — engineer to receptionist. Attackers ride that recency: an email naming the breach feels like an inevitable follow-up, not a probe. The same lure two weeks earlier would die in spam.', + 'Brains shortcut "how likely" with "how easy to recall." After a breach hits the news, every employee has the threat one neuron away — engineer to receptionist. Attackers ride that recency: an email naming the breach feels like an inevitable follow-up, not a probe. The same lure a few weeks earlier would die in spam.', scenarioLabel: 'Scenario', scenario: - 'Spear-phish targeting a 200-person fintech finance team, five days after a competitor’s breach hits the front page.', + 'Spear-phish targeting a finance team in the days after a competitor’s breach hits the front page.', visualLabel: 'Same payload, different framing', visual: { before: { @@ -11,7 +17,6 @@ const content = { sender: 'billing@acme-vendor.com', subject: 'Q3 invoice attached', preview: 'Hi team — please find the attached invoice for Q3.', - stat: { value: '2.8%', label: 'opens' }, }, after: { tag: 'Breach-themed lure', @@ -19,24 +24,23 @@ const content = { subject: 'Action required: NorthBank exposure check', preview: 'Our security team flagged your domain in the NorthBank dataset…', - stat: { value: '21.4%', label: 'opens' }, flagged: true, }, }, outcome: { withoutLabel: 'Without the bias', withoutText: - 'Generic invoice lure — ~3% click rate, lost in noise alongside two other promo emails.', + 'Generic invoice lure reads like every other vendor email. Brushed off, lost in the inbox.', withLabel: 'With the bias', withText: - 'Breach-themed lure — ~7× lift; the threat category is already top-of-mind across the org.', + 'Breach-themed lure rides the news cycle — it feels like an expected follow-up, not a probe.', }, whyItWorksLabel: 'Why it works', whyItWorks: 'Recent media coverage warps base-rate judgment. The brain treats vivid and recent as common and imminent, even when actual incidence hasn’t moved. Identical bytes; the news cycle is doing the persuasion.', blueTeamLabel: 'Blue-team countermeasure', blueTeam: - 'Treat topical urgency as a phishing signal, not a credibility one. SOC adds a detection rule that flags subjects echoing the last 14 days of breach headlines. Crisis runbooks include "expect impersonation within 72 hours" as a default step after any public incident.', + 'Treat topical urgency as a phishing signal, not a credibility one. Tune detection so subjects echoing the current breach news cycle get extra scrutiny, and write crisis runbooks that assume impersonation attempts will follow every public incident.', }; export default content; From b990dbac0fc01c7c7f7f64b4b300ecaf30b3423f Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 23:01:26 +0000 Subject: [PATCH 14/51] =?UTF-8?q?fix(uxcore):=20rename=20"Example=20of=20u?= =?UTF-8?q?se=20by=20team"=20=E2=86=92=20"Examples=20of=20use"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the "by team" qualifier from the modal section header so it covers the new Offensive Cybersecurity use case alongside Product and People Management. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/data/modal/en.ts | 2 +- src/uxcore/data/modal/hy.ts | 2 +- src/uxcore/data/modal/ru.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/uxcore/data/modal/en.ts b/src/uxcore/data/modal/en.ts index 18749e4..445cdb6 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', diff --git a/src/uxcore/data/modal/hy.ts b/src/uxcore/data/modal/hy.ts index 30bee9c..b386174 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', diff --git a/src/uxcore/data/modal/ru.ts b/src/uxcore/data/modal/ru.ts index 21f8665..7f8e448 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: 'Продукт Менеджмент', From 31c417fce66ab365acccabf5222230768122ccf4 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 20 May 2026 23:06:27 +0000 Subject: [PATCH 15/51] fix(uxcore): smooth content swap when toggling PM / HR / OffSec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal's use-case content block was popping on every switch — PM/HR had the sliding indicator on the switcher itself but the body text swapped instantly, and the OffSec branch had no transition at all. Wraps the content slot in a keyed div that replays a short fade-in (opacity + 6px Y) every time the active use case changes, so all three toggles feel continuous on click. Co-Authored-By: Claude Opus 4.7 --- .../UXCoreModal/UXCoreModal.module.scss | 18 +++++++++ .../components/UXCoreModal/UXCoreModal.tsx | 39 +++++++++++-------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index c4ff2cc..9e92e1e 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -1,3 +1,14 @@ +@keyframes usageFadeIn { + 0% { + opacity: 0; + transform: translateY(6px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + .ModalOverlay { display: flex; width: 100vw; @@ -212,6 +223,13 @@ font-size: 14px; line-height: 1.5; } + + // Crossfade-on-key swap for the use case content slot. Each + // PM/HR/OffSec click rotates the keyed wrapper, so the animation + // replays and the new content slides in instead of popping. + .usageFade { + animation: usageFadeIn 320ms cubic-bezier(0.22, 0.95, 0.35, 1) both; + } } } diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index fa0b628..ad7b868 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -256,23 +256,28 @@ const UXCoreModal: FC = ({ {isOffsecView ? : } {offsecText} - {isOffsecView ? ( - (() => { - const offsecContent = getOffsecBiasContent(biasNumber); - return offsecContent ? ( - - ) : ( -
- {offsecComingSoon} -
- ); - })() - ) : ( - - )} +
+ {isOffsecView ? ( + (() => { + const offsecContent = getOffsecBiasContent(biasNumber); + return offsecContent ? ( + + ) : ( +
+ {offsecComingSoon} +
+ ); + })() + ) : ( + + )} +
{data.title && } {questions.length > 0 && ( From a810cde744b0f9f84605716dd78bf32d6bf5a7ea Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 16:43:36 +0000 Subject: [PATCH 16/51] fix(uxcore): clear PM/HR selection state when OffSec view active Co-Authored-By: Claude Opus 4.7 --- src/uxcore/components/UXCoreModal/UXCoreModal.module.scss | 4 ++++ src/uxcore/components/UXCoreModal/UXCoreModal.tsx | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index 9e92e1e..a8063ae 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -176,6 +176,10 @@ &.dimmed { opacity: 0.5; + + &::before { + opacity: 0; + } } } diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index ad7b868..f351242 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -228,7 +228,7 @@ const UXCoreModal: FC = ({ data-cy="switch-product" data-type={defaultViewLabel} className={cn(styles.switcherItem, { - [styles.activeProduct]: !isProductView, + [styles.activeProduct]: !isOffsecView && !isProductView, })} > @@ -239,7 +239,7 @@ const UXCoreModal: FC = ({ data-cy="switch-hr" data-type={secondViewLabel} className={cn(styles.switcherItem, { - [styles.activeHr]: isProductView, + [styles.activeHr]: !isOffsecView && isProductView, })} > From e358b2dcb61595a3ab275461f8ea367eb71fcbe5 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 16:52:45 +0000 Subject: [PATCH 17/51] fix(uxcore): lift ToolHeader above page chrome so language dropdown clears ViewSwitcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolHeader carried z-index: 2, sandboxing its LanguageSwitcher dropdown (z-index: 50) inside that stacking context. The page-level View type switcher and Use cases panel both sit at root z-index: 3, so they floated above the entire navbar — visible as the View-type icon obscuring the Armenian row of the open language menu on /uxcore. Bump ToolHeader to z-index: 10. Navbar now outranks page chrome and the dropdown renders cleanly over both controls. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/components/ToolHeader/ToolHeader.module.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/uxcore/components/ToolHeader/ToolHeader.module.scss b/src/uxcore/components/ToolHeader/ToolHeader.module.scss index 18c1f8a..e706bf1 100644 --- a/src/uxcore/components/ToolHeader/ToolHeader.module.scss +++ b/src/uxcore/components/ToolHeader/ToolHeader.module.scss @@ -13,7 +13,10 @@ $headerHeight: 46px; flex-direction: row; align-items: center; box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.05); - z-index: 2; + // Sits above page chrome (ViewSwitcher / Use cases panel both at z-index: 3 + // in UXCoreLayout) so the LanguageSwitcher dropdown — which lives inside + // this header's stacking context — isn't trapped beneath those controls. + z-index: 10; box-sizing: border-box; justify-content: space-between; From 0a45c5d69578e1850f587eee7d257d0370437b8a Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 16:57:28 +0000 Subject: [PATCH 18/51] =?UTF-8?q?fix(uxcore):=20tighten=20OffSec=20block?= =?UTF-8?q?=20=E2=80=94=20cut=20noise,=20deepen=20defender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical pass on the Offensive Cybersecurity section of the bias modal: - Drop the italic intro paragraph (duplicated WHY IT WORKS). - Drop the WITHOUT/WITH outcome rows (narrated the visual cards). - Sharpen the scenario into a single attack-frame line. - Name the cognitive mechanism in WHY IT WORKS (availability + base-rate neglect) and the substitution the target makes. - Expand BLUE-TEAM into a lede + 4 concrete defender moves. No fabricated stats — directional language only. Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/OffsecBiasView.module.scss | 132 ++++++------------ .../OffsecBiasView/OffsecBiasView.tsx | 26 +--- .../data/biasOffsec/availabilityHeuristics.ts | 29 ++-- 3 files changed, 64 insertions(+), 123 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss index 303a566..dbdabfc 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -33,16 +33,6 @@ $ks-crimson-deep: #7a2618; padding: 2px 0; } -.intro { - margin: 0; - font-size: 15.5px; - line-height: 1.55; - color: $ks-ink; - font-style: italic; - border-left: 3px solid $ks-crimson; - padding-left: 14px; -} - .scenarioBlock { display: flex; flex-direction: column; @@ -219,52 +209,6 @@ $ks-crimson-deep: #7a2618; } } -.outcomeBlock { - display: flex; - flex-direction: column; - gap: 1px; - background: $ks-rule; - border: 1px solid $ks-rule; - border-radius: 6px; - overflow: hidden; -} - -.outcomeRow { - display: grid; - grid-template-columns: 160px 1fr; - gap: 14px; - padding: 12px 16px; - background: #ffffff; - font-size: 14px; - line-height: 1.45; - align-items: start; - - .outcomeKey { - font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; - font-size: 11px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: $ks-ink-soft; - } - - .outcomeValue { - color: $ks-ink; - } -} - -.outcomeRowAccent { - background: $ks-crimson-soft; - - .outcomeKey { - color: $ks-crimson-deep; - } - - .outcomeValue { - color: $ks-crimson-deep; - font-weight: 600; - } -} - .proseBlock { display: flex; flex-direction: column; @@ -287,6 +231,39 @@ $ks-crimson-deep: #7a2618; border-left: 3px solid $ks-crimson; } +.defenderLede { + margin: 0 0 4px; + font-weight: 600; + color: $ks-ink; +} + +.defenderMoves { + margin: 6px 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + + li { + position: relative; + padding-left: 18px; + font-size: 14px; + line-height: 1.55; + color: $ks-ink; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0.65em; + width: 10px; + height: 1px; + background: $ks-crimson; + } + } +} + // === Dark theme ============================================================ :global(body.darkTheme) { @@ -309,11 +286,6 @@ $ks-crimson-deep: #7a2618; color: $dk-crimson; } - .intro { - color: $dk-text; - border-left-color: $dk-crimson; - } - .scenarioBlock { background: $dk-card; border-color: $dk-rule; @@ -380,32 +352,6 @@ $ks-crimson-deep: #7a2618; color: $dk-crimson; } - .outcomeBlock { - background: $dk-rule; - border-color: $dk-rule; - } - - .outcomeRow { - background: $dk-card; - - .outcomeKey { - color: $dk-text-soft; - } - - .outcomeValue { - color: $dk-text; - } - } - - .outcomeRowAccent { - background: $dk-crimson-soft; - - .outcomeKey, - .outcomeValue { - color: #ffc8b8; - } - } - .proseBlock { background: $dk-card; border-color: $dk-rule; @@ -419,6 +365,18 @@ $ks-crimson-deep: #7a2618; background: $dk-card-soft; border-left-color: $dk-crimson; } + + .defenderLede { + color: $dk-text; + } + + .defenderMoves li { + color: $dk-text; + + &::before { + background: $dk-crimson; + } + } } // === Responsive ============================================================ diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index 0f69529..04202b3 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -12,8 +12,6 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => { return (
-

{content.intro}

-
{content.scenarioLabel}

{content.scenario}

@@ -51,23 +49,6 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => {
-
-
- - {content.outcome.withoutLabel} - - - {content.outcome.withoutText} - -
-
- {content.outcome.withLabel} - - {content.outcome.withText} - -
-
-
{content.whyItWorksLabel}

{content.whyItWorks}

@@ -75,7 +56,12 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => {
{content.blueTeamLabel} -

{content.blueTeam}

+

{content.blueTeam.lede}

+
    + {content.blueTeam.moves.map((move, i) => ( +
  • {move}
  • + ))} +
); diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index 852bc20..9fdb5e0 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -5,11 +5,9 @@ // is well documented; the specific lift is not the point of the page. const content = { - intro: - 'Brains shortcut "how likely" with "how easy to recall." After a breach hits the news, every employee has the threat one neuron away — engineer to receptionist. Attackers ride that recency: an email naming the breach feels like an inevitable follow-up, not a probe. The same lure a few weeks earlier would die in spam.', scenarioLabel: 'Scenario', scenario: - 'Spear-phish targeting a finance team in the days after a competitor’s breach hits the front page.', + 'Vendor-impersonation phish, finance team — in the days after a competitor’s breach hits the front page.', visualLabel: 'Same payload, different framing', visual: { before: { @@ -21,26 +19,25 @@ const content = { after: { tag: 'Breach-themed lure', sender: 'security@acme-vendor.com', - subject: 'Action required: NorthBank exposure check', + subject: 'Action required: exposure check after the NorthBank incident', preview: - 'Our security team flagged your domain in the NorthBank dataset…', + 'Our team flagged your domain in the NorthBank dataset. Confirm SSO so we can scope your exposure before EOD.', flagged: true, }, }, - outcome: { - withoutLabel: 'Without the bias', - withoutText: - 'Generic invoice lure reads like every other vendor email. Brushed off, lost in the inbox.', - withLabel: 'With the bias', - withText: - 'Breach-themed lure rides the news cycle — it feels like an expected follow-up, not a probe.', - }, whyItWorksLabel: 'Why it works', whyItWorks: - 'Recent media coverage warps base-rate judgment. The brain treats vivid and recent as common and imminent, even when actual incidence hasn’t moved. Identical bytes; the news cycle is doing the persuasion.', + 'Availability heuristic colliding with base-rate neglect. After a breach saturates the news, the brain stops asking “how likely?” and starts asking “how easy to recall?” — and right now, the answer is everywhere. The target substitutes “I just read about this” for “I should verify this sender,” and a finance employee in that window pattern-matches the email to the news cycle, not to phishing. Identical payload; the news desk is doing the social engineering.', blueTeamLabel: 'Blue-team countermeasure', - blueTeam: - 'Treat topical urgency as a phishing signal, not a credibility one. Tune detection so subjects echoing the current breach news cycle get extra scrutiny, and write crisis runbooks that assume impersonation attempts will follow every public incident.', + blueTeam: { + lede: 'Invert the heuristic in the org’s head: topical is a phishing signal, not a credibility one.', + moves: [ + 'Tune detection so subjects echoing the current news cycle get extra scrutiny — same nouns as the front page is a feature of the attack, not a coincidence.', + 'Pre-publish a breach-week runbook that assumes vendor-impersonation attempts will follow every public incident in the days after.', + 'Drop a one-line prime into the org channel the day a major breach lands: “expect lures naming this company by tomorrow — verify in band before clicking.”', + 'Strip implicit trust from neighboring sender domains; attackers register lookalikes the same week the news breaks.', + ], + }, }; export default content; From c4cb2c4d6f6747f57ead54597bc0a50dc5131524 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 17:02:55 +0000 Subject: [PATCH 19/51] fix(uxcore): make OffSec use-case row declickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the active OffSec row was a no-op — once selected, Cybersecurity locked in with no way back to PM/HR. Now a second click on the active row reverts to the user's last PM/HR selection (lastBaseUseCase, tracked across setUseCase calls and persisted in localStorage). Snackbar pre-set mirrors the resolution so the first frame lands on the correct label. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/hooks/useUXCoreGlobals.ts | 30 ++++++++++++++----- .../layouts/UXCoreLayout/UXCoreLayout.tsx | 17 +++++++---- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/uxcore/hooks/useUXCoreGlobals.ts b/src/uxcore/hooks/useUXCoreGlobals.ts index f2c1e34..528bf75 100644 --- a/src/uxcore/hooks/useUXCoreGlobals.ts +++ b/src/uxcore/hooks/useUXCoreGlobals.ts @@ -5,6 +5,10 @@ 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; } @@ -13,6 +17,7 @@ let state: TState = { isCoreView: true, isProductView: true, isOffsecView: false, + lastBaseUseCase: 'product', showArrows: true, }; @@ -51,15 +56,22 @@ const toggleIsOffsecView = () => { }; // Explicit setter used by the vertical Use cases panel — three mutually -// exclusive targets. Persists both flags atomically so any consumer -// reading the next state gets a consistent snapshot. +// 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') => { - const next = { - isProductView: target === 'product', - isOffsecView: target === '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', }; - // 'hr' leaves isProductView=false, isOffsecView=false. - if (target === 'hr') next.isProductView = false; + 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); @@ -77,6 +89,10 @@ const initUseUXCoreGlobals = () => { 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(); } diff --git a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx index 5d1ae3f..0dff2dc 100644 --- a/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx +++ b/src/uxcore/layouts/UXCoreLayout/UXCoreLayout.tsx @@ -70,8 +70,10 @@ const UXCoreLayout: FC = ({ }) => { const [{ toggleIsCoreView }, { isCoreView }] = useUXCoreGlobals(); const [{ toggleIsProductView }, { isProductView }] = useUXCoreGlobals(); - const [{ toggleIsOffsecView, setUseCase }, { isOffsecView }] = - useUXCoreGlobals(); + const [ + { toggleIsOffsecView, setUseCase }, + { isOffsecView, lastBaseUseCase }, + ] = useUXCoreGlobals(); const router = useRouter(); const { asPath } = router as TRouter; const { isUxcoreMobile } = useUCoreMobile()[1]; @@ -135,11 +137,14 @@ const UXCoreLayout: FC = ({ // 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. - // Snackbar text is pre-set so the first frame doesn't flash the - // previous label while the hook listener catches up. + // 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') => { - if (target === 'product') setSnackBarText(browsingAsProduct); - else if (target === 'hr') setSnackBarText(browsingAsHR); + const resolved = + target === 'offsec' && isOffsecView ? lastBaseUseCase || 'hr' : target; + if (resolved === 'product') setSnackBarText(browsingAsProduct); + else if (resolved === 'hr') setSnackBarText(browsingAsHR); else setSnackBarText(browsingAsOffsec); setUseCase(target); From cefdac221c3348a3de520fa2924a2c688d7149e1 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 17:03:39 +0000 Subject: [PATCH 20/51] fix(uxcore): hide PM/HR visual-example block when OffSec view is active OffSec already ships its own visual (the email-card pair) inside the section body. Showing the PM/HR "Visual example" block underneath made the modal look like the OffSec content had been bolted on top of an unrelated demo. Suppressing BiasBody when isOffsecView is true keeps the OffSec layer self-contained. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/components/UXCoreModal/UXCoreModal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index f351242..72cd175 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -279,7 +279,9 @@ const UXCoreModal: FC = ({ )} - {data.title && } + {!isOffsecView && data.title && ( + + )} {questions.length > 0 && ( <>
Date: Thu, 21 May 2026 17:51:40 +0000 Subject: [PATCH 21/51] fix(uxcore): fold Scenario into Same-payload block; drop Hexens mark from example header Cuts one section, removes the brand-conflated icon from the example, and sharpens two blue-team moves (grammar + "out of band" instead of "in band", "lookalike" instead of "neighboring"). Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/OffsecBiasView.module.scss | 52 ++++--------------- .../OffsecBiasView/OffsecBiasView.tsx | 14 +---- .../data/biasOffsec/availabilityHeuristics.ts | 9 ++-- 3 files changed, 15 insertions(+), 60 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss index dbdabfc..d221ffa 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -33,27 +33,10 @@ $ks-crimson-deep: #7a2618; padding: 2px 0; } -.scenarioBlock { - display: flex; - flex-direction: column; - gap: 6px; - padding: 14px 16px; - background: $ks-paper; - border: 1px solid $ks-rule; - border-radius: 6px; - - .scenario { - margin: 0; - font-size: 14px; - line-height: 1.5; - color: $ks-ink-soft; - } -} - .visualBlock { display: flex; flex-direction: column; - gap: 12px; + gap: 10px; padding: 16px; background: #ffffff; border: 1px solid $ks-rule; @@ -85,20 +68,12 @@ $ks-crimson-deep: #7a2618; border-bottom-right-radius: 8px; pointer-events: none; } -} - -.visualHeader { - display: flex; - align-items: center; - gap: 8px; - .markWrap { - display: inline-flex; - width: 22px; - height: 22px; - align-items: center; - justify-content: center; - color: $ks-crimson; + .scenario { + margin: 0 0 4px; + font-size: 14px; + line-height: 1.5; + color: $ks-ink-soft; } } @@ -286,15 +261,6 @@ $ks-crimson-deep: #7a2618; color: $dk-crimson; } - .scenarioBlock { - background: $dk-card; - border-color: $dk-rule; - - .scenario { - color: $dk-text-soft; - } - } - .visualBlock { background: $dk-card-soft; border-color: $dk-rule; @@ -303,10 +269,10 @@ $ks-crimson-deep: #7a2618; &::after { border-color: $dk-crimson; } - } - .visualHeader .markWrap { - color: $dk-crimson; + .scenario { + color: $dk-text-soft; + } } .card { diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index 04202b3..d6ec3dc 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -1,4 +1,3 @@ -import { OffSecIcon } from '@uxcore/assets/icons/OffSecIcon'; import { OffsecBiasContent } from '@uxcore/data/biasOffsec'; import styles from './OffsecBiasView.module.scss'; @@ -12,18 +11,9 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => { return (
-
- {content.scenarioLabel} -

{content.scenario}

-
-
-
- - - - {content.visualLabel} -
+ {content.visualLabel} +

{content.scenario}

diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index 9fdb5e0..64ec3ac 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -5,9 +5,8 @@ // is well documented; the specific lift is not the point of the page. const content = { - scenarioLabel: 'Scenario', scenario: - 'Vendor-impersonation phish, finance team — in the days after a competitor’s breach hits the front page.', + 'Vendor-impersonation phish aimed at finance — in the days after a competitor’s breach hits the front page.', visualLabel: 'Same payload, different framing', visual: { before: { @@ -32,10 +31,10 @@ const content = { blueTeam: { lede: 'Invert the heuristic in the org’s head: topical is a phishing signal, not a credibility one.', moves: [ - 'Tune detection so subjects echoing the current news cycle get extra scrutiny — same nouns as the front page is a feature of the attack, not a coincidence.', + 'Tune detection so subjects echoing the current news cycle get extra scrutiny — front-page vocabulary landing in your inbox is a feature of the attack, not a coincidence.', 'Pre-publish a breach-week runbook that assumes vendor-impersonation attempts will follow every public incident in the days after.', - 'Drop a one-line prime into the org channel the day a major breach lands: “expect lures naming this company by tomorrow — verify in band before clicking.”', - 'Strip implicit trust from neighboring sender domains; attackers register lookalikes the same week the news breaks.', + 'Drop a one-line prime into the org channel the day a major breach lands: “expect lures naming this company by tomorrow — verify out of band before clicking.”', + 'Strip implicit trust from lookalike sender domains — attackers register them the same week the news breaks.', ], }, }; From e3791b1b2411588f14f2288ee7d7b9b48fe35924 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 18:02:00 +0000 Subject: [PATCH 22/51] =?UTF-8?q?fix(uxcore):=20sharpen=20OffSec=20counter?= =?UTF-8?q?measures=20=E2=80=94=20IOC=20promotion=20+=20posture=20elevatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves 1 and 2 were too vague ("tune detection", "pre-publish a runbook"). Replace with technical blue-team actions tied to the attack's kill chain: treat the breach disclosure itself as an IOC, and elevate mail posture (URL rewriting, sandbox detonation, forced SSO re-auth) for the news-cycle window. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/data/biasOffsec/availabilityHeuristics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index 64ec3ac..b7cfa98 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -31,8 +31,8 @@ const content = { blueTeam: { lede: 'Invert the heuristic in the org’s head: topical is a phishing signal, not a credibility one.', moves: [ - 'Tune detection so subjects echoing the current news cycle get extra scrutiny — front-page vocabulary landing in your inbox is a feature of the attack, not a coincidence.', - 'Pre-publish a breach-week runbook that assumes vendor-impersonation attempts will follow every public incident in the days after.', + 'Treat the breach disclosure as an IOC. The moment HIBP, a CERT advisory, or the vendor itself names the victim, pipe the company name, exec names, and incident terms into the mail gateway and SIEM — every inbound match gets sandboxed and banner-flagged for the duration of the news cycle.', + 'Raise mail posture for the window: click-time URL rewriting on every inbound link, hold-and-detonate for non-trusted attachments, and forced SSO re-auth so credentials phished mid-window can’t ride a live session.', 'Drop a one-line prime into the org channel the day a major breach lands: “expect lures naming this company by tomorrow — verify out of band before clicking.”', 'Strip implicit trust from lookalike sender domains — attackers register them the same week the news breaks.', ], From ed1cc9d566dd1fcc7021ec4258de0db132a59852 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 18:20:15 +0000 Subject: [PATCH 23/51] fix(uxcore): re-frame OffSec countermeasures from blue-team to individual reader The audience for UX Core isn't security teams; it's product/design folk and curious readers. Re-cast the section as "Protect yourself" with user-level habits (slow down on topical lures, out-of-band verify, password manager as domain judge, treat breach references as citations to check). Rename the schema field blueTeam/blueTeamLabel -> defense/defenseLabel so the same shape carries across all 105 biases. Co-Authored-By: Claude Opus 4.7 --- .../components/OffsecBiasView/OffsecBiasView.tsx | 6 +++--- .../data/biasOffsec/availabilityHeuristics.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index d6ec3dc..5cbdf42 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -45,10 +45,10 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => {
- {content.blueTeamLabel} -

{content.blueTeam.lede}

+ {content.defenseLabel} +

{content.defense.lede}

    - {content.blueTeam.moves.map((move, i) => ( + {content.defense.moves.map((move, i) => (
  • {move}
  • ))}
diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index b7cfa98..99a47f7 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -27,14 +27,14 @@ const content = { whyItWorksLabel: 'Why it works', whyItWorks: 'Availability heuristic colliding with base-rate neglect. After a breach saturates the news, the brain stops asking “how likely?” and starts asking “how easy to recall?” — and right now, the answer is everywhere. The target substitutes “I just read about this” for “I should verify this sender,” and a finance employee in that window pattern-matches the email to the news cycle, not to phishing. Identical payload; the news desk is doing the social engineering.', - blueTeamLabel: 'Blue-team countermeasure', - blueTeam: { - lede: 'Invert the heuristic in the org’s head: topical is a phishing signal, not a credibility one.', + defenseLabel: 'Protect yourself', + defense: { + lede: 'Your security team is handling the perimeter. Here’s how you handle your inbox.', moves: [ - 'Treat the breach disclosure as an IOC. The moment HIBP, a CERT advisory, or the vendor itself names the victim, pipe the company name, exec names, and incident terms into the mail gateway and SIEM — every inbound match gets sandboxed and banner-flagged for the duration of the news cycle.', - 'Raise mail posture for the window: click-time URL rewriting on every inbound link, hold-and-detonate for non-trusted attachments, and forced SSO re-auth so credentials phished mid-window can’t ride a live session.', - 'Drop a one-line prime into the org channel the day a major breach lands: “expect lures naming this company by tomorrow — verify out of band before clicking.”', - 'Strip implicit trust from lookalike sender domains — attackers register them the same week the news breaks.', + 'When an email 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.', + 'Verify through a channel you already trust — the vendor’s portal from a bookmark, or the phone number already in your contacts. Never the link or number in the email itself.', + '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 in the email as a claim, not a fact. Check the company’s own status page or Have I Been Pwned before you click anything else in the message.', ], }, }; From a730041e3224d21af8ccc0383e06372e67e54a1d Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 19:13:15 +0000 Subject: [PATCH 24/51] fix(uxcore): simplify scenario copy; make OffSec cards look like actual emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eyebrow becomes plain "Scenario" with two-beat copy ("A major company just got breached… now an attacker emails your finance team") so non- technical readers grok the setup at a glance. The two cards lose their inline tags (now sit outside as captions), gain a sender + timestamp header row, a hairline rule between subject and body, and an attachment chip on the generic invoice — they now read as email previews rather than abstract spec cards. Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/OffsecBiasView.module.scss | 128 +++++++++++------- .../OffsecBiasView/OffsecBiasView.tsx | 54 ++++++-- .../data/biasOffsec/availabilityHeuristics.ts | 11 +- 3 files changed, 130 insertions(+), 63 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss index d221ffa..341783a 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -84,22 +84,41 @@ $ks-crimson-deep: #7a2618; gap: 8px; } +.cardWrap { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.cardCaption { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: $ks-ink-soft; +} + +.cardCaptionFlagged { + color: $ks-crimson; +} + .card { display: flex; flex-direction: column; gap: 6px; - padding: 14px; - background: $ks-paper; + padding: 12px 14px 14px; + background: #ffffff; border: 1px solid $ks-rule; border-radius: 6px; min-width: 0; + box-shadow: 0 1px 0 rgba(27, 30, 38, 0.04); - .cardTag { - font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; - font-size: 10px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: $ks-ink-soft; + .emailHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; } .cardSender { @@ -109,6 +128,14 @@ $ks-crimson-deep: #7a2618; word-break: break-all; } + .cardTimestamp { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 10.5px; + color: $ks-ink-soft; + opacity: 0.75; + flex-shrink: 0; + } + .cardSubject { display: flex; align-items: center; @@ -119,6 +146,12 @@ $ks-crimson-deep: #7a2618; line-height: 1.3; } + .cardRule { + height: 1px; + background: $ks-rule; + margin: 4px 0 2px; + } + .cardUrgencyDot { width: 8px; height: 8px; @@ -130,31 +163,27 @@ $ks-crimson-deep: #7a2618; .cardPreview { font-size: 12.5px; - line-height: 1.4; + line-height: 1.45; color: $ks-ink-soft; } - .cardStat { - display: flex; - align-items: baseline; - gap: 4px; + .cardAttachment { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; margin-top: 4px; - padding-top: 8px; - border-top: 1px dashed $ks-rule; - - .cardStatValue { - font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; - font-size: 16px; - font-weight: 700; - color: $ks-ink; - } + padding: 4px 8px; + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 11px; + color: $ks-ink-soft; + background: $ks-paper; + border: 1px solid $ks-rule; + border-radius: 4px; - .cardStatLabel { - font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; - font-size: 10px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: $ks-ink-soft; + .cardAttachmentIcon { + font-size: 11px; + line-height: 1; } } } @@ -163,12 +192,8 @@ $ks-crimson-deep: #7a2618; background: linear-gradient(180deg, $ks-crimson-soft 0%, #ffffff 110%); border-color: $ks-crimson; - .cardTag { - color: $ks-crimson-deep; - } - - .cardStatValue { - color: $ks-crimson; + .cardRule { + background: rgba(200, 65, 42, 0.25); } } @@ -275,29 +300,42 @@ $ks-crimson-deep: #7a2618; } } + .cardCaption { + color: $dk-text-soft; + } + + .cardCaptionFlagged { + color: $dk-crimson; + } + .card { background: $dk-card; border-color: $dk-rule; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25); - .cardTag, .cardSender, - .cardPreview, - .cardStatLabel { + .cardTimestamp, + .cardPreview { color: $dk-text-soft; } - .cardSubject, - .cardStatValue { + .cardSubject { color: $dk-text; } + .cardRule { + background: $dk-rule; + } + .cardUrgencyDot { background: $dk-crimson; box-shadow: 0 0 0 3px $dk-crimson-glow; } - .cardStat { - border-top-color: $dk-rule; + .cardAttachment { + background: $dk-card-soft; + border-color: $dk-rule; + color: $dk-text-soft; } } @@ -305,12 +343,8 @@ $ks-crimson-deep: #7a2618; background: linear-gradient(180deg, $dk-crimson-soft 0%, $dk-card 110%); border-color: $dk-crimson; - .cardTag { - color: $dk-crimson; - } - - .cardStatValue { - color: $dk-crimson; + .cardRule { + background: rgba(229, 114, 84, 0.35); } } diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index 5cbdf42..409eecf 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -16,25 +16,55 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => {

{content.scenario}

-
-
{before.tag}
-
{before.sender}
-
{before.subject}
-
{before.preview}
+
+ {before.tag} +
+
+ {before.sender} + {before.timestamp && ( + + {before.timestamp} + + )} +
+
{before.subject}
+
+
{before.preview}
+ {before.attachment && ( +
+ 📎 + {before.attachment} +
+ )} +
-
-
{after.tag}
-
{after.sender}
-
- - {after.subject} +
+ + {after.tag} + +
+
+ {after.sender} + {after.timestamp && ( + + {after.timestamp} + + )} +
+
+ + {after.subject} +
+
+
{after.preview}
-
{after.preview}
diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index 99a47f7..7aab89b 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -6,18 +6,21 @@ const content = { scenario: - 'Vendor-impersonation phish aimed at finance — in the days after a competitor’s breach hits the front page.', - visualLabel: 'Same payload, different framing', + 'A major company just got breached and the news is everywhere. Now an attacker emails your finance team, pretending to be a trusted vendor.', + visualLabel: 'Scenario', visual: { before: { - tag: 'Generic lure', + tag: 'Generic', sender: 'billing@acme-vendor.com', + timestamp: 'Mon, 10:42 AM', subject: 'Q3 invoice attached', preview: 'Hi team — please find the attached invoice for Q3.', + attachment: 'invoice-Q3.pdf', }, after: { - tag: 'Breach-themed lure', + tag: 'News-anchored', sender: 'security@acme-vendor.com', + timestamp: 'Tue, 08:17 AM', subject: 'Action required: exposure check after the NorthBank incident', preview: 'Our team flagged your domain in the NorthBank dataset. Confirm SSO so we can scope your exposure before EOD.', From 5c8997817fdc420eab94b350c7be4704a542bba6 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 19:28:33 +0000 Subject: [PATCH 25/51] feat(theme): extract one shared ThemeToggle; use it in site header and bias modal Single source of truth for the moon/sun toggle. Previously the only toggle lived as an inline div in src/components/Header/Header.tsx; bias modals had none. Extracted into src/components/ThemeToggle/, swapped the inline version, and dropped the same component next to the language selector inside the bias-modal header. Cross-realm sync (window event in useGlobals) keeps both trees in lockstep, so a click anywhere reaches everywhere. Co-Authored-By: Claude Opus 4.7 --- src/components/Header/Header.module.scss | 19 ------------ src/components/Header/Header.tsx | 16 ++-------- .../ThemeToggle/ThemeToggle.module.scss | 30 ++++++++++++++++++ src/components/ThemeToggle/ThemeToggle.tsx | 31 +++++++++++++++++++ src/components/ThemeToggle/index.ts | 1 + .../UXCoreModalHeader/UXCoreModalHeader.tsx | 4 ++- 6 files changed, 68 insertions(+), 33 deletions(-) create mode 100644 src/components/ThemeToggle/ThemeToggle.module.scss create mode 100644 src/components/ThemeToggle/ThemeToggle.tsx create mode 100644 src/components/ThemeToggle/index.ts diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss index 2686626..f54ab9f 100644 --- a/src/components/Header/Header.module.scss +++ b/src/components/Header/Header.module.scss @@ -72,17 +72,6 @@ margin: 0 6px; } } - - & .toggleTheme { - background-image: url('/keepsimple_/assets/themeIcons/moon.png'); - cursor: pointer; - background-position: left center; - background-repeat: no-repeat; - background-size: 24px; - width: 24px; - height: 24px; - padding-right: 16px; - } } } @@ -203,10 +192,6 @@ & .actions { background: #151a26; - & .toggleTheme { - background-image: url('/keepsimple_/assets/themeIcons/sun.png'); - } - .toggleLanguage { .languageTitle { color: #dadada; @@ -332,10 +317,6 @@ } } - & .toggleTheme { - background-image: url('/keepsimple_/assets/themeIcons/sun.png'); - } - .toggleLanguage { .languageTitle { color: #dadada; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 7d9ac8a..e2cef50 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -24,6 +24,7 @@ import { GlobalContext } from '@components/Context/GlobalContext'; import LogIn from '@components/LogIn'; import Navbar from '@components/Navbar'; import Link from '@components/NextLink'; +import ThemeToggle from '@components/ThemeToggle'; import UserProfile from '@components/UserProfile'; import styles from './Header.module.scss'; @@ -42,10 +43,7 @@ const Header: FC = () => { const isSmallScreen = useIsWidthLessThan(1141); const [openLogin, setOpenLogin] = useState(false); const { accountData, setAccountData } = useContext(GlobalContext); - const [ - { toggleIsDarkTheme, toggleSidebar }, - { isDarkTheme, isOpenedSidebar }, - ] = useGlobals(); + const [{ toggleSidebar }, { isDarkTheme, isOpenedSidebar }] = useGlobals(); useEffect(() => { const storedToken = localStorage.getItem('accessToken'); @@ -58,10 +56,6 @@ const Header: FC = () => { } }, [router.query.authError]); - const handleToggleTheme = useCallback(() => { - toggleIsDarkTheme(); - }, []); - const handleToggleSidebar = useCallback(() => { toggleSidebar(); }, []); @@ -176,11 +170,7 @@ const Header: FC = () => { handleClick={handleClick} />
-
+
{ + const [{ toggleIsDarkTheme }, { isDarkTheme }] = useGlobals(); + + const onClick = useCallback(() => { + toggleIsDarkTheme(); + }, []); + + return ( +
-
+
{content.whyItWorksLabel}

{content.whyItWorks}

From 5f7321170ea8651fcc8809e3fc4a96b51f1cd1e6 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 20:57:27 +0000 Subject: [PATCH 27/51] fix(uxcore): single-click switch from OffSec back to PM/HR in bias modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: handlePageViewChange used a toggle-with-guard "(type === secondViewLabel) !== isSecondView". When OffSec was active the underlying isProductView still pointed at the prior PM/HR side, so clicking that same side made the guard false-out and the click got swallowed — user had to click twice (or click HR first then PM) to actually land on PM. Replace the toggle with an explicit setUseCase('product' | 'hr') call. Always lands on the clicked side in one click, regardless of whether OffSec was active. setUseCase already drops isOffsecView atomically. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/components/UXCoreModal/UXCoreModal.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index 72cd175..4feb751 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -53,7 +53,6 @@ const UXCoreModal: FC = ({ onClose, onChangeBiasId, isProductView, - toggleIsProductView, isSecondView, data, setIsModalClosed, @@ -65,7 +64,8 @@ const UXCoreModal: FC = ({ slugs, }) => { const router = useRouter(); - const [{ toggleIsOffsecView }, { isOffsecView }] = useUXCoreGlobals(); + const [{ toggleIsOffsecView, setUseCase }, { isOffsecView }] = + useUXCoreGlobals(); const [isCopyTooltipVisible, setIsCopyTooltipVisible] = useState(false); const [isQuestionHovered, setIsQuestionHovered] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -79,11 +79,13 @@ const UXCoreModal: FC = ({ const handlePageViewChange = useCallback( e => { const { type } = e.currentTarget.dataset; - if ((type === secondViewLabel) !== isSecondView) { - toggleIsProductView(); - } + // Explicit set (not toggle) — guarantees a single click switches to + // the clicked side regardless of whether OffSec is currently active. + // The prior toggle-with-guard swallowed clicks when OffSec was on but + // the underlying isProductView still matched the clicked side. + setUseCase(type === secondViewLabel ? 'hr' : 'product'); }, - [isSecondView, toggleIsProductView], + [secondViewLabel, setUseCase], ); const handleCopyLink = useCallback(() => { From afe6bba493a28fc3a0095f3d657d2f8947e8ebda Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 22:27:26 +0000 Subject: [PATCH 28/51] fix(uxcore): lock OffSec voice to second-person across all three blocks Scenario said "your team", Why-it-works said "the target / a finance employee", Protect-yourself said "you". Re-cast Scenario and Why-it-works in the same direct second-person register as Protect-yourself so the reader stays the protagonist from setup to defence. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/data/biasOffsec/availabilityHeuristics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index 7aab89b..c9f5031 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -6,7 +6,7 @@ const content = { scenario: - 'A major company just got breached and the news is everywhere. Now an attacker emails your finance team, pretending to be a trusted vendor.', + 'A major company just got breached and the news is everywhere. The next morning, an email lands in your inbox — looks like a vendor you trust, anchored to the breach you just read about.', visualLabel: 'Scenario', visual: { before: { @@ -29,7 +29,7 @@ const content = { }, whyItWorksLabel: 'Why it works', whyItWorks: - 'Availability heuristic colliding with base-rate neglect. After a breach saturates the news, the brain stops asking “how likely?” and starts asking “how easy to recall?” — and right now, the answer is everywhere. The target substitutes “I just read about this” for “I should verify this sender,” and a finance employee in that window pattern-matches the email to the news cycle, not to phishing. Identical payload; the news desk is doing the social engineering.', + '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 sender,” and pattern-match the email to the news cycle, not to phishing. Identical payload; the news desk is doing the social engineering.', defenseLabel: 'Protect yourself', defense: { lede: 'Your security team is handling the perimeter. Here’s how you handle your inbox.', From 4b7e27820788dfa76b385a0a260a249a2f98dbf8 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 22:37:21 +0000 Subject: [PATCH 29/51] =?UTF-8?q?fix(uxcore):=20tighten=20Protect-yourself?= =?UTF-8?q?=20lede=20=E2=80=94=20your=20homework=20framing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the "handle the perimeter / handle your inbox" parallel for a sharper "while your security team handles the perimeter — here's your homework" opener. Reads as a directive instead of a parallelism; "homework" sets expectation that what follows is actionable and personal. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/data/biasOffsec/availabilityHeuristics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index c9f5031..79d311b 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -32,7 +32,7 @@ const content = { '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 sender,” and pattern-match the email to the news cycle, not to phishing. Identical payload; the news desk is doing the social engineering.', defenseLabel: 'Protect yourself', defense: { - lede: 'Your security team is handling the perimeter. Here’s how you handle your inbox.', + lede: 'While your security team handles the perimeter — here’s your homework.', moves: [ 'When an email 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.', 'Verify through a channel you already trust — the vendor’s portal from a bookmark, or the phone number already in your contacts. Never the link or number in the email itself.', From fb294165aff5cb25c836c9bca4eb918f623abe13 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 22:57:55 +0000 Subject: [PATCH 30/51] feat(uxcore): add kemmio co-author credit + bio popup at the foot of OffSec view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-liner under the Protect-yourself block — "Examples co-authored with kemmio." — where kemmio is a button that opens a portal-rendered read-only popup. The popup closes on backdrop click and on Escape (capture-phase listener with stopImmediatePropagation so the parent bias modal does not also close on the same keypress). Bio is constant across all OffSec biases (component-internal copy, not per-bias data): Hexens co-founder, Aptos critical-vulnerability research ($1T+ Web3 exposure averted), and the historically biggest Web3 critical-vulnerability disclosure ($500M instant + $1.7T cascade, caught in private disclosure). Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/KemmioCredit.module.scss | 211 ++++++++++++++++++ .../OffsecBiasView/KemmioCredit.tsx | 97 ++++++++ .../OffsecBiasView/OffsecBiasView.tsx | 4 + 3 files changed, 312 insertions(+) create mode 100644 src/uxcore/components/OffsecBiasView/KemmioCredit.module.scss create mode 100644 src/uxcore/components/OffsecBiasView/KemmioCredit.tsx diff --git a/src/uxcore/components/OffsecBiasView/KemmioCredit.module.scss b/src/uxcore/components/OffsecBiasView/KemmioCredit.module.scss new file mode 100644 index 0000000..4ed95a9 --- /dev/null +++ b/src/uxcore/components/OffsecBiasView/KemmioCredit.module.scss @@ -0,0 +1,211 @@ +$ks-paper: #fbf8f1; +$ks-ink: #1b1e26; +$ks-ink-soft: #4a5060; +$ks-rule: #d8d0bf; +$ks-crimson: #c8412a; +$ks-crimson-deep: #7a2618; + +.credit { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.4; + text-align: right; + color: $ks-ink-soft; + font-style: italic; + font-family: 'Lato', sans-serif; +} + +.kemmioLink { + background: transparent; + border: 0; + padding: 0; + margin: 0; + font: inherit; + color: $ks-crimson; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + + &:hover { + color: $ks-crimson-deep; + } + + &:focus-visible { + outline: 2px solid $ks-crimson; + outline-offset: 2px; + border-radius: 2px; + } +} + +.backdrop { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(15, 17, 22, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + backdrop-filter: blur(3px); + animation: fadeIn 180ms ease-out; +} + +.dialog { + position: relative; + width: 100%; + max-width: 480px; + background: $ks-paper; + border: 1px solid $ks-rule; + border-radius: 10px; + padding: 24px 28px 22px; + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22); + font-family: 'Lato', sans-serif; + color: $ks-ink; + animation: popIn 200ms cubic-bezier(0.22, 0.95, 0.35, 1); +} + +.closeBtn { + position: absolute; + top: 8px; + right: 10px; + background: transparent; + border: 0; + padding: 4px 10px 6px; + font-size: 22px; + line-height: 1; + color: $ks-ink-soft; + cursor: pointer; + + &:hover { + color: $ks-crimson; + } +} + +.title { + margin: 0 0 6px; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.005em; +} + +.realName { + font-weight: 400; + font-size: 14px; + color: $ks-ink-soft; +} + +.lead { + margin: 0 0 14px; + font-size: 14px; + line-height: 1.55; + + strong { + color: $ks-crimson; + font-weight: 700; + } +} + +.facts { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 10px; + + li { + position: relative; + padding-left: 18px; + font-size: 13.5px; + line-height: 1.5; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0.7em; + width: 10px; + height: 1px; + background: $ks-crimson; + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes popIn { + from { + opacity: 0; + transform: scale(0.97) translateY(4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +:global(body.darkTheme) { + $dk-card: #1b1f29; + $dk-rule: #2f3441; + $dk-text: #e6e8ee; + $dk-text-soft: #a5acba; + $dk-crimson: #e57254; + + .credit { + color: $dk-text-soft; + } + + .kemmioLink { + color: $dk-crimson; + + &:hover { + color: lighten($dk-crimson, 8%); + } + } + + .backdrop { + background: rgba(0, 0, 0, 0.65); + } + + .dialog { + background: $dk-card; + border-color: $dk-rule; + color: $dk-text; + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.55); + } + + .closeBtn { + color: $dk-text-soft; + + &:hover { + color: $dk-crimson; + } + } + + .realName { + color: $dk-text-soft; + } + + .lead { + color: $dk-text; + + strong { + color: $dk-crimson; + } + } + + .facts li { + color: $dk-text; + + &::before { + background: $dk-crimson; + } + } +} diff --git a/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx b/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx new file mode 100644 index 0000000..46d6423 --- /dev/null +++ b/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import styles from './KemmioCredit.module.scss'; + +const KemmioCredit = () => { + const [open, setOpen] = useState(false); + + const handleClose = useCallback(() => setOpen(false), []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + // Capture phase + stopImmediatePropagation so the parent bias modal + // doesn't also close on the same Escape press. + e.stopPropagation(); + if (typeof e.stopImmediatePropagation === 'function') { + e.stopImmediatePropagation(); + } + setOpen(false); + }; + document.addEventListener('keydown', onKey, true); + return () => document.removeEventListener('keydown', onKey, true); + }, [open]); + + const portalTarget = typeof document !== 'undefined' ? document.body : null; + + return ( + <> +

+ Examples co-authored with{' '} + + . +

+ + {open && + portalTarget && + createPortal( +
+
e.stopPropagation()} + > + + +

+ kemmio + — Vahe Karapetyan 🇦🇲 +

+ +

+ 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. +
  • +
+
+
, + portalTarget, + )} + + ); +}; + +export default KemmioCredit; diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index a571dae..bbf6329 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -1,5 +1,7 @@ import { OffsecBiasContent } from '@uxcore/data/biasOffsec'; +import KemmioCredit from './KemmioCredit'; + import styles from './OffsecBiasView.module.scss'; interface OffsecBiasViewProps { @@ -83,6 +85,8 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => { ))}
+ +
); }; From 14448f3ccc113b1e7a6115ca49693bc7ec6b7dcb Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 23:12:03 +0000 Subject: [PATCH 31/51] feat(ai-atlas): tighten Multimove satellite cluster; add SeoGeoSolver project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas changes: - Optional childrenArc field on a project independently governs the angular spread of its satellite children, separate from territoryArc (which drives the territory band backdrop). Multimove now uses childrenArc: 36 so Orchestrator, Telegram, LinkedIn, Twitter and Medium read as a cluster instead of a 70° fan. - New project entity SeoGeoSolver at theta=45 (between Multimove and elea) with its Engineering Lead chip. Workspace lives at /workspace/seo-geo-solved; current CLAUDE.md size (148 lines) surfaced in the dossier description. EN + RU data mirrored. Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 48 ++++++++++++++++++++++++++++++++++++ public/ai-atlas/data.json | 48 ++++++++++++++++++++++++++++++++++++ src/pages/ai-atlas.tsx | 9 ++++--- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index 979995b..6189a4b 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -101,6 +101,7 @@ "status": "ok", "leadDiamond": "blue", "territoryArc": 70, + "childrenArc": 36, "children": [ { "id": "orchestrator", @@ -193,6 +194,20 @@ ], "territoryLabel": "B2B SAAS" }, + { + "id": "seogeosolved", + "label": "SeoGeoSolver", + "sub": "seo + geo", + "diamond": "red", + "theta": 45, + "status": "ok", + "leadDiamond": "blue", + "leadDeg": 6, + "leadR": 0.54, + "territoryArc": 0, + "children": [], + "territoryLabel": "" + }, { "id": "keepsimple", "label": "KeepSimple", @@ -508,6 +523,39 @@ ] }, + "seogeosolved": { + "title": "SEOGEOSOLVER", + "cjk": "索", + "desc": "Мастерская по поисковой и генеративной оптимизации. Инструменты и аудиты для нового слоя ранжирования — где вопрос не «есть ли ты в Google», а «цитирует ли тебя модель». Рабочая папка /workspace/seo-geo-solved · CLAUDE.md сейчас 148 строк.", + "rows": [ + { "k": "тип", "v": "продукт", "cls": "red" }, + { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "лид", + "v": "ИИ инженерный агент", + "cls": "blue", + "ref": "lead-seogeosolved" + }, + { "k": "владеет", "v": "seo + geo территория" } + ] + }, + + "lead-seogeosolved": { + "title": "ТЕХНИЧЕСКИЙ ЛИД · SEOGEOSOLVER", + "cjk": "長", + "desc": "Технический лид, прикреплён к проекту SeoGeoSolver.", + "rows": [ + { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, + { "k": "роль", "v": "технический лид" }, + { + "k": "полномочия", + "v": "кодовая база seo-geo-solved · поисковые и GEO эксперименты" + }, + { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, + { "k": "кольцо", "v": "III — ключевые продукты" } + ] + }, + "lead-elea": { "title": "ТЕХНИЧЕСКИЙ ЛИД · ELEA", "cjk": "長", diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index 9c1782c..8ff98d3 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -101,6 +101,7 @@ "status": "ok", "leadDiamond": "blue", "territoryArc": 70, + "childrenArc": 36, "children": [ { "id": "orchestrator", @@ -193,6 +194,20 @@ ], "territoryLabel": "B2B SAAS" }, + { + "id": "seogeosolved", + "label": "SeoGeoSolver", + "sub": "seo + geo", + "diamond": "red", + "theta": 45, + "status": "ok", + "leadDiamond": "blue", + "leadDeg": 6, + "leadR": 0.54, + "territoryArc": 0, + "children": [], + "territoryLabel": "" + }, { "id": "keepsimple", "label": "KeepSimple", @@ -500,6 +515,39 @@ ] }, + "seogeosolved": { + "title": "SEOGEOSOLVER", + "cjk": "索", + "desc": "Search-engine + generative-engine optimization workshop. Tools and audits that move pages on the new ranking layer where the question is no longer “is this on Google” but “does the model cite this.” Workspace at /workspace/seo-geo-solved · CLAUDE.md currently 148 lines.", + "rows": [ + { "k": "kind", "v": "product", "cls": "red" }, + { "k": "ring", "v": "III — core products" }, + { + "k": "lead", + "v": "AI engineering agent", + "cls": "blue", + "ref": "lead-seogeosolved" + }, + { "k": "owns", "v": "seo + geo territory" } + ] + }, + + "lead-seogeosolved": { + "title": "ENGINEERING LEAD · SEOGEOSOLVER", + "cjk": "長", + "desc": "Engineering lead attached to the SeoGeoSolver project.", + "rows": [ + { "k": "kind", "v": "ai agent", "cls": "blue" }, + { "k": "role", "v": "engineering lead" }, + { + "k": "authority", + "v": "seo-geo-solved codebase · search/GEO experiments" + }, + { "k": "reports", "v": "wolf", "cls": "gold" }, + { "k": "ring", "v": "III — core products" } + ] + }, + "lead-elea": { "title": "ENGINEERING LEAD · ELEA", "cjk": "長", diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 84d723c..38896c2 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -1638,10 +1638,13 @@ function AiAtlasApp() { data.projects.members.forEach((p: any) => { const n = p.children.length; if (!n) return; - const half = p.territoryArc / 2; + // childrenArc (if set) governs the angular spread of child entities + // independently of territoryArc (which drives the territory band + // backdrop). Lets the band stay wide while keeping satellites tight. + const arc = p.childrenArc != null ? p.childrenArc : p.territoryArc; + const half = arc / 2; p.children.forEach((c: any, i: number) => { - const t = - n === 1 ? p.theta : p.theta - half + i * (p.territoryArc / (n - 1)); + const t = n === 1 ? p.theta : p.theta - half + i * (arc / (n - 1)); const r = c.external ? data.territoryR + 0.1 : data.territoryR; const pos = POL(r, t); m[c.id] = { ...pos, ring: 'territories', node: c, parent: p.id }; From 7d873c28211d0555d9e05dd38116b105752c7f1c Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 23:15:27 +0000 Subject: [PATCH 32/51] =?UTF-8?q?fix(uxcore):=20kemmio=20popup=20=E2=80=94?= =?UTF-8?q?=20real=20AM=20flag,=20tagline,=20clickable=20Hexens,=20Solidit?= =?UTF-8?q?y=20TSTORE=20bullet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the broken AM-fallback emoji with an inline SVG of the Armenian tricolor (renders identically across all browsers/fonts). - Add italic tagline under his real name: "Among the most consequential whitehat hackers alive." - Wrap Hexens in an external-tab link to hexens.io; styling unchanged (inherits the crimson strong colour). - Add a third fact: first critical Solidity compiler vulnerability in over a decade — the TSTORE poison bug, with link to the Hexens research write-up. Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/KemmioCredit.module.scss | 73 +++++++++++++++++++ .../OffsecBiasView/KemmioCredit.tsx | 65 ++++++++++++++++- 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/KemmioCredit.module.scss b/src/uxcore/components/OffsecBiasView/KemmioCredit.module.scss index 4ed95a9..e8197c2 100644 --- a/src/uxcore/components/OffsecBiasView/KemmioCredit.module.scss +++ b/src/uxcore/components/OffsecBiasView/KemmioCredit.module.scss @@ -94,6 +94,28 @@ $ks-crimson-deep: #7a2618; color: $ks-ink-soft; } +.flagAM { + display: inline-block; + vertical-align: -2px; + margin-left: 2px; + line-height: 0; + border: 1px solid rgba(27, 30, 38, 0.18); + border-radius: 1.5px; + overflow: hidden; + + svg { + display: block; + } +} + +.tagline { + margin: 6px 0 14px; + font-size: 13px; + line-height: 1.45; + font-style: italic; + color: $ks-ink-soft; +} + .lead { margin: 0 0 14px; font-size: 14px; @@ -105,6 +127,41 @@ $ks-crimson-deep: #7a2618; } } +.hexensLink { + color: inherit; + text-decoration: none; + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.78; + } + + &:focus-visible { + outline: 2px solid $ks-crimson; + outline-offset: 2px; + border-radius: 2px; + } +} + +.researchLink { + color: $ks-crimson; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; + cursor: pointer; + + &:hover { + color: $ks-crimson-deep; + } + + &:focus-visible { + outline: 2px solid $ks-crimson; + outline-offset: 2px; + border-radius: 2px; + } +} + .facts { margin: 0; padding: 0; @@ -193,6 +250,14 @@ $ks-crimson-deep: #7a2618; color: $dk-text-soft; } + .flagAM { + border-color: rgba(230, 232, 238, 0.25); + } + + .tagline { + color: $dk-text-soft; + } + .lead { color: $dk-text; @@ -201,6 +266,14 @@ $ks-crimson-deep: #7a2618; } } + .researchLink { + color: $dk-crimson; + + &:hover { + color: lighten($dk-crimson, 8%); + } + } + .facts li { color: $dk-text; diff --git a/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx b/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx index 46d6423..66f4404 100644 --- a/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx +++ b/src/uxcore/components/OffsecBiasView/KemmioCredit.tsx @@ -66,12 +66,58 @@ const KemmioCredit = () => {

kemmio - — Vahe Karapetyan 🇦🇲 + + {' '} + — Vahe Karapetyan{' '} + + + + + + + +

+

+ Among the most consequential whitehat hackers alive. +

+

- Co-founder of Hexens — the cybersecurity firm - whose audits have safeguarded over $125B in assets. + Co-founder of{' '} + + Hexens + {' '} + — the cybersecurity firm whose audits have safeguarded over + $125B in assets.

    @@ -85,6 +131,19 @@ const KemmioCredit = () => { $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 + + . +
, From 9543949cadcb34659bb551f83ab1ce77e24f325f Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 23:22:05 +0000 Subject: [PATCH 33/51] fix(ai-atlas): pull SeoGeoSolver into Multimove cluster; uniform claude.md row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SeoGeoSolver moves from theta=45 to theta=26 (inside Multimove's CONTENT · PR territory band) and uses default lead positioning so its Eng. Lead chip sits below it the same way Multimove's does. Treated as a peer/partner of Multimove inside the content+PR wing. - Multimove children further tightened (childrenArc 36 -> 28) so the satellite stack reads as a unit. - Static claudeMdLines field on dossiers now feeds the same structured "claude.md" row as the live metrics endpoint (live overrides static when present). Drops the awkward prose mention from the SeoGeoSolver description; the 148-line CLAUDE.md surfaces in the lead dossier in the same format used for every other Eng. Lead. EN + RU mirrored. Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 11 +++++------ public/ai-atlas/data.json | 11 +++++------ src/pages/ai-atlas.tsx | 11 +++++++++-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index 6189a4b..23d5d02 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -101,7 +101,7 @@ "status": "ok", "leadDiamond": "blue", "territoryArc": 70, - "childrenArc": 36, + "childrenArc": 28, "children": [ { "id": "orchestrator", @@ -199,11 +199,9 @@ "label": "SeoGeoSolver", "sub": "seo + geo", "diamond": "red", - "theta": 45, + "theta": 26, "status": "ok", "leadDiamond": "blue", - "leadDeg": 6, - "leadR": 0.54, "territoryArc": 0, "children": [], "territoryLabel": "" @@ -526,7 +524,7 @@ "seogeosolved": { "title": "SEOGEOSOLVER", "cjk": "索", - "desc": "Мастерская по поисковой и генеративной оптимизации. Инструменты и аудиты для нового слоя ранжирования — где вопрос не «есть ли ты в Google», а «цитирует ли тебя модель». Рабочая папка /workspace/seo-geo-solved · CLAUDE.md сейчас 148 строк.", + "desc": "Мастерская по поисковой и генеративной оптимизации. Инструменты и аудиты для нового слоя ранжирования — где вопрос не «есть ли ты в Google», а «цитирует ли тебя модель». В паре с Multimove внутри контент/PR-направления.", "rows": [ { "k": "тип", "v": "продукт", "cls": "red" }, { "k": "кольцо", "v": "III — ключевые продукты" }, @@ -536,7 +534,7 @@ "cls": "blue", "ref": "lead-seogeosolved" }, - { "k": "владеет", "v": "seo + geo территория" } + { "k": "партнёр", "v": "multimove", "cls": "red", "ref": "multimove" } ] }, @@ -544,6 +542,7 @@ "title": "ТЕХНИЧЕСКИЙ ЛИД · SEOGEOSOLVER", "cjk": "長", "desc": "Технический лид, прикреплён к проекту SeoGeoSolver.", + "claudeMdLines": 148, "rows": [ { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, { "k": "роль", "v": "технический лид" }, diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index 8ff98d3..cbb2bd6 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -101,7 +101,7 @@ "status": "ok", "leadDiamond": "blue", "territoryArc": 70, - "childrenArc": 36, + "childrenArc": 28, "children": [ { "id": "orchestrator", @@ -199,11 +199,9 @@ "label": "SeoGeoSolver", "sub": "seo + geo", "diamond": "red", - "theta": 45, + "theta": 26, "status": "ok", "leadDiamond": "blue", - "leadDeg": 6, - "leadR": 0.54, "territoryArc": 0, "children": [], "territoryLabel": "" @@ -518,7 +516,7 @@ "seogeosolved": { "title": "SEOGEOSOLVER", "cjk": "索", - "desc": "Search-engine + generative-engine optimization workshop. Tools and audits that move pages on the new ranking layer where the question is no longer “is this on Google” but “does the model cite this.” Workspace at /workspace/seo-geo-solved · CLAUDE.md currently 148 lines.", + "desc": "Search-engine + generative-engine optimization workshop. Tools and audits that move pages on the new ranking layer where the question is no longer “is this on Google” but “does the model cite this.” Partners with Multimove inside the content / PR wing.", "rows": [ { "k": "kind", "v": "product", "cls": "red" }, { "k": "ring", "v": "III — core products" }, @@ -528,7 +526,7 @@ "cls": "blue", "ref": "lead-seogeosolved" }, - { "k": "owns", "v": "seo + geo territory" } + { "k": "partner", "v": "multimove", "cls": "red", "ref": "multimove" } ] }, @@ -536,6 +534,7 @@ "title": "ENGINEERING LEAD · SEOGEOSOLVER", "cjk": "長", "desc": "Engineering lead attached to the SeoGeoSolver project.", + "claudeMdLines": 148, "rows": [ { "k": "kind", "v": "ai agent", "cls": "blue" }, { "k": "role", "v": "engineering lead" }, diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 38896c2..7bb36e2 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -1678,11 +1678,18 @@ function AiAtlasApp() { : focusedDossier || buildIntroDossier(data, now, t); /* When a focused entity has a CLAUDE.md count from the metrics feed, - append it as the last row of the dossier. */ - const claudeLines = + append it as the last row of the dossier. Falls back to a static + `claudeMdLines` on the dossier itself when the metrics endpoint + doesn't (yet) know about the entity — keeps the row format uniform. */ + const metricsLines = focusId && metrics?.claudeMdLines?.[focusId] != null ? metrics.claudeMdLines[focusId] : null; + const staticLines = + focusedDossier && typeof (focusedDossier as any).claudeMdLines === 'number' + ? (focusedDossier as any).claudeMdLines + : null; + const claudeLines = metricsLines != null ? metricsLines : staticLines; if (claudeLines != null) { dossier = { ...dossier, From 93c600ec04396fd31714f2bfc78a826170770194 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 23:24:31 +0000 Subject: [PATCH 34/51] fix(ai-atlas): drop "Welcome to" prefix from the atlas header banner Banner reads "The heart of KeepSimple Team's operations" now (RU mirrored). Same all-caps treatment via existing CSS. Co-Authored-By: Claude Opus 4.7 --- src/pages/ai-atlas.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 7bb36e2..5537b12 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -71,7 +71,7 @@ const STRINGS = { ogImageAlt: 'AI Atlas — orbital map of KeepSimple operations', loading: 'Loading…', failedToLoad: 'Failed to load data — ', - welcomeBanner: "Welcome to the heart of KeepSimple Team's operations", + welcomeBanner: "The heart of KeepSimple Team's operations", day: 'DAY', daySinceTail: 'since the beginning of our movement', apexFounderFallback: 'founder', @@ -281,7 +281,7 @@ const STRINGS = { ogImageAlt: 'ИИ Атлас — орбитальная карта операций KeepSimple', loading: 'Загрузка…', failedToLoad: 'Ошибка загрузки данных — ', - welcomeBanner: 'Добро пожаловать в сердце операций команды KeepSimple', + welcomeBanner: 'Сердце операций команды KeepSimple', day: 'ДЕНЬ', daySinceTail: 'с начала нашего движения', apexFounderFallback: 'основатель', From 527d5c75c539e917d939f56d6536c173c315d5af Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 23:44:57 +0000 Subject: [PATCH 35/51] feat(uxcore): OffSec examples for biases #2 (attentional) and #3 (illusory truth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new entries that follow the same structure as availability-heuristics: scenario, two side-by-side email-style cards (with the bias-exploiting side flagged), why-it-works paragraph, and a four-move Protect-yourself list keyed to the bias mechanism. - Attentional bias: a loud "URGENT sign-in from Moscow" decoy beside a quiet "updated wire details" ask one minute earlier. The bias mechanism is attention budget — the noisy one is bait, the quiet one is the attack. Defenses: scan the same window after the loud event, never trust an alert's link, decoys travel in pairs. - Illusory truth effect: a cold "vendor banking update" ask vs. the same content reframed as a third touch ("Re: Re: ... as mentioned last week"). Two prior friendly notes were a deposit into your credibility account; the third is the withdrawal. Defenses: thread length is not verification, treat first money/credential ask as new no matter how familiar, cross-check sender against external contacts. Plus a small refactor: OffsecBiasContent moved out of availabilityHeuristics.ts into a dedicated types.ts so the type now defines attachment/flagged as truly optional and the same shape applies across all 105 future biases without each file needing to mirror every field of the first one. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/data/biasOffsec/attentionalBias.ts | 46 +++++++++++++++++++ .../data/biasOffsec/availabilityHeuristics.ts | 5 +- .../data/biasOffsec/illusoryTruthEffect.ts | 46 +++++++++++++++++++ src/uxcore/data/biasOffsec/index.ts | 9 ++-- src/uxcore/data/biasOffsec/types.ts | 25 ++++++++++ 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 src/uxcore/data/biasOffsec/attentionalBias.ts create mode 100644 src/uxcore/data/biasOffsec/illusoryTruthEffect.ts create mode 100644 src/uxcore/data/biasOffsec/types.ts diff --git a/src/uxcore/data/biasOffsec/attentionalBias.ts b/src/uxcore/data/biasOffsec/attentionalBias.ts new file mode 100644 index 0000000..3966f44 --- /dev/null +++ b/src/uxcore/data/biasOffsec/attentionalBias.ts @@ -0,0 +1,46 @@ +// No quoted figures in this file by policy (see +// feedback_offsec_no_mocked_numbers). The pattern — paired noisy decoy +// plus quiet real ask — is a long-documented social-engineering +// technique; the specific success rate is not the point. + +import type { OffsecBiasContent } from './types'; + +const content: OffsecBiasContent = { + scenario: + 'Two emails arrive in the same minute. One is loud and demands you act now. The other is quiet and looks routine. Your attention has a budget — the attacker chose where to spend it.', + visualLabel: 'Scenario', + visual: { + before: { + tag: 'Loud decoy', + sender: 'security-alert@acme-corp-mail.com', + timestamp: 'Wed, 2:13 PM', + subject: 'URGENT: sign-in from Moscow — confirm or lock account', + preview: + 'We detected an unauthorized sign-in attempt. Review and lock your account before further damage.', + flagged: true, + }, + after: { + tag: 'Quiet ask', + sender: 'ap@acme-vendor.com', + timestamp: 'Wed, 2:14 PM', + subject: 'Updated wire details for May invoices', + preview: + 'Heads up — our bank changed last week. New routing and account below. Same totals, same schedule.', + }, + }, + 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 “urgent” captures the budget; a quiet bank-detail change does not. So you triage the decoy, feel responsible, and never quite see the small one a minute earlier. Two emails 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 the inbox 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 bank details deserves a fresh out-of-band confirmation — even if it looks routine and especially when you’re mid-fire on something else.', + 'Treat any “urgent sign-in alert” as a question, not an instruction. Open your account from a bookmark or app — not the link in the email — and check the actual session list yourself.', + 'After you’ve 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 index 79d311b..62b4bc1 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -4,7 +4,9 @@ // pattern — that topical, news-anchored lures outperform generic ones — // is well documented; the specific lift is not the point of the page. -const content = { +import type { OffsecBiasContent } from './types'; + +const content: OffsecBiasContent = { scenario: 'A major company just got breached and the news is everywhere. The next morning, an email lands in your inbox — looks like a vendor you trust, anchored to the breach you just read about.', visualLabel: 'Scenario', @@ -43,4 +45,3 @@ const content = { }; export default content; -export type OffsecBiasContent = typeof content; diff --git a/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts b/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts new file mode 100644 index 0000000..b2bcfd4 --- /dev/null +++ b/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts @@ -0,0 +1,46 @@ +// No quoted figures by policy (see feedback_offsec_no_mocked_numbers). +// The pattern — multi-touch grooming that converts a cold sender into +// a familiar one before the ask — is a documented BEC technique; the +// specific lift over single-shot phishing is not the point of the page. + +import type { OffsecBiasContent } from './types'; + +const content: OffsecBiasContent = { + scenario: + 'A new sender spent two weeks softly introducing themselves — small notes, no asks. By week three, when they finally request a wire change, the name in your inbox already feels familiar enough to trust.', + visualLabel: 'Scenario', + visual: { + before: { + tag: 'Cold ask', + sender: 'k.lange@acme-supplier.io', + timestamp: 'Thu, 9:30 AM', + subject: 'Vendor banking update', + preview: + 'Hello — I’m Klaus from Acme Supplier finance. We’ve changed our account details, please update before the next payment run.', + }, + after: { + tag: 'Third touch', + sender: 'k.lange@acme-supplier.io', + timestamp: 'Thu, 9:30 AM', + subject: 'Re: Re: quick housekeeping — banking update', + preview: + '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 sender’s name, it felt new and needed scrutiny. By the third touch, processing is cheap; cheap feels familiar; familiar feels true. The two prior emails 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. “Re: Re:” in a subject does not mean someone trustworthy sent the first one — attackers reply to themselves to fake history.', + 'Any time a sender first asks for money, credentials, or bank details, treat them as new — no matter how familiar the inbox makes them feel. Verify out of band, every time, even on the fifth email.', + 'Watch for relationship-builders that don’t ask for anything. Three friendly notes in a row from someone you have never met outside this inbox is a pattern, not a coincidence.', + 'Cross-check the sender against contacts you have elsewhere — Slack, CRM, a signed contract. If they only exist inside this email thread, the familiarity is staged.', + ], + }, +}; + +export default content; diff --git a/src/uxcore/data/biasOffsec/index.ts b/src/uxcore/data/biasOffsec/index.ts index 7d00e90..d7df00c 100644 --- a/src/uxcore/data/biasOffsec/index.ts +++ b/src/uxcore/data/biasOffsec/index.ts @@ -1,10 +1,13 @@ import { biases } from '../biasList/biases'; -import availabilityHeuristics, { - OffsecBiasContent, -} from './availabilityHeuristics'; +import attentionalBias from './attentionalBias'; +import availabilityHeuristics from './availabilityHeuristics'; +import illusoryTruthEffect from './illusoryTruthEffect'; +import type { OffsecBiasContent } from './types'; const offsecBySlug: Record = { 'availability-heuristics': availabilityHeuristics, + 'attentional-bias': attentionalBias, + 'illusory-truth-effect': illusoryTruthEffect, }; export const getOffsecBiasContent = ( diff --git a/src/uxcore/data/biasOffsec/types.ts b/src/uxcore/data/biasOffsec/types.ts new file mode 100644 index 0000000..631debc --- /dev/null +++ b/src/uxcore/data/biasOffsec/types.ts @@ -0,0 +1,25 @@ +export interface OffsecBiasCard { + tag: string; + sender: string; + timestamp?: string; + subject: string; + preview: string; + attachment?: string; + flagged?: boolean; +} + +export interface OffsecBiasContent { + scenario: string; + visualLabel: string; + visual: { + before: OffsecBiasCard; + after: OffsecBiasCard; + }; + whyItWorksLabel: string; + whyItWorks: string; + defenseLabel: string; + defense: { + lede: string; + moves: string[]; + }; +} From a98c02d5b2151830222a12cdf9d8f7fb1f9811d5 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 21 May 2026 23:54:08 +0000 Subject: [PATCH 36/51] =?UTF-8?q?fix(uxcore):=20diversify=20OffSec=20surfa?= =?UTF-8?q?ces=20=E2=80=94=20bias=20#2=20now=20notifications,=20#3=20chat?= =?UTF-8?q?=20DMs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email was overused for the first three examples. Generalised the visual chassis with a discriminated card kind so each bias picks the surface that matches its actual attack vector. Email stays for availability (news-anchored phishing IS email-shaped); attentional bias #2 pivots to two competing push notifications (Microsoft Defender "unauthorised sign-in" decoy vs Wire Approvals "bank details changed" quiet ask); illusory truth #3 pivots to a LinkedIn-style chat (cold ask vs same message reframed as the third touch with a "2 messages this month" context line). Future biases (browser alerts, OS dialogs, voice calls, etc.) can add their own card kind to types.ts without breaking the existing two. Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/OffsecBiasView.module.scss | 166 ++++++++++++++++++ .../OffsecBiasView/OffsecBiasView.tsx | 114 ++++++++---- src/uxcore/data/biasOffsec/attentionalBias.ts | 40 ++--- .../data/biasOffsec/availabilityHeuristics.ts | 2 + .../data/biasOffsec/illusoryTruthEffect.ts | 37 ++-- src/uxcore/data/biasOffsec/index.ts | 4 +- src/uxcore/data/biasOffsec/types.ts | 40 ++++- 7 files changed, 327 insertions(+), 76 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss index 1942418..4834f2b 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -197,6 +197,138 @@ $ks-crimson-deep: #7a2618; } } +// === Notification surface ================================================== + +.card_notification { + gap: 8px; + + .notifHeader { + display: flex; + align-items: center; + gap: 8px; + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 10.5px; + letter-spacing: 0.04em; + color: $ks-ink-soft; + } + + .notifAppIcon { + width: 16px; + height: 16px; + border-radius: 4px; + background: linear-gradient(135deg, $ks-crimson, $ks-crimson-deep); + flex-shrink: 0; + } + + .notifAppName { + text-transform: uppercase; + font-weight: 600; + flex: 1; + } + + .notifTimestamp { + opacity: 0.75; + } + + .notifTitle { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 700; + line-height: 1.3; + color: $ks-ink; + } + + .notifBody { + font-size: 12.5px; + line-height: 1.45; + color: $ks-ink-soft; + } +} + +// === Chat surface ========================================================== + +.card_chat { + gap: 10px; + + .chatHeader { + display: flex; + align-items: center; + gap: 10px; + } + + .chatAvatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: $ks-crimson; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Lato', sans-serif; + font-weight: 700; + font-size: 13px; + flex-shrink: 0; + } + + .chatIdentity { + display: flex; + flex-direction: column; + line-height: 1.15; + flex: 1; + min-width: 0; + } + + .chatSenderName { + font-size: 13.5px; + font-weight: 700; + color: $ks-ink; + } + + .chatSenderHandle { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 11px; + color: $ks-ink-soft; + margin-top: 2px; + } + + .chatTimestamp { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 10.5px; + color: $ks-ink-soft; + opacity: 0.75; + flex-shrink: 0; + } + + .chatPrior { + font-size: 11.5px; + font-style: italic; + color: $ks-ink-soft; + padding-left: 4px; + } + + .chatBubble { + display: flex; + gap: 6px; + align-items: flex-start; + background: $ks-paper; + border: 1px solid $ks-rule; + border-radius: 12px; + border-top-left-radius: 4px; + padding: 10px 12px; + font-size: 13px; + line-height: 1.5; + color: $ks-ink; + } +} + +.card_chat.cardFlagged .chatBubble { + background: rgba(200, 65, 42, 0.08); + border-color: $ks-crimson; +} + .cardDivider { display: flex; align-items: center; @@ -378,6 +510,40 @@ $ks-crimson-deep: #7a2618; } } + .card_notification { + .notifHeader, + .notifBody { + color: $dk-text-soft; + } + + .notifTitle { + color: $dk-text; + } + } + + .card_chat { + .chatSenderName { + color: $dk-text; + } + + .chatSenderHandle, + .chatTimestamp, + .chatPrior { + color: $dk-text-soft; + } + + .chatBubble { + background: $dk-card-soft; + border-color: $dk-rule; + color: $dk-text; + } + } + + .card_chat.cardFlagged .chatBubble { + background: rgba(229, 114, 84, 0.14); + border-color: $dk-crimson; + } + .cardDivider .cardArrow { color: $dk-crimson; } diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index bbf6329..1c51f04 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -1,4 +1,4 @@ -import { OffsecBiasContent } from '@uxcore/data/biasOffsec'; +import { OffsecBiasCard, OffsecBiasContent } from '@uxcore/data/biasOffsec'; import KemmioCredit from './KemmioCredit'; @@ -8,6 +8,79 @@ interface OffsecBiasViewProps { content: OffsecBiasContent; } +const CardBody = ({ card }: { card: OffsecBiasCard }) => { + if (card.kind === 'email') { + return ( + <> +
+ {card.sender} + {card.timestamp && ( + {card.timestamp} + )} +
+
+ {card.flagged && } + {card.subject} +
+
+
{card.preview}
+ {card.attachment && ( +
+ 📎 + {card.attachment} +
+ )} + + ); + } + + if (card.kind === 'notification') { + return ( + <> +
+
+
+ {card.flagged && } + {card.title} +
+
{card.body}
+ + ); + } + + // kind === 'chat' + return ( + <> +
+ +
+ {card.senderName} + {card.senderHandle && ( + {card.senderHandle} + )} +
+ {card.timestamp && ( + {card.timestamp} + )} +
+ {card.priorContext && ( +
↳ {card.priorContext}
+ )} +
+ {card.flagged && } + {card.body} +
+ + ); +}; + const OffsecBiasView = ({ content }: OffsecBiasViewProps) => { const { before, after } = content.visual; @@ -20,24 +93,8 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => {
{before.tag} -
-
- {before.sender} - {before.timestamp && ( - - {before.timestamp} - - )} -
-
{before.subject}
-
-
{before.preview}
- {before.attachment && ( -
- 📎 - {before.attachment} -
- )} +
+
@@ -51,21 +108,10 @@ const OffsecBiasView = ({ content }: OffsecBiasViewProps) => { > {after.tag} -
-
- {after.sender} - {after.timestamp && ( - - {after.timestamp} - - )} -
-
- - {after.subject} -
-
-
{after.preview}
+
+
diff --git a/src/uxcore/data/biasOffsec/attentionalBias.ts b/src/uxcore/data/biasOffsec/attentionalBias.ts index 3966f44..78f18b6 100644 --- a/src/uxcore/data/biasOffsec/attentionalBias.ts +++ b/src/uxcore/data/biasOffsec/attentionalBias.ts @@ -1,44 +1,44 @@ -// No quoted figures in this file by policy (see -// feedback_offsec_no_mocked_numbers). The pattern — paired noisy decoy -// plus quiet real ask — is a long-documented social-engineering -// technique; the specific success rate is not the point. +// No quoted figures by policy. Surface here is push notifications, not +// email — attentional-bias attacks land wherever multiple things compete +// for your eyes (lock-screen, OS toasts, browser pop-ups), and the +// mechanism is unrelated to the inbox. import type { OffsecBiasContent } from './types'; const content: OffsecBiasContent = { scenario: - 'Two emails arrive in the same minute. One is loud and demands you act now. The other is quiet and looks routine. Your attention has a budget — the attacker chose where to spend it.', + 'Two notifications arrive on your phone within the same minute. 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', - sender: 'security-alert@acme-corp-mail.com', - timestamp: 'Wed, 2:13 PM', - subject: 'URGENT: sign-in from Moscow — confirm or lock account', - preview: - 'We detected an unauthorized sign-in attempt. Review and lock your account before further damage.', + 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: 'notification', tag: 'Quiet ask', - sender: 'ap@acme-vendor.com', - timestamp: 'Wed, 2:14 PM', - subject: 'Updated wire details for May invoices', - preview: - 'Heads up — our bank changed last week. New routing and account below. Same totals, same schedule.', + appName: 'Wire Approvals', + timestamp: '1 min ago', + title: 'Acme Vendor updated their bank details', + body: '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 “urgent” captures the budget; a quiet bank-detail change does not. So you triage the decoy, feel responsible, and never quite see the small one a minute earlier. Two emails arrived; one paid the attacker.', + '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 the inbox 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 bank details deserves a fresh out-of-band confirmation — even if it looks routine and especially when you’re mid-fire on something else.', - 'Treat any “urgent sign-in alert” as a question, not an instruction. Open your account from a bookmark or app — not the link in the email — and check the actual session list yourself.', - 'After you’ve handled the noisy one, do one more sweep: anything else from that hour that asked you to do something? Decoys travel in pairs.', + '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.', ], }, }; diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index 62b4bc1..717706f 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -12,6 +12,7 @@ const content: OffsecBiasContent = { visualLabel: 'Scenario', visual: { before: { + kind: 'email', tag: 'Generic', sender: 'billing@acme-vendor.com', timestamp: 'Mon, 10:42 AM', @@ -20,6 +21,7 @@ const content: OffsecBiasContent = { attachment: 'invoice-Q3.pdf', }, after: { + kind: 'email', tag: 'News-anchored', sender: 'security@acme-vendor.com', timestamp: 'Tue, 08:17 AM', diff --git a/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts b/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts index b2bcfd4..a4c7001 100644 --- a/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts +++ b/src/uxcore/data/biasOffsec/illusoryTruthEffect.ts @@ -1,44 +1,45 @@ -// No quoted figures by policy (see feedback_offsec_no_mocked_numbers). -// The pattern — multi-touch grooming that converts a cold sender into -// a familiar one before the ask — is a documented BEC technique; the -// specific lift over single-shot phishing is not the point of the page. +// 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 sender spent two weeks softly introducing themselves — small notes, no asks. By week three, when they finally request a wire change, the name in your inbox already feels familiar enough to trust.', + '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', - sender: 'k.lange@acme-supplier.io', + senderName: 'Klaus Lange', + senderHandle: 'Acme Supplier · finance', timestamp: 'Thu, 9:30 AM', - subject: 'Vendor banking update', - preview: - 'Hello — I’m Klaus from Acme Supplier finance. We’ve changed our account details, please update before the next payment run.', + 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', - sender: 'k.lange@acme-supplier.io', + senderName: 'Klaus Lange', + senderHandle: 'Acme Supplier · finance', timestamp: 'Thu, 9:30 AM', - subject: 'Re: Re: quick housekeeping — banking update', - preview: - '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.', + 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 sender’s name, it felt new and needed scrutiny. By the third touch, processing is cheap; cheap feels familiar; familiar feels true. The two prior emails carried no ask at all — that’s the point. They were a deposit into your credibility account. The third withdraws.', + '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. “Re: Re:” in a subject does not mean someone trustworthy sent the first one — attackers reply to themselves to fake history.', - 'Any time a sender first asks for money, credentials, or bank details, treat them as new — no matter how familiar the inbox makes them feel. Verify out of band, every time, even on the fifth email.', - 'Watch for relationship-builders that don’t ask for anything. Three friendly notes in a row from someone you have never met outside this inbox is a pattern, not a coincidence.', - 'Cross-check the sender against contacts you have elsewhere — Slack, CRM, a signed contract. If they only exist inside this email thread, the familiarity is staged.', + '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.', ], }, }; diff --git a/src/uxcore/data/biasOffsec/index.ts b/src/uxcore/data/biasOffsec/index.ts index d7df00c..f408f34 100644 --- a/src/uxcore/data/biasOffsec/index.ts +++ b/src/uxcore/data/biasOffsec/index.ts @@ -2,7 +2,7 @@ import { biases } from '../biasList/biases'; import attentionalBias from './attentionalBias'; import availabilityHeuristics from './availabilityHeuristics'; import illusoryTruthEffect from './illusoryTruthEffect'; -import type { OffsecBiasContent } from './types'; +import type { OffsecBiasCard, OffsecBiasContent } from './types'; const offsecBySlug: Record = { 'availability-heuristics': availabilityHeuristics, @@ -18,4 +18,4 @@ export const getOffsecBiasContent = ( return offsecBySlug[entry.slug] ?? null; }; -export type { OffsecBiasContent }; +export type { OffsecBiasCard, OffsecBiasContent }; diff --git a/src/uxcore/data/biasOffsec/types.ts b/src/uxcore/data/biasOffsec/types.ts index 631debc..769e35e 100644 --- a/src/uxcore/data/biasOffsec/types.ts +++ b/src/uxcore/data/biasOffsec/types.ts @@ -1,13 +1,49 @@ -export interface OffsecBiasCard { +// 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; - flagged?: boolean; } +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; +} + +export type OffsecBiasCard = + | OffsecBiasEmailCard + | OffsecBiasNotificationCard + | OffsecBiasChatCard; + export interface OffsecBiasContent { scenario: string; visualLabel: string; From 7c149f871efb67da2bb0c956d6d97382c75520d1 Mon Sep 17 00:00:00 2001 From: manager Date: Fri, 22 May 2026 08:23:00 +0000 Subject: [PATCH 37/51] feat(uxcore): real Hexens logo + merge OffSec into PM/HR tab strip Modal tab row is now a single 3-position pill (PM | HR | Cybersecurity) instead of a 2-pill + separate OffSec button below. OffSec slot uses the actual Hexens brand mark served from /public/uxcore/. Co-Authored-By: Claude Opus 4.7 --- public/uxcore/hexens-logo.png | Bin 0 -> 368 bytes src/uxcore/assets/icons/OffSecIcon.tsx | 36 +++----- .../UXCoreModal/UXCoreModal.module.scss | 87 +++++++----------- .../components/UXCoreModal/UXCoreModal.tsx | 50 +++++----- 4 files changed, 66 insertions(+), 107 deletions(-) create mode 100644 public/uxcore/hexens-logo.png diff --git a/public/uxcore/hexens-logo.png b/public/uxcore/hexens-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..907086a543d2481d7e4bf6508fef24bf739141a9 GIT binary patch literal 368 zcmV-$0gwKPP)*3^j#&MK+o+V9F$+Aq6B$3$1P1E!X?)zTaww1ChW8=l0=c&#FB0#up8)5+T zAW&WxZQ(lgcN~Y{w63e(?Q2;U0al0X@;sNOX|#V7fC?Z(1_atWIuFOtilWdvL!^J4 zyROq}81S_fO;Fdh`q@{|0tN_}2PCxPw}Xyk_*gs$tT9G@5RjGf38orAHRzZHdW(Pn zM+le&B@^u7Z3H7j#u*S`1oR4!0VI$jgMHtH!E!zbSShQ;(E^g!Es%wk+U%ah>-k2= zj-LO_qXG>7!1ur)0d{6YqG$p#0~?I|1pom5|AelTi~s-t21!IgR09AUYst)D#YGeV O0000 so the trademark stays pixel-faithful. +// Dark-mode inversion is handled by the consumer's stylesheet via a +// `filter: invert(1) brightness(2)` rule on `body.darkTheme` — +// see UXCoreModal.module.scss for the active-state colouring. +// Two exports kept for parity with the PM/HR icon pair. const HexensMark = () => ( - - {/* top-left bracket */} - - {/* bottom-right bracket */} - - {/* X — two diagonal bars meeting at center */} - - - + ); export const OffSecIcon = () => ; diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index a8063ae..8160199 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -118,7 +118,7 @@ .switcher { position: relative; display: flex; - margin-bottom: 10px; + margin-bottom: 12px; border: 1px solid #e0e0e0; border-radius: 8px; background-color: #fff; @@ -130,25 +130,36 @@ top: -1px; bottom: -1px; left: -1px; - width: calc(50% + 1px); + width: calc(33.3333% + 1px); background-color: #e8f0fb; border: 1px solid #e0e0e0; border-radius: 8px; transform: translateX(0); - transition: transform 450ms cubic-bezier(0.22, 0.95, 0.35, 1); + transition: + transform 450ms cubic-bezier(0.22, 0.95, 0.35, 1), + background-color 250ms ease, + border-color 250ms ease; will-change: transform; pointer-events: none; z-index: 0; } &:has(.activeHr)::before { - transform: translateX(calc(100% - 2px)); + transform: translateX(calc(100% - 1px)); + } + + // 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)); + background-color: #fdecea; + border-color: #c8412a; } .switcherItem { position: relative; z-index: 1; - width: 50%; + width: 33.3333%; text-align: center; height: 100%; padding: 8px 0; @@ -156,7 +167,7 @@ display: flex; align-items: center; justify-content: center; - gap: 4px; + gap: 6px; color: #000000a6; transition: color 300ms ease; @@ -174,43 +185,8 @@ } } - &.dimmed { - opacity: 0.5; - - &::before { - opacity: 0; - } - } - } - - .offsecSwitcher { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 36px; - margin-bottom: 12px; - padding: 0 14px; - background-color: #fff; - border: 1px solid #e0e0e0; - border-radius: 8px; - color: #000000a6; - font-size: 14px; - cursor: pointer; - opacity: 0.7; - transition: - opacity 200ms ease, - color 200ms ease; - - &:hover { - opacity: 1; - } - - &.active { - background-color: #fdecea; - border-color: #c8412a; + .activeOffsec { color: #c8412a; - opacity: 1; svg { fill: #c8412a; @@ -605,6 +581,11 @@ ol { border-color: #4c8cc1 !important; } + .ModalBodyContent .switcher:has(.activeOffsec)::before { + background-color: rgba(200, 65, 42, 0.18) !important; + border-color: #e57254 !important; + } + .ModalBodyContent .switcher .switcherItem { color: rgba(218, 218, 218, 0.7) !important; } @@ -623,24 +604,18 @@ ol { fill: #bbe4f2 !important; } - .ModalBodyContent .offsecSwitcher { - background-color: #151a26 !important; - border-color: #303338 !important; - color: #c2c7cf !important; + .ModalBodyContent .switcher .activeOffsec { + color: #ffd4c7 !important; svg { - fill: #c2c7cf !important; + fill: #ffd4c7 !important; } + } - &.active { - background-color: rgba(200, 65, 42, 0.18) !important; - border-color: #e57254 !important; - color: #ffd4c7 !important; - - svg { - fill: #ffd4c7 !important; - } - } + // Invert the Hexens bitmap on dark backgrounds — same trick we use on + // the kemmio popup. `brightness` lifts the mid-grey into white range. + .ModalBodyContent .switcher img { + filter: invert(1) brightness(1.4); } .ModalBodyContent .offsecComingSoon { diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx index 4feb751..a53be87 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.tsx +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.tsx @@ -64,8 +64,7 @@ const UXCoreModal: FC = ({ slugs, }) => { const router = useRouter(); - const [{ toggleIsOffsecView, setUseCase }, { isOffsecView }] = - useUXCoreGlobals(); + const [{ setUseCase }, { isOffsecView }] = useUXCoreGlobals(); const [isCopyTooltipVisible, setIsCopyTooltipVisible] = useState(false); const [isQuestionHovered, setIsQuestionHovered] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -76,16 +75,12 @@ const UXCoreModal: FC = ({ const { locale } = router as TRouter; const isOpen = !!biasNumber && data; - const handlePageViewChange = useCallback( + const handleUseCaseClick = useCallback( e => { - const { type } = e.currentTarget.dataset; - // Explicit set (not toggle) — guarantees a single click switches to - // the clicked side regardless of whether OffSec is currently active. - // The prior toggle-with-guard swallowed clicks when OffSec was on but - // the underlying isProductView still matched the clicked side. - setUseCase(type === secondViewLabel ? 'hr' : 'product'); + const { usecase } = e.currentTarget.dataset; + setUseCase(usecase as 'product' | 'hr' | 'offsec'); }, - [secondViewLabel, setUseCase], + [setUseCase], ); const handleCopyLink = useCallback(() => { @@ -220,15 +215,11 @@ const UXCoreModal: FC = ({ {usage}
-
+
= ({ {productText}
= ({ {hrText}
-
-
- {isOffsecView ? : } - {offsecText} +
+ {isOffsecView ? : } + {offsecText} +
Date: Fri, 22 May 2026 09:14:09 +0000 Subject: [PATCH 38/51] fix(uxcore): redraw Hexens mark as crisp SVG (kill blurry 16px PNG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 16×16 PNG asset blurred badly when rendered at 18px next to the crisp SVG PM/HR icons. Replaced with a stroke-based SVG that uses currentColor — scales to any size, picks up the active-state colour (crimson on active OffSec, white in dark mode) without a separate filter trick. Co-Authored-By: Claude Opus 4.7 --- public/uxcore/hexens-logo.png | Bin 368 -> 0 bytes src/uxcore/assets/icons/OffSecIcon.tsx | 36 +++++++++++------- .../UXCoreModal/UXCoreModal.module.scss | 6 --- 3 files changed, 23 insertions(+), 19 deletions(-) delete mode 100644 public/uxcore/hexens-logo.png diff --git a/public/uxcore/hexens-logo.png b/public/uxcore/hexens-logo.png deleted file mode 100644 index 907086a543d2481d7e4bf6508fef24bf739141a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 368 zcmV-$0gwKPP)*3^j#&MK+o+V9F$+Aq6B$3$1P1E!X?)zTaww1ChW8=l0=c&#FB0#up8)5+T zAW&WxZQ(lgcN~Y{w63e(?Q2;U0al0X@;sNOX|#V7fC?Z(1_atWIuFOtilWdvL!^J4 zyROq}81S_fO;Fdh`q@{|0tN_}2PCxPw}Xyk_*gs$tT9G@5RjGf38orAHRzZHdW(Pn zM+le&B@^u7Z3H7j#u*S`1oR4!0VI$jgMHtH!E!zbSShQ;(E^g!Es%wk+U%ah>-k2= zj-LO_qXG>7!1ur)0d{6YqG$p#0~?I|1pom5|AelTi~s-t21!IgR09AUYst)D#YGeV O0000 so the trademark stays pixel-faithful. -// Dark-mode inversion is handled by the consumer's stylesheet via a -// `filter: invert(1) brightness(2)` rule on `body.darkTheme` — -// see UXCoreModal.module.scss for the active-state colouring. -// Two exports kept for parity with the PM/HR icon pair. +// Hexens brand mark, redrawn as SVG so it stays crisp at any size and +// inherits the host's text colour via `currentColor`. Geometry mirrors +// the 16×16 pixel reference: two diagonal "corner ticks" in the +// top-left and bottom-right plus an X formed by two diagonals through +// the middle. Two exports kept for parity with the PM/HR icon pair. const HexensMark = () => ( - + > + {/* top-left tick */} + + {/* bottom-right tick */} + + {/* X — two diagonals through the center */} + + + ); export const OffSecIcon = () => ; diff --git a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss index 8160199..a78a1d7 100644 --- a/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss +++ b/src/uxcore/components/UXCoreModal/UXCoreModal.module.scss @@ -612,12 +612,6 @@ ol { } } - // Invert the Hexens bitmap on dark backgrounds — same trick we use on - // the kemmio popup. `brightness` lifts the mid-grey into white range. - .ModalBodyContent .switcher img { - filter: invert(1) brightness(1.4); - } - .ModalBodyContent .offsecComingSoon { background-color: #1f1419 !important; border-color: #c8412a !important; From 87fb0994ff664d9ab770594755a5a2c114e06c37 Mon Sep 17 00:00:00 2001 From: manager Date: Fri, 22 May 2026 09:16:56 +0000 Subject: [PATCH 39/51] fix(uxcore): invert Our Projects icons in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strapi-served project glyphs were dark ink on transparent — invisible against the dark panel. Apply invert+brightness in dark theme, with stacked grayscale for in-development rows. Also flip GitHub/API icon swap so the light icon shows at rest and the dark icon takes over when the button hovers to a light background. Co-Authored-By: Claude Opus 4.7 --- .../OurProjectsModal.module.scss | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/uxcore/components/OurProjectsModal/OurProjectsModal.module.scss b/src/uxcore/components/OurProjectsModal/OurProjectsModal.module.scss index b372c3b..8e3d23a 100644 --- a/src/uxcore/components/OurProjectsModal/OurProjectsModal.module.scss +++ b/src/uxcore/components/OurProjectsModal/OurProjectsModal.module.scss @@ -106,14 +106,45 @@ color: rgba(218, 218, 218, 0.75) !important; } + // Strapi ships the project glyphs as dark ink on transparent — they + // disappear against the dark panel. Invert them, and stack the + // grayscale on inDev rows so the dim-state still reads. + .projectIcon, + .openLinkIcon { + filter: invert(1) brightness(1.4); + } + + .inDevelopment .projectIcon, + .inDevelopment .openLinkIcon { + filter: invert(1) brightness(1.4) grayscale(70%); + } + .buttonStyleLink { border-color: #303338; color: #dadada; background-color: transparent; + // Resting state on dark needs the LIGHT (white) icon; on hover the + // button flips to a light background and the DARK icon takes over. + .darkIcon { + display: none; + } + + .lightIcon { + display: inline-block; + } + &:hover { background-color: #dadada; color: #1b1e26; + + .darkIcon { + display: inline-block; + } + + .lightIcon { + display: none; + } } } } From 89ad5eac23923bf23e238f5efd82a10af8e84de1 Mon Sep 17 00:00:00 2001 From: manager Date: Fri, 22 May 2026 09:26:13 +0000 Subject: [PATCH 40/51] fix(uxcore): make attentional-bias 'quiet ask' read as an email The QUIET ASK side was rendering as a second push notification, so the cross-channel attack ("two pings, different surfaces") didn't show. Convert it to the email card kind and dress the header with a crimson envelope glyph plus a From: label so the surface is unmistakable. Scenario copy updated to call out 'phone push + email'. Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/OffsecBiasView.module.scss | 37 +++++++++++++++++-- .../OffsecBiasView/OffsecBiasView.tsx | 15 ++++++++ src/uxcore/data/biasOffsec/attentionalBias.ts | 21 ++++++----- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss index 4834f2b..2ddaf78 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -116,9 +116,30 @@ $ks-crimson-deep: #7a2618; .emailHeader { display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; + align-items: center; + gap: 6px; + } + + // Envelope glyph that anchors the email card visually so it can't be + // mistaken for a push notification or chat row. + .emailEnvelope { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 18px; + color: $ks-crimson; + flex-shrink: 0; + } + + .emailFromLabel { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $ks-ink-soft; + opacity: 0.7; + flex-shrink: 0; } .cardSender { @@ -126,6 +147,8 @@ $ks-crimson-deep: #7a2618; font-size: 11.5px; color: $ks-ink-soft; word-break: break-all; + flex: 1 1 auto; + min-width: 0; } .cardTimestamp { @@ -481,6 +504,14 @@ $ks-crimson-deep: #7a2618; color: $dk-text-soft; } + .emailEnvelope { + color: $dk-crimson; + } + + .emailFromLabel { + color: $dk-text-soft; + } + .cardSubject { color: $dk-text; } diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index 1c51f04..02f860e 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -13,6 +13,21 @@ const CardBody = ({ card }: { card: OffsecBiasCard }) => { return ( <>
+ + From {card.sender} {card.timestamp && ( {card.timestamp} diff --git a/src/uxcore/data/biasOffsec/attentionalBias.ts b/src/uxcore/data/biasOffsec/attentionalBias.ts index 78f18b6..5f70002 100644 --- a/src/uxcore/data/biasOffsec/attentionalBias.ts +++ b/src/uxcore/data/biasOffsec/attentionalBias.ts @@ -1,13 +1,15 @@ -// No quoted figures by policy. Surface here is push notifications, not -// email — attentional-bias attacks land wherever multiple things compete -// for your eyes (lock-screen, OS toasts, browser pop-ups), and the -// mechanism is unrelated to the inbox. +// 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 notifications arrive on your phone within the same minute. 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.', + '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: { @@ -20,12 +22,13 @@ const content: OffsecBiasContent = { flagged: true, }, after: { - kind: 'notification', + kind: 'email', tag: 'Quiet ask', - appName: 'Wire Approvals', + sender: 'approvals@acme-vendor.com', timestamp: '1 min ago', - title: 'Acme Vendor updated their bank details', - body: 'New routing + account on file. Same totals, same schedule — approve to keep payments flowing.', + 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', From 618cb1a62109a0d6e98537281e8a133b656a9290 Mon Sep 17 00:00:00 2001 From: manager Date: Fri, 22 May 2026 09:44:50 +0000 Subject: [PATCH 41/51] fix(uxcore): availability bias now renders as browser tab, not email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three OffSec biases were stacking up too inbox-shaped (attentional had half-email, availability was email+email). Convert availability to a new 'browser' card kind — faux Chrome chrome with URL bar, lookalike host, page heading + body + CTA. The crimson host on the flagged side calls out the deceptive subdomain stacking that real news-anchored phishing relies on. Surface diversity now: attentional = push + email, illusory = chat, availability = browser. Co-Authored-By: Claude Opus 4.7 --- .../OffsecBiasView/OffsecBiasView.module.scss | 155 ++++++++++++++++++ .../OffsecBiasView/OffsecBiasView.tsx | 43 +++++ .../data/biasOffsec/availabilityHeuristics.ts | 41 +++-- src/uxcore/data/biasOffsec/types.ts | 18 +- 4 files changed, 239 insertions(+), 18 deletions(-) diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss index 2ddaf78..51268e4 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -220,6 +220,121 @@ $ks-crimson-deep: #7a2618; } } +// === Browser surface ======================================================= + +.card_browser { + padding: 0; + gap: 0; + overflow: hidden; + + .browserChrome { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 12px; + background: $ks-paper; + border-bottom: 1px solid $ks-rule; + } + + .browserDots { + display: inline-flex; + gap: 4px; + flex-shrink: 0; + + span { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(27, 30, 38, 0.18); + } + } + + .browserUrlBar { + display: flex; + align-items: center; + gap: 4px; + flex: 1 1 auto; + min-width: 0; + padding: 4px 10px; + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 11px; + color: $ks-ink-soft; + background: #ffffff; + border: 1px solid $ks-rule; + border-radius: 999px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .browserPadlock { + display: inline-flex; + align-items: center; + color: #2a8a4a; + flex-shrink: 0; + } + + .browserProtocol { + opacity: 0.55; + } + + .browserHost { + color: $ks-ink; + font-weight: 600; + } + + .browserPath { + opacity: 0.65; + } + + .browserPageHeading { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 14px 0; + font-size: 14px; + font-weight: 700; + color: $ks-ink; + line-height: 1.3; + } + + .cardRule { + margin: 8px 14px 0; + } + + .browserPageBody { + padding: 8px 14px 0; + font-size: 12.5px; + line-height: 1.45; + color: $ks-ink-soft; + } + + .browserCta { + align-self: flex-start; + margin: 10px 14px 14px; + padding: 5px 12px; + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #ffffff; + background: $ks-crimson; + border-radius: 4px; + } +} + +// On the flagged variant, the lookalike URL should read crimson so the +// reader's eye is dragged to the deception, not just the page body. +.cardFlagged.card_browser { + .browserHost { + color: $ks-crimson; + } + + .browserPadlock { + color: $ks-crimson; + } +} + // === Notification surface ================================================== .card_notification { @@ -575,6 +690,46 @@ $ks-crimson-deep: #7a2618; border-color: $dk-crimson; } + .card_browser { + .browserChrome { + background: $dk-card-soft; + border-bottom-color: $dk-rule; + } + + .browserDots span { + background: rgba(218, 218, 218, 0.22); + } + + .browserUrlBar { + background: $dk-card; + border-color: $dk-rule; + color: $dk-text-soft; + } + + .browserHost { + color: $dk-text; + } + + .browserPadlock { + color: #6fcf97; + } + + .browserPageHeading { + color: $dk-text; + } + + .browserPageBody { + color: $dk-text-soft; + } + } + + .cardFlagged.card_browser { + .browserHost, + .browserPadlock { + color: $dk-crimson; + } + } + .cardDivider .cardArrow { color: $dk-crimson; } diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx index 02f860e..074bdb7 100644 --- a/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -49,6 +49,49 @@ const CardBody = ({ card }: { card: OffsecBiasCard }) => { ); } + if (card.kind === 'browser') { + const protocol = card.protocol || 'https'; + return ( + <> +
+
+
+ {card.flagged && } + {card.pageHeading} +
+
+
{card.pageBody}
+ {card.cta &&
{card.cta}
} + + ); + } + if (card.kind === 'notification') { return ( <> diff --git a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts index 717706f..c486484 100644 --- a/src/uxcore/data/biasOffsec/availabilityHeuristics.ts +++ b/src/uxcore/data/biasOffsec/availabilityHeuristics.ts @@ -3,45 +3,52 @@ // 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. The next morning, an email lands in your inbox — looks like a vendor you trust, anchored to the breach you just read about.', + '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: 'email', + kind: 'browser', tag: 'Generic', - sender: 'billing@acme-vendor.com', - timestamp: 'Mon, 10:42 AM', - subject: 'Q3 invoice attached', - preview: 'Hi team — please find the attached invoice for Q3.', - attachment: 'invoice-Q3.pdf', + 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: 'email', + kind: 'browser', tag: 'News-anchored', - sender: 'security@acme-vendor.com', - timestamp: 'Tue, 08:17 AM', - subject: 'Action required: exposure check after the NorthBank incident', - preview: - 'Our team flagged your domain in the NorthBank dataset. Confirm SSO so we can scope your exposure before EOD.', + 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 sender,” and pattern-match the email to the news cycle, not to phishing. Identical payload; the news desk is doing the social engineering.', + '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 an email 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.', - 'Verify through a channel you already trust — the vendor’s portal from a bookmark, or the phone number already in your contacts. Never the link or number in the email itself.', + '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 in the email as a claim, not a fact. Check the company’s own status page or Have I Been Pwned before you click anything else in the message.', + '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.', ], }, }; diff --git a/src/uxcore/data/biasOffsec/types.ts b/src/uxcore/data/biasOffsec/types.ts index 769e35e..7c7c9cb 100644 --- a/src/uxcore/data/biasOffsec/types.ts +++ b/src/uxcore/data/biasOffsec/types.ts @@ -39,10 +39,26 @@ export interface OffsecBiasChatCard extends OffsecBiasCardCommon { 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; + | OffsecBiasChatCard + | OffsecBiasBrowserCard; export interface OffsecBiasContent { scenario: string; From f7380829e18bfd0d1763899f5f6bb9999ba1dd92 Mon Sep 17 00:00:00 2001 From: manager Date: Sat, 23 May 2026 06:36:53 +0000 Subject: [PATCH 42/51] chore(claude-md): adopt Karpathy's 4 coding rules Behavioral guardrails for Claude Code agents working in this repo (think before coding, simplicity first, surgical changes, goal-driven execution). Sourced from multica-ai/andrej-karpathy-skills. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2abec2d..7d3fe09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# CLAUDE.md — keepsimple-merged (for Claude Code agents) +# CLAUDE.md — keepsimple (for Claude Code agents) This file is loaded by Claude Code at session start. Human-readable agent guidelines live in `AGENTS.md` next to it; this file is the machine-facing version. @@ -36,3 +36,52 @@ The 100+ cognitive biases in UX Core are the product of 5+ years of curation and ## Everything else See `AGENTS.md` for repo conventions, build/test commands, and contribution rules. + +--- + +## ⚠️ Karpathy's 4 coding rules — apply to all work + +Behavioral guidelines to reduce common LLM coding mistakes. Source: Andrej Karpathy via `multica-ai/andrej-karpathy-skills`. Bias toward caution over speed; for trivial tasks, use judgment. + +### 1. Think Before Coding + +Don't assume. Don't hide confusion. Surface tradeoffs. + +- State assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them — don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +### 2. Simplicity First + +Minimum code that solves the problem. Nothing speculative. + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. +- The test: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +### 3. Surgical Changes + +Touch only what you must. Clean up only your own mess. + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it — don't delete it. +- Remove imports/variables/functions that YOUR changes made unused; don't remove pre-existing dead code unless asked. +- The test: every changed line should trace directly to the user's request. + +### 4. Goal-Driven Execution + +Define success criteria. Loop until verified. + +- "Add validation" → "Write tests for invalid inputs, then make them pass." +- "Fix the bug" → "Write a test that reproduces it, then make it pass." +- "Refactor X" → "Ensure tests pass before and after." +- For multi-step tasks, state a brief plan with a verify check per step. +- Strong success criteria let you loop independently; weak ones ("make it work") force constant clarification. + +Working if: fewer unnecessary diffs, fewer rewrites from overcomplication, clarifying questions land before implementation rather than after mistakes. From 453d8fbde2be5d44a0b04e8a5aec6a9aaebd283b Mon Sep 17 00:00:00 2001 From: manager Date: Sat, 23 May 2026 06:56:06 +0000 Subject: [PATCH 43/51] chore: gitignore /attachments/ (wolfs-terminal session drops) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshots Wolf pastes into the chat land here and trip the "unsaved" badge after every push. Local-only artefacts — keep them out of git entirely. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 005291a..88d158b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ .idea/ .codex +# Wolf's-terminal session attachments (screenshots Wolf drops in mid-chat). +# Local-only — never commit. +/attachments/ + # Logs logs *.log From 4dfa55e9e5809d2526315b5656f423014e46126c Mon Sep 17 00:00:00 2001 From: manager Date: Sat, 23 May 2026 07:02:35 +0000 Subject: [PATCH 44/51] chore(atlas): update lead-seogeosolved static line count 148 -> 142 Manual fallback used when the live metrics endpoint is unreachable. Live count now also reports 142 after the host script picked up the recent CLAUDE.md trim. Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 2 +- public/ai-atlas/data.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index 23d5d02..ab4c261 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -542,7 +542,7 @@ "title": "ТЕХНИЧЕСКИЙ ЛИД · SEOGEOSOLVER", "cjk": "長", "desc": "Технический лид, прикреплён к проекту SeoGeoSolver.", - "claudeMdLines": 148, + "claudeMdLines": 142, "rows": [ { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, { "k": "роль", "v": "технический лид" }, diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index cbb2bd6..32776e1 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -534,7 +534,7 @@ "title": "ENGINEERING LEAD · SEOGEOSOLVER", "cjk": "長", "desc": "Engineering lead attached to the SeoGeoSolver project.", - "claudeMdLines": 148, + "claudeMdLines": 142, "rows": [ { "k": "kind", "v": "ai agent", "cls": "blue" }, { "k": "role", "v": "engineering lead" }, From ac4a72e5df5bbdf92a2337a220e833ea43f86f8a Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 27 May 2026 20:41:00 +0000 Subject: [PATCH 45/51] docs: refresh CLAUDE.md and add 2026-05-25 staging-to-prod notes - Point CLAUDE.md at the global Wolf rules and MemPalace; drop the duplicated Karpathy block now that it lives globally. - Capture the May-25 staging-to-prod safety assessment and The Order's pipeline-correction response so future agents have the audit trail. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 69 ++-------------- staging-to-prod-assessment.md | 70 +++++++++++++++++ staging-to-prod-order-response-2026-05-25.md | 82 ++++++++++++++++++++ 3 files changed, 160 insertions(+), 61 deletions(-) create mode 100644 staging-to-prod-assessment.md create mode 100644 staging-to-prod-order-response-2026-05-25.md diff --git a/CLAUDE.md b/CLAUDE.md index 7d3fe09..a07a15a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,14 @@ # CLAUDE.md — keepsimple (for Claude Code agents) -This file is loaded by Claude Code at session start. Human-readable agent guidelines live in `AGENTS.md` next to it; this file is the machine-facing version. +> **Global rules apply.** Communication style + Agent Directory routing live in `~/.claude/CLAUDE.md` — read that first. This project participates in the directory; use `/send-to` to ask peers. -## Code search — prefer CodeGraph over Grep +MemPalace wing: `keepsimple` (protocol lives in `~/.claude/CLAUDE.md`). -This repo is indexed by **CodeGraph** (MCP server `codegraph`, registered globally). Symbol/structure queries are sub-millisecond there and dramatically cheaper than grep. Reach for it FIRST when you have a name: +Human-readable agent guidelines live in `AGENTS.md` next to this file; this file is the machine-facing version. See `AGENTS.md` for repo conventions, build/test commands, and contribution rules. -- `codegraph_search` — find a symbol by name (kind + location + signature in one shot) -- `codegraph_callers` / `codegraph_callees` — function-call graph navigation -- `codegraph_context` — fastest onboarding for "what is this file/feature about?" -- `codegraph_impact` — blast radius before a rename or refactor -- `codegraph_files` — what's in a directory + per-file symbol counts +## Code search — prefer CodeGraph over Grep -Use **Grep / Glob only when** the query is a _concept_ with no symbol name ("where do we handle the Cohere fallback?"), or when a CodeGraph query returned nothing. Index lags writes ~500ms; if you just edited a file, give it a turn before re-querying. +Repo is indexed by **CodeGraph** (MCP `codegraph`, registered globally). Use it FIRST when you have a symbol name: `codegraph_search`, `codegraph_callers`/`callees`, `codegraph_context`, `codegraph_impact`, `codegraph_files`. Grep/Glob only when query is conceptual or CodeGraph returned nothing. Index lags writes ~500ms. ## Voice for user-facing copy @@ -25,7 +21,7 @@ When writing copy that ships to users (microcopy, page headings, marketing blurb - No AI-isms — no "let me know if…", no "happy to help", no preamble before the answer. - Reference piece: **"The Rise of the Choice Architect"** (article on keepsimple.io). Match its register. -## UX Core data is canonical +## ⚠️ UX Core data is canonical The 100+ cognitive biases in UX Core are the product of 5+ years of curation and are referenced by Duke, Harvard, MIT, Google, Yandex, Amazon, and others. @@ -33,55 +29,6 @@ The 100+ cognitive biases in UX Core are the product of 5+ years of curation and - If you need structured bias data, pull from `/uxcore-api` (see AGENTS.md → Public data API). Don't scrape, don't paraphrase from memory. - Schema changes to UX Core data require explicit approval. -## Everything else - -See `AGENTS.md` for repo conventions, build/test commands, and contribution rules. - ---- - -## ⚠️ Karpathy's 4 coding rules — apply to all work - -Behavioral guidelines to reduce common LLM coding mistakes. Source: Andrej Karpathy via `multica-ai/andrej-karpathy-skills`. Bias toward caution over speed; for trivial tasks, use judgment. - -### 1. Think Before Coding - -Don't assume. Don't hide confusion. Surface tradeoffs. - -- State assumptions explicitly. If uncertain, ask. -- If multiple interpretations exist, present them — don't pick silently. -- If a simpler approach exists, say so. Push back when warranted. -- If something is unclear, stop. Name what's confusing. Ask. - -### 2. Simplicity First - -Minimum code that solves the problem. Nothing speculative. - -- No features beyond what was asked. -- No abstractions for single-use code. -- No "flexibility" or "configurability" that wasn't requested. -- No error handling for impossible scenarios. -- If you write 200 lines and it could be 50, rewrite it. -- The test: "Would a senior engineer say this is overcomplicated?" If yes, simplify. - -### 3. Surgical Changes - -Touch only what you must. Clean up only your own mess. - -- Don't "improve" adjacent code, comments, or formatting. -- Don't refactor things that aren't broken. -- Match existing style, even if you'd do it differently. -- If you notice unrelated dead code, mention it — don't delete it. -- Remove imports/variables/functions that YOUR changes made unused; don't remove pre-existing dead code unless asked. -- The test: every changed line should trace directly to the user's request. - -### 4. Goal-Driven Execution - -Define success criteria. Loop until verified. - -- "Add validation" → "Write tests for invalid inputs, then make them pass." -- "Fix the bug" → "Write a test that reproduces it, then make it pass." -- "Refactor X" → "Ensure tests pass before and after." -- For multi-step tasks, state a brief plan with a verify check per step. -- Strong success criteria let you loop independently; weak ones ("make it work") force constant clarification. +## MemPalace usage (wing: `keepsimple`) -Working if: fewer unnecessary diffs, fewer rewrites from overcomplication, clarifying questions land before implementation rather than after mistakes. +When you find yourself stuck > 10 minutes on a problem and figure it out, write a brief drawer in your wing — chronology + fix. Next-session-you won't waste the same 10 minutes. Same when a deployment/config decision is non-obvious — capture _why_ alongside _what_. diff --git a/staging-to-prod-assessment.md b/staging-to-prod-assessment.md new file mode 100644 index 0000000..6ed4c70 --- /dev/null +++ b/staging-to-prod-assessment.md @@ -0,0 +1,70 @@ +# Staging → Prod safety assessment (2026-05-25) + +Baseline assumption: **staging == origin/dev tip** (needs confirmation from Wolf / The Order). +Comparison: `origin/main` (prod) → `origin/dev` (presumed staging). + +## Scale of the push + +- 101 commits, 2244 files changed, +134,848 / -2,569 lines. +- Bulk is the UX Core merge (folded May 14): `src/uxcore/` alone is +95,760 lines, `public/` +12,309. Net new product code (Copilot, auth, AI Atlas updates) is the smaller, hotter slice. +- Last prod push: hotfix #108 (`hotfix/delete-test-login`) on 2026-05-11. PR #102 (dev→main) on 2026-05-08. **17 days of dev work** accumulated since prod last moved. + +## Green / verified safe + +1. **Hotfix #108 preserved.** `src/pages/api/test-login.ts` is absent on **both** main and dev. The unauthenticated JWT-mint endpoint will not re-appear post-merge. +2. **No DB migrations / SQL files** in the diff. No Strapi schema changes inside this repo. +3. **No `_document.tsx` touch.** `_app.tsx` modified (locale/context/atlas-class wiring + UX Core context Proxy) — diff is non-trivial but localized. + +## Red — needs Order verification before push + +1. **Copilot analytics depends on Postgres `copilot-events` service.** + - New endpoints on dev: `/api/copilot/event`, `/api/concierge`, `/api/concierge-landing`, plus `/admin/copilot-sessions`. + - New libs: `src/lib/copilotAnalytics.ts`, `src/lib/copilotEventsRead.ts`. + - Strapi sink was ripped out (PR `4635feb feat(copilot): swap analytics sink from Strapi to copilot-events Postgres`). + - **PROD MUST HAVE:** the `copilot-events` Postgres service running + reachable from the prod container, and the Postgres connection env vars (`COPILOT_EVENTS_*` or equivalent) set in prod secrets. Without these, every Copilot event POST and concierge turn will throw on log-write. Fire-and-forget should swallow errors (per `event.ts` design), but admin viewer (`/admin/copilot-sessions`) will be empty / errored. + +2. **`.env.example` changed.** New required env keys may have been introduced and not yet provisioned in prod. Order needs to diff `.env.example` against prod's actual env and add what's missing **before** the dev → main merge runs the build. + +3. **24 new npm dependencies, yarn.lock +2047/-329.** + - Notable: `isomorphic-dompurify`, `marked`, `rehype-sanitize` (markdown/XSS surface), `d3-geo` + topojson stack, `victory`, `react-toastify`, `web-vitals`, `tsx`, `linkinator`, `axe-core`. + - Prod build must `yarn install` cleanly — confirm no private-registry deps and Node 18.18.0 image still resolves all of them. + +4. **Magic-link auth flow added.** `src/pages/auth/magic-link.tsx`, `MagicLinkEmailForm`, plus changes to `[...nextauth].ts`. Mailer (SMTP / provider) must be configured in prod env. NextAuth callback URLs (Google/LinkedIn/Discord) for keepsimple.io domain must still be whitelisted in their respective OAuth consoles after the auth refactor. + +5. **`build-fetch-patch.js` Strapi guardrail.** Injected via `NODE_OPTIONS=--require=./build-fetch-patch.js` in `yarn build:staging`. Confirm prod's build command path also wires this in — if prod calls `yarn build` (plain), Strapi 5xx during build will crash the deploy. + +6. **`.github/workflows/cypress-manual.yml` shows up in the diff.** Per `CLAUDE.local.md` Cypress was already removed (PR #103). Likely a no-op dead workflow — won't block prod, just clutter. + +## Yellow — process risk + +- **101-commit single jump.** No incremental staging cadence inside the window; the whole 17-day backlog rides together. Rollback path is `git revert b1e6f9f` (last merge to main) — works, but loses everything. +- **Staging coverage uncertain.** I do not know: + - Which exact commit / branch staging.keepsimple.io is serving. + - Whether QA has run a full smoke / canonical pass on that build. + - Whether the magic-link flow has been exercised end-to-end on staging with a real mailer. + +## What's NOT in this push (clarifying scope) + +- `feat/uxcore-cybersec` (current local branch, 30 commits ahead of dev) — OffSec layer, AI Atlas tweaks, Hexens/kemmio attribution. **Not on dev, therefore not on staging, therefore not in this prod push.** Stays in the queue. + +## Recommendation + +**Conditional GO**, contingent on Order confirming, on the prod host: + +1. `copilot-events` Postgres service exists, is running, and its DSN/creds are in the prod container env. +2. `.env.example` keys not yet in prod env are added. +3. Magic-link mailer creds present; OAuth callback URLs unchanged. +4. Prod build command includes the `build-fetch-patch.js` guardrail (or equivalent Strapi 5xx tolerance). +5. Staging is in fact on `origin/dev` tip and has cleared a QA smoke pass in the last 24h. + +If any of (1)–(4) is missing, **do not merge dev → main yet**. Item (5) needs Wolf / QA, not Order. + +## SEND TO @TheOrder draft (for Wolf to relay) + +> Before we merge dev → main on keepsimpleio/KeepSimpleOSS (101 commits, prod push), please verify on the prod host: +> +> 1. `copilot-events` Postgres service is running and its connection env vars are set in the prod KS container. +> 2. Any new keys in `.env.example` (vs `origin/main`) are present in prod env. +> 3. Magic-link SMTP/mailer creds are configured; OAuth callback URLs (Google/LinkedIn/Discord) for keepsimple.io still match the refactored NextAuth handler. +> 4. The prod build command path wires `NODE_OPTIONS=--require=./build-fetch-patch.js` (Strapi 5xx absorber) — same way `yarn build:staging` does. +> Also: which exact commit is `staging.keepsimple.io` serving right now? Need that to confirm staging == dev tip. diff --git a/staging-to-prod-order-response-2026-05-25.md b/staging-to-prod-order-response-2026-05-25.md new file mode 100644 index 0000000..7a51633 --- /dev/null +++ b/staging-to-prod-order-response-2026-05-25.md @@ -0,0 +1,82 @@ +# The Order — readiness check response (2026-05-25) + +Responding to `staging-to-prod-assessment.md`. Audited the prod host + ks-contabo staging + the keepsimple repo @ `origin/dev` + the GH Actions CI workflow. + +## The pipeline you're merging into (correction to assessment framing) + +Prod deploy is **fully automatic** the moment `dev → main` lands. There is no manual `docker build` on the prod host: + +1. GitHub Actions `.github/workflows/main.yaml` fires on push to `main`. +2. CI runs `docker build` using a **base64-encoded `ENV_PRODUCTION` GH Actions secret** as both `.env` and `.env.staging` baked into the image, runs `yarn build:staging`, pushes `/keepsimple-next:prod`. +3. Watchtower on the prod host polls the registry and recreates `keepsimple-next-staging` (yes, container is misnamed) when a new digest appears. + +So **prod env secrets do NOT live on the prod host** — they live in the GH Actions secret `ENV_PRODUCTION` (and `ENV_STAGING` for dev pushes). Verifying the prod host's `.env` is meaningless for keys baked at build time; only the GH secret matters. The verification target for items 1-3 is **the `ENV_PRODUCTION` secret on the keepsimpleio/KeepSimpleOSS GitHub repo**, set by Wolf or me (The Order) before the merge. + +This also means: **the merge itself is the deploy.** No staged go/no-go after the click. + +## Item-by-item + +### 1. `copilot-events` Postgres service — RED + +- No `copilot-events` container exists on prod. Only `keepsimple-db-prod` (the main Strapi postgres:12) is up. +- Required env vars (`COPILOT_EVENTS_URL`, `COPILOT_EVENTS_WRITE_TOKEN`, `COPILOT_EVENTS_READ_TOKEN`) are **absent from both the staging AND prod baked env**. +- Implication: per `event.ts` fire-and-forget design, runtime won't crash, but every Copilot session writes 0 events and `/admin/copilot-sessions` returns empty. **Same is true on staging today** — Copilot analytics has likely never been exercised end-to-end with a real sink. +- Decision needed from Wolf: + - (A) Stand up the `copilot-events` Postgres service (new container) + provision tokens + add to `ENV_PRODUCTION` + `ENV_STAGING` BEFORE merge. The Order can do this in ~30 min if KEEPSIMPLE provides the schema/migration file or service compose snippet. + - (B) Accept Copilot ships dark on prod, fix in a follow-up. Admin viewer empty, no telemetry, no crash. + +### 2. New `.env.example` keys missing on prod — YELLOW (but only meaningfully missing: `MAILRU_*`) + +Diffing prod's baked env against the dev `.env.example`: + +| Key | In prod baked env? | Impact | +| ----------------------------------------------------- | ------------------ | ----------------------------------------- | +| `NEXT_PUBLIC_AHREFS_ANALYTICS_KEY` | NO | Ahrefs analytics tag won't render on prod | +| `MAILRU_CLIENT_ID` / `MAILRU_CLIENT_SECRET` | NO | Mailru OAuth button will fail post-merge | +| `COPILOT_EVENTS_URL` / `_WRITE_TOKEN` / `_READ_TOKEN` | NO | Covered in item 1 | +| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | YES (already set) | OK | +| `NEXTAUTH_SECRET`, OAuth (Google/LinkedIn/Discord) | YES | OK, hotfix #108 era already set them | + +Action: update `ENV_PRODUCTION` GH secret to include the missing keys before merge. The Order needs values from Wolf (Ahrefs key) and from Mailru OAuth console registration (CLIENT_ID/SECRET, redirect_uri whitelist for `https://keepsimple.io/api/auth/callback/mailru`). + +### 3. Magic-link mailer + OAuth callbacks — GREEN (with one ask of KEEPSIMPLE) + +- **No SMTP/EmailProvider in the Next.js NextAuth handler.** The Next.js app only _consumes_ magic links via `/auth/magic-link` (`consumeMagicLink` from `@api/auth`). The send-side mailer lives in the **backend** (`uxcat-api` or Strapi) — already running on prod, out of scope for this Next.js push. +- OAuth providers wired in handler: Google, LinkedIn, Discord, Twitter, Yandex, **Mailru (new)**. Existing 5 keep their redirect URIs unchanged (paths weren't refactored). Mailru is the only one requiring console setup. +- **Confirm with KEEPSIMPLE:** has the magic-link flow been exercised end-to-end on staging with a real email landing in a real inbox? If yes, send-side mailer is configured in uxcat-api/Strapi and prod inherits the same backend. If no, magic-link UI ships dark and that's a separate fix. + +### 4. `build-fetch-patch.js` Strapi guardrail — GREEN, already done + +The Dockerfile **unconditionally runs `yarn build:staging`** for both `:prod` and `:staging` tags. That command includes `NODE_OPTIONS=--require=./build-fetch-patch.js`. Strapi 5xx absorber is already protecting every prod build. **No action needed.** Strike this item from the gate list. + +### 5. Staging-commit confirmation — staging IS on dev tip (with caveat) + +- ks-contabo's `keepsimple-next-staging` container runs `keepsimple-next:staging`, image **built 2026-05-22 15:05 UTC**. +- GH Actions auto-builds `:staging` on every push to `origin/dev`. +- `origin/dev` tip locally is `f659653` (Merge PR #119 chore/minor-improvements). +- If anything pushed to `dev` after 2026-05-22 15:05 UTC, staging is N commits behind dev tip. Likely 0-2 commits behind based on commit cadence — Wolf, check `git log --since='2026-05-22 15:05Z' origin/dev` if exact-tip-on-staging matters for QA sign-off. + +## Recommended sequence + +1. KEEPSIMPLE answers: was magic-link send-side tested on staging with a real inbox? (5-line reply) +2. Wolf decides: copilot-events Postgres before merge (A) or after (B)? +3. If (A): The Order stands up service + tokens + appends to both GH secrets (~30 min, needs schema from KEEPSIMPLE). +4. The Order updates `ENV_PRODUCTION` GH secret with: AHREFS key (from Wolf), MAILRU OAuth creds (from Wolf via Mailru console), copilot-events tokens (from step 3). +5. The Order does same updates to `ENV_STAGING` secret to keep parity, pushes a no-op commit to `dev` to rebuild staging with full env, smoke-checks at staging.keepsimple.io. +6. Merge `dev → main` in PR UI. Watchtower picks up in ≤1 hr. +7. The Order watches prod healthcheck + Copilot endpoint logs for 30 min post-deploy. + +## SEND TO @Keepsimple draft (for Wolf to relay) + +> The Order ran the readiness audit. Three corrections to your assessment: +> +> 1. Your item 4 (build-fetch-patch guardrail) is ALREADY active for prod — the Dockerfile runs `yarn build:staging` for the `:prod` tag too, so the Strapi 5xx absorber is wrapped around every prod build. Drop this item. +> 2. Your item 3 (magic-link mailer): the Next.js app only consumes magic links — it never sends. Send-side mailer lives in the backend (uxcat-api or Strapi), not Next.js. ONE question for you: has the magic-link flow been tested end-to-end on staging with a real email landing in a real inbox? If yes → backend mailer is already configured, no prod risk. If no → magic-link ships dark, plan a follow-up. +> 3. Items 1, 2 (copilot-events Postgres + missing env keys): your framing of "Order needs to verify on the prod host" is wrong layer. Prod secrets are not on the prod host — they're baked into the image at build time from the `ENV_PRODUCTION` GitHub Actions secret. Same for staging from `ENV_STAGING`. Verified by comparing the prod image's `/app/.env` against `.env.example`. Confirmed missing in prod's baked env: `MAILRU_CLIENT_ID/SECRET`, `NEXT_PUBLIC_AHREFS_ANALYTICS_KEY`, all three `COPILOT_EVENTS_*`. Same gaps on the staging baked env (so Copilot analytics has never had a real sink, including on staging). +> +> Decisions for Wolf: +> +> - (A) Stand up `copilot-events` Postgres before merge — need the service compose snippet + DB schema/migration from you. I can deploy + token-provision in ~30 min once those land. +> - (B) Ship Copilot dark on prod, fix in a follow-up. No crash (event.ts swallows errors), just empty admin viewer. +> +> Staging is on `:staging` image built 2026-05-22 15:05 UTC; if any dev commits landed after that, staging is N behind. Local `origin/dev` tip is `f659653` (Merge PR #119). If you need staging at exact dev tip for QA sign-off, push a no-op or let me re-trigger the build after the env secret update. From 2989f6a5f19d9aeb1525ece80892acbe1506a005 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 27 May 2026 21:11:46 +0000 Subject: [PATCH 46/51] feat(ai-atlas): flip KeepSimple eng lead to AI agent (blue diamond) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KeepSimple project's lead pin was the only outlier showing a gold (human) diamond — every other project (terminal, multimove, agentsforge, elea, seogeosolved) renders its eng lead as an AI agent. Switch keepsimple to match: leadDiamond blue + dossier reshaped to mirror lead-terminal / lead-seogeosolved. The lead-keepsimple key already exists in the live metrics feed, so the dossier picks up its CLAUDE.md line count automatically. EN + RU kept in sync. Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 15 ++++++++++----- public/ai-atlas/data.json | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index ab4c261..492bfab 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -213,7 +213,7 @@ "diamond": "red", "theta": 180, "status": "ok", - "leadDiamond": "gold", + "leadDiamond": "blue", "territoryArc": 70, "children": [ { @@ -484,11 +484,16 @@ "lead-keepsimple": { "title": "ТЕХНИЧЕСКИЙ ЛИД · KEEPSIMPLE", "cjk": "長", - "desc": "Человек-куратор open-source крыла.", + "desc": "Технический лид, прикреплён к open-source крылу KeepSimple.", "rows": [ - { "k": "тип", "v": "человек", "cls": "gold" }, - { "k": "кольцо", "v": "III — ключевые продукты" }, - { "k": "пара", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" } + { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, + { "k": "роль", "v": "технический лид" }, + { + "k": "полномочия", + "v": "кодовая база keepsimple.io · UX Core · AI Atlas" + }, + { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, + { "k": "кольцо", "v": "III — ключевые продукты" } ] }, diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index 32776e1..c7b925f 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -213,7 +213,7 @@ "diamond": "red", "theta": 180, "status": "ok", - "leadDiamond": "gold", + "leadDiamond": "blue", "territoryArc": 70, "children": [ { @@ -479,11 +479,16 @@ "lead-keepsimple": { "title": "ENGINEERING LEAD · KEEPSIMPLE", "cjk": "長", - "desc": "Human custodian of the open-source wing.", + "desc": "Engineering lead attached to the KeepSimple open-source wing.", "rows": [ - { "k": "kind", "v": "human", "cls": "gold" }, - { "k": "ring", "v": "III — core products" }, - { "k": "pairs", "v": "keepsimple", "cls": "red" } + { "k": "kind", "v": "ai agent", "cls": "blue" }, + { "k": "role", "v": "engineering lead" }, + { + "k": "authority", + "v": "keepsimple.io codebase · UX Core · AI Atlas" + }, + { "k": "reports", "v": "wolf", "cls": "gold" }, + { "k": "ring", "v": "III — core products" } ] }, From c982b56c6a628736ebc05839a6a70165c3b25a8c Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 27 May 2026 21:22:29 +0000 Subject: [PATCH 47/51] feat(ai-atlas): support a second engineering lead per project KeepSimple has both an AI engineering lead (the agent owning this repo, counted in claudeMdLines) and a human engineering lead (custodian of the open-source wing). Previously the data model allowed only one lead per project, forcing a choice. Adds opt-in support for a second lead pin via `leadDiamond2` / `leadDeg2` / `leadR2` on a project, rendered as `lead2-` with its own dossier. KeepSimple now exposes both; all other projects are unaffected. EN + RU in sync. The AI lead keeps the live claudeMdLines wiring (metrics key `lead-keepsimple`); the human lead has no CLAUDE.md, by design. Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 21 ++++++++++- public/ai-atlas/data.json | 21 ++++++++++- src/pages/ai-atlas.tsx | 72 ++++++++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index 492bfab..da2ec79 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -214,6 +214,7 @@ "theta": 180, "status": "ok", "leadDiamond": "blue", + "leadDiamond2": "gold", "territoryArc": 70, "children": [ { @@ -423,11 +424,17 @@ "rows": [ { "k": "тип", "v": "продукт", "cls": "red" }, { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "лид", + "v": "ИИ инженерный агент", + "cls": "blue", + "ref": "lead-keepsimple" + }, { "k": "лид", "v": "человек · технический лид", "cls": "gold", - "ref": "lead-keepsimple" + "ref": "lead2-keepsimple" }, { "k": "владеет", "v": "созвездие публичного влияния" } ] @@ -497,6 +504,18 @@ ] }, + "lead2-keepsimple": { + "title": "ТЕХНИЧЕСКИЙ ЛИД · KEEPSIMPLE", + "cjk": "長", + "desc": "Человек-куратор open-source крыла KeepSimple — работает в паре с ИИ-лидом.", + "rows": [ + { "k": "тип", "v": "человек", "cls": "gold" }, + { "k": "роль", "v": "технический лид" }, + { "k": "пара", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, + { "k": "кольцо", "v": "III — ключевые продукты" } + ] + }, + "lead-terminal": { "title": "ТЕХНИЧЕСКИЙ ЛИД · TERMINAL", "cjk": "長", diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index c7b925f..6e15ac5 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -214,6 +214,7 @@ "theta": 180, "status": "ok", "leadDiamond": "blue", + "leadDiamond2": "gold", "territoryArc": 70, "children": [ { @@ -423,11 +424,17 @@ "rows": [ { "k": "kind", "v": "product", "cls": "red" }, { "k": "ring", "v": "III — core products" }, + { + "k": "lead", + "v": "AI engineering agent", + "cls": "blue", + "ref": "lead-keepsimple" + }, { "k": "lead", "v": "human · engineering lead", "cls": "gold", - "ref": "lead-keepsimple" + "ref": "lead2-keepsimple" }, { "k": "owns", "v": "public impact constellation" } ] @@ -492,6 +499,18 @@ ] }, + "lead2-keepsimple": { + "title": "ENGINEERING LEAD · KEEPSIMPLE", + "cjk": "長", + "desc": "Human custodian of the KeepSimple open-source wing — pairs with the AI engineering lead.", + "rows": [ + { "k": "kind", "v": "human", "cls": "gold" }, + { "k": "role", "v": "engineering lead" }, + { "k": "pairs", "v": "keepsimple", "cls": "red" }, + { "k": "ring", "v": "III — core products" } + ] + }, + "lead-terminal": { "title": "ENGINEERING LEAD · TERMINAL", "cjk": "長", diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 5537b12..33fb8a8 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -1009,6 +1009,7 @@ function tallyDiamonds(data: any) { ((data.projects && data.projects.members) || []).forEach((p: any) => { tally(p.diamond); if (p.leadDiamond) tally(p.leadDiamond); + if (p.leadDiamond2) tally(p.leadDiamond2); (p.children || []).forEach((c: any) => tally(c.diamond)); }); return { humans, agents, products }; @@ -1634,6 +1635,26 @@ function AiAtlasApp() { role: 'lead', }, }; + // Optional second lead (e.g. when a project has both an AI and a human + // engineering lead). Opt-in via `leadDiamond2`; `leadDeg2` / `leadR2` + // control its placement relative to the project pin. + if (p.leadDiamond2) { + const lead2Id = `lead2-${p.id}`; + const lead2Offset = + p.leadDeg2 !== undefined ? p.leadDeg2 : -data.projects.leadDeg; + const lead2R = p.leadR2 !== undefined ? p.leadR2 : data.projects.r; + const lead2Pos = POL(lead2R, p.theta + lead2Offset); + m[lead2Id] = { + ...lead2Pos, + ring: 'projects', + node: { + id: lead2Id, + label: t.engLeadLabel, + diamond: p.leadDiamond2, + role: 'lead', + }, + }; + } }); data.projects.members.forEach((p: any) => { const n = p.children.length; @@ -1730,6 +1751,7 @@ function AiAtlasApp() { data.projects.members.forEach((p: any) => { set.add(p.id); set.add(`lead-${p.id}`); + if (p.leadDiamond2) set.add(`lead2-${p.id}`); }); if (ringKey === 'territories') data.projects.members.forEach((p: any) => @@ -1740,13 +1762,19 @@ function AiAtlasApp() { if (pinnedSolo) return set; const fp = points[highlightId]; if (!fp) return set; - if (highlightId.startsWith('lead-')) { - const projId = highlightId.slice(5); + if (highlightId.startsWith('lead2-') || highlightId.startsWith('lead-')) { + const projId = highlightId.startsWith('lead2-') + ? highlightId.slice(6) + : highlightId.slice(5); set.add(projId); const proj = data.projects.members.find((p: any) => p.id === projId); if (proj) proj.children.forEach((c: any) => set.add(c.id)); } else { set.add(`lead-${highlightId}`); + const focusProj = data.projects.members.find( + (p: any) => p.id === highlightId, + ); + if (focusProj && focusProj.leadDiamond2) set.add(`lead2-${highlightId}`); } if (highlightId === 'terminal') { if (data.order.member.diamond === 'blue') set.add(data.order.member.id); @@ -1763,7 +1791,10 @@ function AiAtlasApp() { set.add(fp.parent); set.add(`lead-${fp.parent}`); const parent = data.projects.members.find((p: any) => p.id === fp.parent); - if (parent) parent.children.forEach((c: any) => set.add(c.id)); + if (parent) { + if (parent.leadDiamond2) set.add(`lead2-${fp.parent}`); + parent.children.forEach((c: any) => set.add(c.id)); + } } if (highlightId === 'wolf') { set.add('order'); @@ -1776,6 +1807,7 @@ function AiAtlasApp() { data.projects.members.forEach((p: any) => { set.add(p.id); set.add(`lead-${p.id}`); + if (p.leadDiamond2) set.add(`lead2-${p.id}`); }); } if (fp.ring === 'dev') set.add('order'); @@ -1937,6 +1969,19 @@ function AiAtlasApp() { /> ))} + {data.projects.members + .filter((p: any) => p.leadDiamond2) + .map((p: any) => ( + + ))} + {data.projects.members.flatMap((p: any) => p.children .filter((c: any) => !c.noSpoke) @@ -2081,6 +2126,27 @@ function AiAtlasApp() { ); })} + {data.projects.members + .filter((p: any) => p.leadDiamond2) + .map((p: any) => { + const id = `lead2-${p.id}`; + return ( + + ); + })} + {data.projects.members.flatMap((p: any) => p.children.map((c: any) => { const labelForWidth = c.redacted From 305749e5e5c3c7296197d4f9d358dee27a17e684 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 27 May 2026 21:28:07 +0000 Subject: [PATCH 48/51] chore(ai-atlas): seed lead-keepsimple claudeMdLines fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a static value, the claude.md line-count row only appears after the live metrics fetch resolves — leaving a perceptible flash where the AI lead's dossier shows no count. Seed 34 so the row renders instantly; the live metrics feed still overrides it whenever fresher. Mirrors lead-seogeosolved's existing static fallback pattern. Co-Authored-By: Claude Opus 4.7 --- public/ai-atlas/data-ru.json | 1 + public/ai-atlas/data.json | 1 + 2 files changed, 2 insertions(+) diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index da2ec79..ec7c939 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -492,6 +492,7 @@ "title": "ТЕХНИЧЕСКИЙ ЛИД · KEEPSIMPLE", "cjk": "長", "desc": "Технический лид, прикреплён к open-source крылу KeepSimple.", + "claudeMdLines": 34, "rows": [ { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, { "k": "роль", "v": "технический лид" }, diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index 6e15ac5..0ad2014 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -487,6 +487,7 @@ "title": "ENGINEERING LEAD · KEEPSIMPLE", "cjk": "長", "desc": "Engineering lead attached to the KeepSimple open-source wing.", + "claudeMdLines": 34, "rows": [ { "k": "kind", "v": "ai agent", "cls": "blue" }, { "k": "role", "v": "engineering lead" }, From 54558f8c5bd909bdbebe7cad168210c04d351131 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 27 May 2026 21:34:23 +0000 Subject: [PATCH 49/51] =?UTF-8?q?fix(widget):=20never=20auto-engage=20on?= =?UTF-8?q?=20page=20load=20=E2=80=94=20open=20+=20flash=20require=20user?= =?UTF-8?q?=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes so the Copilot stays fully dormant until the visitor opens the pill themselves: 1. Boot the panel closed every time. Previously the open state was restored from localStorage, so a visitor who had ever left the panel open would see it auto-pop on the next visit — even on a different page — with no fresh signal that the Copilot was acting. 2. Seed `lastFlashedTurnIdRef` with the latest restored turn id. The host-page highlight effect fires whenever the most recent turn has citations, which means rehydrated turns from a prior session were re-flashing UI elements on page load — visible to the visitor while the closed pill gave no hint where the highlight was coming from. Seeding the ref makes the effect skip the rehydrated last turn and only ever fire for turns the visitor produces in the current session. The persisted transcript is still loaded (and visible once the visitor opens the pill) — only the proactive side-effects are gated. Co-Authored-By: Claude Opus 4.7 --- widget/src/AskUxCore.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index 23ceb0c..d996d58 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]; From 168f16890a0aecc2fbf46f3a6697159650a35556 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 27 May 2026 21:53:55 +0000 Subject: [PATCH 50/51] feat(admin/copilot-sessions): add Typed? column for user-text sessions Adds a column that tells at-a-glance whether a session contains at least one user-typed message (event kind = "question") versus nav/dwell/card-click-only browsing noise. Computed in getServerSideProps via N parallel /events fetches, fine for the limit=100 dev viewer; a single-query aggregation on the events service would be cheaper for larger limits. Co-Authored-By: Claude Opus 4.7 --- src/lib/copilotEventsRead.ts | 37 ++++++++++++++++++++++ src/pages/admin/copilot-sessions/index.tsx | 15 +++++++++ 2 files changed, 52 insertions(+) diff --git a/src/lib/copilotEventsRead.ts b/src/lib/copilotEventsRead.ts index 9ba8c3c..893bda3 100644 --- a/src/lib/copilotEventsRead.ts +++ b/src/lib/copilotEventsRead.ts @@ -64,6 +64,43 @@ export async function listSessions( } } +/* Admin-only. Returns a map of session_id → bool indicating whether + the session contains at least one user-typed message (event kind + = "question"). Issued as N parallel fetches against the per-session + /events endpoint; fine for the dev admin's limit=100 page, but a + server-side aggregation on the events service would be cheaper. */ +export async function hasUserQuestionsBulk( + sids: string[], +): Promise> { + if (!copilotEventsReadEnabled() || sids.length === 0) return {}; + const entries = await Promise.all( + sids.map(async sid => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch( + `${BASE}/sessions/${encodeURIComponent(sid)}/events`, + { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + signal: ctrl.signal, + }, + ); + if (!r.ok) return [sid, false] as const; + const j = (await r.json().catch(() => null)) as { + events?: EventRow[]; + } | null; + const events = Array.isArray(j?.events) ? j!.events! : []; + return [sid, events.some(e => e.kind === 'question')] as const; + } catch { + return [sid, false] as const; + } finally { + clearTimeout(timer); + } + }), + ); + return Object.fromEntries(entries); +} + /* Admin-only. Debug field contains the internal service URL plus status/body slices; never expose this return value to a non-admin caller. The /admin/copilot-sessions pages are env-gated. */ diff --git a/src/pages/admin/copilot-sessions/index.tsx b/src/pages/admin/copilot-sessions/index.tsx index a3455f5..0fe9cc4 100644 --- a/src/pages/admin/copilot-sessions/index.tsx +++ b/src/pages/admin/copilot-sessions/index.tsx @@ -9,6 +9,7 @@ import { useRouter } from 'next/router'; import { copilotEventsReadEnabled, + hasUserQuestionsBulk, listSessions, type SessionRow, } from '@lib/copilotEventsRead'; @@ -21,6 +22,7 @@ type EnvTab = (typeof VALID_ENVS)[number]; type Props = { envTab: EnvTab; sessions: SessionRow[]; + hasUserText: Record; enabled: boolean; }; @@ -37,10 +39,14 @@ export const getServerSideProps: GetServerSideProps = async ctx => { ? (q as EnvTab) : 'dev'; const sessions = await listSessions(envTab, 100); + const hasUserText = await hasUserQuestionsBulk( + sessions.map(s => s.session_id), + ); return { props: { envTab, sessions, + hasUserText, enabled: copilotEventsReadEnabled(), }, }; @@ -62,6 +68,7 @@ function shortSid(sid: string): string { export default function CopilotSessionsIndex({ envTab, sessions, + hasUserText, enabled, }: Props) { const router = useRouter(); @@ -114,6 +121,7 @@ export default function CopilotSessionsIndex({ Lang Events Threads + Typed? Linked user First URL @@ -134,6 +142,13 @@ export default function CopilotSessionsIndex({ {s.lang ?? '—'} {s.event_count} {s.thread_count} + + {hasUserText[s.session_id] ? ( + 'yes' + ) : ( + + )} + {s.linked_user ? ( {s.linked_user} From c798059353eb8b542070b217c52a54b1a3cfeaea Mon Sep 17 00:00:00 2001 From: manager Date: Fri, 29 May 2026 07:20:14 +0000 Subject: [PATCH 51/51] chore(uxcore): drop stale toggleIsProductView prop and remove staging infra docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove dead toggleIsProductView prop from UXCoreModal (type field + call site) — it was unused inside the component. Also remove internal staging-to-prod assessment docs that should not live in the public repo. Co-Authored-By: Claude Opus 4.7 --- src/pages/uxcore/[slug].tsx | 1 - .../components/UXCoreModal/UXCoreModal.tsx | 1 - staging-to-prod-assessment.md | 70 ---------------- staging-to-prod-order-response-2026-05-25.md | 82 ------------------- 4 files changed, 154 deletions(-) delete mode 100644 staging-to-prod-assessment.md delete mode 100644 staging-to-prod-order-response-2026-05-25.md diff --git a/src/pages/uxcore/[slug].tsx b/src/pages/uxcore/[slug].tsx index 6b1fc19..79fd871 100644 --- a/src/pages/uxcore/[slug].tsx +++ b/src/pages/uxcore/[slug].tsx @@ -169,7 +169,6 @@ const UXCoreIds: FC = ({ ) : ( void; onChangeBiasId: (nextBiasId: number, nextBiasName: string) => void; isProductView: boolean; - toggleIsProductView: () => void; isSecondView: boolean; secondViewLabel: string; setIsModalClosed: (isModalClosed: boolean) => void; diff --git a/staging-to-prod-assessment.md b/staging-to-prod-assessment.md deleted file mode 100644 index 6ed4c70..0000000 --- a/staging-to-prod-assessment.md +++ /dev/null @@ -1,70 +0,0 @@ -# Staging → Prod safety assessment (2026-05-25) - -Baseline assumption: **staging == origin/dev tip** (needs confirmation from Wolf / The Order). -Comparison: `origin/main` (prod) → `origin/dev` (presumed staging). - -## Scale of the push - -- 101 commits, 2244 files changed, +134,848 / -2,569 lines. -- Bulk is the UX Core merge (folded May 14): `src/uxcore/` alone is +95,760 lines, `public/` +12,309. Net new product code (Copilot, auth, AI Atlas updates) is the smaller, hotter slice. -- Last prod push: hotfix #108 (`hotfix/delete-test-login`) on 2026-05-11. PR #102 (dev→main) on 2026-05-08. **17 days of dev work** accumulated since prod last moved. - -## Green / verified safe - -1. **Hotfix #108 preserved.** `src/pages/api/test-login.ts` is absent on **both** main and dev. The unauthenticated JWT-mint endpoint will not re-appear post-merge. -2. **No DB migrations / SQL files** in the diff. No Strapi schema changes inside this repo. -3. **No `_document.tsx` touch.** `_app.tsx` modified (locale/context/atlas-class wiring + UX Core context Proxy) — diff is non-trivial but localized. - -## Red — needs Order verification before push - -1. **Copilot analytics depends on Postgres `copilot-events` service.** - - New endpoints on dev: `/api/copilot/event`, `/api/concierge`, `/api/concierge-landing`, plus `/admin/copilot-sessions`. - - New libs: `src/lib/copilotAnalytics.ts`, `src/lib/copilotEventsRead.ts`. - - Strapi sink was ripped out (PR `4635feb feat(copilot): swap analytics sink from Strapi to copilot-events Postgres`). - - **PROD MUST HAVE:** the `copilot-events` Postgres service running + reachable from the prod container, and the Postgres connection env vars (`COPILOT_EVENTS_*` or equivalent) set in prod secrets. Without these, every Copilot event POST and concierge turn will throw on log-write. Fire-and-forget should swallow errors (per `event.ts` design), but admin viewer (`/admin/copilot-sessions`) will be empty / errored. - -2. **`.env.example` changed.** New required env keys may have been introduced and not yet provisioned in prod. Order needs to diff `.env.example` against prod's actual env and add what's missing **before** the dev → main merge runs the build. - -3. **24 new npm dependencies, yarn.lock +2047/-329.** - - Notable: `isomorphic-dompurify`, `marked`, `rehype-sanitize` (markdown/XSS surface), `d3-geo` + topojson stack, `victory`, `react-toastify`, `web-vitals`, `tsx`, `linkinator`, `axe-core`. - - Prod build must `yarn install` cleanly — confirm no private-registry deps and Node 18.18.0 image still resolves all of them. - -4. **Magic-link auth flow added.** `src/pages/auth/magic-link.tsx`, `MagicLinkEmailForm`, plus changes to `[...nextauth].ts`. Mailer (SMTP / provider) must be configured in prod env. NextAuth callback URLs (Google/LinkedIn/Discord) for keepsimple.io domain must still be whitelisted in their respective OAuth consoles after the auth refactor. - -5. **`build-fetch-patch.js` Strapi guardrail.** Injected via `NODE_OPTIONS=--require=./build-fetch-patch.js` in `yarn build:staging`. Confirm prod's build command path also wires this in — if prod calls `yarn build` (plain), Strapi 5xx during build will crash the deploy. - -6. **`.github/workflows/cypress-manual.yml` shows up in the diff.** Per `CLAUDE.local.md` Cypress was already removed (PR #103). Likely a no-op dead workflow — won't block prod, just clutter. - -## Yellow — process risk - -- **101-commit single jump.** No incremental staging cadence inside the window; the whole 17-day backlog rides together. Rollback path is `git revert b1e6f9f` (last merge to main) — works, but loses everything. -- **Staging coverage uncertain.** I do not know: - - Which exact commit / branch staging.keepsimple.io is serving. - - Whether QA has run a full smoke / canonical pass on that build. - - Whether the magic-link flow has been exercised end-to-end on staging with a real mailer. - -## What's NOT in this push (clarifying scope) - -- `feat/uxcore-cybersec` (current local branch, 30 commits ahead of dev) — OffSec layer, AI Atlas tweaks, Hexens/kemmio attribution. **Not on dev, therefore not on staging, therefore not in this prod push.** Stays in the queue. - -## Recommendation - -**Conditional GO**, contingent on Order confirming, on the prod host: - -1. `copilot-events` Postgres service exists, is running, and its DSN/creds are in the prod container env. -2. `.env.example` keys not yet in prod env are added. -3. Magic-link mailer creds present; OAuth callback URLs unchanged. -4. Prod build command includes the `build-fetch-patch.js` guardrail (or equivalent Strapi 5xx tolerance). -5. Staging is in fact on `origin/dev` tip and has cleared a QA smoke pass in the last 24h. - -If any of (1)–(4) is missing, **do not merge dev → main yet**. Item (5) needs Wolf / QA, not Order. - -## SEND TO @TheOrder draft (for Wolf to relay) - -> Before we merge dev → main on keepsimpleio/KeepSimpleOSS (101 commits, prod push), please verify on the prod host: -> -> 1. `copilot-events` Postgres service is running and its connection env vars are set in the prod KS container. -> 2. Any new keys in `.env.example` (vs `origin/main`) are present in prod env. -> 3. Magic-link SMTP/mailer creds are configured; OAuth callback URLs (Google/LinkedIn/Discord) for keepsimple.io still match the refactored NextAuth handler. -> 4. The prod build command path wires `NODE_OPTIONS=--require=./build-fetch-patch.js` (Strapi 5xx absorber) — same way `yarn build:staging` does. -> Also: which exact commit is `staging.keepsimple.io` serving right now? Need that to confirm staging == dev tip. diff --git a/staging-to-prod-order-response-2026-05-25.md b/staging-to-prod-order-response-2026-05-25.md deleted file mode 100644 index 7a51633..0000000 --- a/staging-to-prod-order-response-2026-05-25.md +++ /dev/null @@ -1,82 +0,0 @@ -# The Order — readiness check response (2026-05-25) - -Responding to `staging-to-prod-assessment.md`. Audited the prod host + ks-contabo staging + the keepsimple repo @ `origin/dev` + the GH Actions CI workflow. - -## The pipeline you're merging into (correction to assessment framing) - -Prod deploy is **fully automatic** the moment `dev → main` lands. There is no manual `docker build` on the prod host: - -1. GitHub Actions `.github/workflows/main.yaml` fires on push to `main`. -2. CI runs `docker build` using a **base64-encoded `ENV_PRODUCTION` GH Actions secret** as both `.env` and `.env.staging` baked into the image, runs `yarn build:staging`, pushes `/keepsimple-next:prod`. -3. Watchtower on the prod host polls the registry and recreates `keepsimple-next-staging` (yes, container is misnamed) when a new digest appears. - -So **prod env secrets do NOT live on the prod host** — they live in the GH Actions secret `ENV_PRODUCTION` (and `ENV_STAGING` for dev pushes). Verifying the prod host's `.env` is meaningless for keys baked at build time; only the GH secret matters. The verification target for items 1-3 is **the `ENV_PRODUCTION` secret on the keepsimpleio/KeepSimpleOSS GitHub repo**, set by Wolf or me (The Order) before the merge. - -This also means: **the merge itself is the deploy.** No staged go/no-go after the click. - -## Item-by-item - -### 1. `copilot-events` Postgres service — RED - -- No `copilot-events` container exists on prod. Only `keepsimple-db-prod` (the main Strapi postgres:12) is up. -- Required env vars (`COPILOT_EVENTS_URL`, `COPILOT_EVENTS_WRITE_TOKEN`, `COPILOT_EVENTS_READ_TOKEN`) are **absent from both the staging AND prod baked env**. -- Implication: per `event.ts` fire-and-forget design, runtime won't crash, but every Copilot session writes 0 events and `/admin/copilot-sessions` returns empty. **Same is true on staging today** — Copilot analytics has likely never been exercised end-to-end with a real sink. -- Decision needed from Wolf: - - (A) Stand up the `copilot-events` Postgres service (new container) + provision tokens + add to `ENV_PRODUCTION` + `ENV_STAGING` BEFORE merge. The Order can do this in ~30 min if KEEPSIMPLE provides the schema/migration file or service compose snippet. - - (B) Accept Copilot ships dark on prod, fix in a follow-up. Admin viewer empty, no telemetry, no crash. - -### 2. New `.env.example` keys missing on prod — YELLOW (but only meaningfully missing: `MAILRU_*`) - -Diffing prod's baked env against the dev `.env.example`: - -| Key | In prod baked env? | Impact | -| ----------------------------------------------------- | ------------------ | ----------------------------------------- | -| `NEXT_PUBLIC_AHREFS_ANALYTICS_KEY` | NO | Ahrefs analytics tag won't render on prod | -| `MAILRU_CLIENT_ID` / `MAILRU_CLIENT_SECRET` | NO | Mailru OAuth button will fail post-merge | -| `COPILOT_EVENTS_URL` / `_WRITE_TOKEN` / `_READ_TOKEN` | NO | Covered in item 1 | -| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | YES (already set) | OK | -| `NEXTAUTH_SECRET`, OAuth (Google/LinkedIn/Discord) | YES | OK, hotfix #108 era already set them | - -Action: update `ENV_PRODUCTION` GH secret to include the missing keys before merge. The Order needs values from Wolf (Ahrefs key) and from Mailru OAuth console registration (CLIENT_ID/SECRET, redirect_uri whitelist for `https://keepsimple.io/api/auth/callback/mailru`). - -### 3. Magic-link mailer + OAuth callbacks — GREEN (with one ask of KEEPSIMPLE) - -- **No SMTP/EmailProvider in the Next.js NextAuth handler.** The Next.js app only _consumes_ magic links via `/auth/magic-link` (`consumeMagicLink` from `@api/auth`). The send-side mailer lives in the **backend** (`uxcat-api` or Strapi) — already running on prod, out of scope for this Next.js push. -- OAuth providers wired in handler: Google, LinkedIn, Discord, Twitter, Yandex, **Mailru (new)**. Existing 5 keep their redirect URIs unchanged (paths weren't refactored). Mailru is the only one requiring console setup. -- **Confirm with KEEPSIMPLE:** has the magic-link flow been exercised end-to-end on staging with a real email landing in a real inbox? If yes, send-side mailer is configured in uxcat-api/Strapi and prod inherits the same backend. If no, magic-link UI ships dark and that's a separate fix. - -### 4. `build-fetch-patch.js` Strapi guardrail — GREEN, already done - -The Dockerfile **unconditionally runs `yarn build:staging`** for both `:prod` and `:staging` tags. That command includes `NODE_OPTIONS=--require=./build-fetch-patch.js`. Strapi 5xx absorber is already protecting every prod build. **No action needed.** Strike this item from the gate list. - -### 5. Staging-commit confirmation — staging IS on dev tip (with caveat) - -- ks-contabo's `keepsimple-next-staging` container runs `keepsimple-next:staging`, image **built 2026-05-22 15:05 UTC**. -- GH Actions auto-builds `:staging` on every push to `origin/dev`. -- `origin/dev` tip locally is `f659653` (Merge PR #119 chore/minor-improvements). -- If anything pushed to `dev` after 2026-05-22 15:05 UTC, staging is N commits behind dev tip. Likely 0-2 commits behind based on commit cadence — Wolf, check `git log --since='2026-05-22 15:05Z' origin/dev` if exact-tip-on-staging matters for QA sign-off. - -## Recommended sequence - -1. KEEPSIMPLE answers: was magic-link send-side tested on staging with a real inbox? (5-line reply) -2. Wolf decides: copilot-events Postgres before merge (A) or after (B)? -3. If (A): The Order stands up service + tokens + appends to both GH secrets (~30 min, needs schema from KEEPSIMPLE). -4. The Order updates `ENV_PRODUCTION` GH secret with: AHREFS key (from Wolf), MAILRU OAuth creds (from Wolf via Mailru console), copilot-events tokens (from step 3). -5. The Order does same updates to `ENV_STAGING` secret to keep parity, pushes a no-op commit to `dev` to rebuild staging with full env, smoke-checks at staging.keepsimple.io. -6. Merge `dev → main` in PR UI. Watchtower picks up in ≤1 hr. -7. The Order watches prod healthcheck + Copilot endpoint logs for 30 min post-deploy. - -## SEND TO @Keepsimple draft (for Wolf to relay) - -> The Order ran the readiness audit. Three corrections to your assessment: -> -> 1. Your item 4 (build-fetch-patch guardrail) is ALREADY active for prod — the Dockerfile runs `yarn build:staging` for the `:prod` tag too, so the Strapi 5xx absorber is wrapped around every prod build. Drop this item. -> 2. Your item 3 (magic-link mailer): the Next.js app only consumes magic links — it never sends. Send-side mailer lives in the backend (uxcat-api or Strapi), not Next.js. ONE question for you: has the magic-link flow been tested end-to-end on staging with a real email landing in a real inbox? If yes → backend mailer is already configured, no prod risk. If no → magic-link ships dark, plan a follow-up. -> 3. Items 1, 2 (copilot-events Postgres + missing env keys): your framing of "Order needs to verify on the prod host" is wrong layer. Prod secrets are not on the prod host — they're baked into the image at build time from the `ENV_PRODUCTION` GitHub Actions secret. Same for staging from `ENV_STAGING`. Verified by comparing the prod image's `/app/.env` against `.env.example`. Confirmed missing in prod's baked env: `MAILRU_CLIENT_ID/SECRET`, `NEXT_PUBLIC_AHREFS_ANALYTICS_KEY`, all three `COPILOT_EVENTS_*`. Same gaps on the staging baked env (so Copilot analytics has never had a real sink, including on staging). -> -> Decisions for Wolf: -> -> - (A) Stand up `copilot-events` Postgres before merge — need the service compose snippet + DB schema/migration from you. I can deploy + token-provision in ~30 min once those land. -> - (B) Ship Copilot dark on prod, fix in a follow-up. No crash (event.ts swallows errors), just empty admin viewer. -> -> Staging is on `:staging` image built 2026-05-22 15:05 UTC; if any dev commits landed after that, staging is N behind. Local `origin/dev` tip is `f659653` (Merge PR #119). If you need staging at exact dev tip for QA sign-off, push a no-op or let me re-trigger the build after the env secret update.