diff --git a/.gitignore b/.gitignore index 868617fa..db4fec52 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 diff --git a/CLAUDE.md b/CLAUDE.md index 08a54a1f..a07a15aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,19 +1,34 @@ -# 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. +> **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. + +MemPalace wing: `keepsimple` (protocol lives in `~/.claude/CLAUDE.md`). + +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. ## Code search — prefer CodeGraph over Grep -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: +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 + +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 -- `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 +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. -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. +- 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 +## MemPalace usage (wing: `keepsimple`) -See `AGENTS.md` for repo conventions, build/test commands, and contribution rules. +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/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index 979995be..ec7c9392 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": 28, "children": [ { "id": "orchestrator", @@ -193,6 +194,18 @@ ], "territoryLabel": "B2B SAAS" }, + { + "id": "seogeosolved", + "label": "SeoGeoSolver", + "sub": "seo + geo", + "diamond": "red", + "theta": 26, + "status": "ok", + "leadDiamond": "blue", + "territoryArc": 0, + "children": [], + "territoryLabel": "" + }, { "id": "keepsimple", "label": "KeepSimple", @@ -200,7 +213,8 @@ "diamond": "red", "theta": 180, "status": "ok", - "leadDiamond": "gold", + "leadDiamond": "blue", + "leadDiamond2": "gold", "territoryArc": 70, "children": [ { @@ -410,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": "созвездие публичного влияния" } ] @@ -471,11 +491,29 @@ "lead-keepsimple": { "title": "ТЕХНИЧЕСКИЙ ЛИД · KEEPSIMPLE", "cjk": "長", - "desc": "Человек-куратор open-source крыла.", + "desc": "Технический лид, прикреплён к open-source крылу KeepSimple.", + "claudeMdLines": 34, + "rows": [ + { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, + { "k": "роль", "v": "технический лид" }, + { + "k": "полномочия", + "v": "кодовая база keepsimple.io · UX Core · AI Atlas" + }, + { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, + { "k": "кольцо", "v": "III — ключевые продукты" } + ] + }, + + "lead2-keepsimple": { + "title": "ТЕХНИЧЕСКИЙ ЛИД · KEEPSIMPLE", + "cjk": "長", + "desc": "Человек-куратор open-source крыла KeepSimple — работает в паре с ИИ-лидом.", "rows": [ { "k": "тип", "v": "человек", "cls": "gold" }, - { "k": "кольцо", "v": "III — ключевые продукты" }, - { "k": "пара", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" } + { "k": "роль", "v": "технический лид" }, + { "k": "пара", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, + { "k": "кольцо", "v": "III — ключевые продукты" } ] }, @@ -508,6 +546,40 @@ ] }, + "seogeosolved": { + "title": "SEOGEOSOLVER", + "cjk": "索", + "desc": "Мастерская по поисковой и генеративной оптимизации. Инструменты и аудиты для нового слоя ранжирования — где вопрос не «есть ли ты в Google», а «цитирует ли тебя модель». В паре с Multimove внутри контент/PR-направления.", + "rows": [ + { "k": "тип", "v": "продукт", "cls": "red" }, + { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "лид", + "v": "ИИ инженерный агент", + "cls": "blue", + "ref": "lead-seogeosolved" + }, + { "k": "партнёр", "v": "multimove", "cls": "red", "ref": "multimove" } + ] + }, + + "lead-seogeosolved": { + "title": "ТЕХНИЧЕСКИЙ ЛИД · SEOGEOSOLVER", + "cjk": "長", + "desc": "Технический лид, прикреплён к проекту SeoGeoSolver.", + "claudeMdLines": 142, + "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 9c1782c2..0ad20146 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -101,6 +101,7 @@ "status": "ok", "leadDiamond": "blue", "territoryArc": 70, + "childrenArc": 28, "children": [ { "id": "orchestrator", @@ -193,6 +194,18 @@ ], "territoryLabel": "B2B SAAS" }, + { + "id": "seogeosolved", + "label": "SeoGeoSolver", + "sub": "seo + geo", + "diamond": "red", + "theta": 26, + "status": "ok", + "leadDiamond": "blue", + "territoryArc": 0, + "children": [], + "territoryLabel": "" + }, { "id": "keepsimple", "label": "KeepSimple", @@ -200,7 +213,8 @@ "diamond": "red", "theta": 180, "status": "ok", - "leadDiamond": "gold", + "leadDiamond": "blue", + "leadDiamond2": "gold", "territoryArc": 70, "children": [ { @@ -410,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" } ] @@ -466,11 +486,29 @@ "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.", + "claudeMdLines": 34, + "rows": [ + { "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" } + ] + }, + + "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": "ring", "v": "III — core products" }, - { "k": "pairs", "v": "keepsimple", "cls": "red" } + { "k": "role", "v": "engineering lead" }, + { "k": "pairs", "v": "keepsimple", "cls": "red" }, + { "k": "ring", "v": "III — core products" } ] }, @@ -500,6 +538,40 @@ ] }, + "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.” Partners with Multimove inside the content / PR wing.", + "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": "partner", "v": "multimove", "cls": "red", "ref": "multimove" } + ] + }, + + "lead-seogeosolved": { + "title": "ENGINEERING LEAD · SEOGEOSOLVER", + "cjk": "長", + "desc": "Engineering lead attached to the SeoGeoSolver project.", + "claudeMdLines": 142, + "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/components/Header/Header.module.scss b/src/components/Header/Header.module.scss index 26866261..f54ab9f4 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 7d9ac8ae..e2cef50c 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 ( + + . +

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

+ kemmio + + {' '} + — Vahe Karapetyan{' '} + + + + + + + + +

+ +

+ Among the most consequential whitehat hackers alive. +

+ +

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

+ +
    +
  • + Authored the Aptos critical-vulnerability research — + unpatched, the flaw would have erased over $1T from Web3. +
  • +
  • + Authored the disclosure behind the largest critical + vulnerability in Web3 history — $500M of instant loss and + $1.7T of cascade-effect damage on the table. Caught in private + disclosure; exploitation never landed. +
  • +
  • + Uncovered the first critical Solidity compiler vulnerability + in over a decade — the{' '} + + TSTORE poison bug + + . +
  • +
+
+
, + portalTarget, + )} + + ); +}; + +export default KemmioCredit; diff --git a/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss new file mode 100644 index 00000000..51268e46 --- /dev/null +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.module.scss @@ -0,0 +1,788 @@ +// 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; +} + +.visualBlock { + display: flex; + flex-direction: column; + gap: 10px; + 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; + } + + .scenario { + margin: 0 0 4px; + font-size: 14px; + line-height: 1.5; + color: $ks-ink-soft; + } +} + +.cards { + display: grid; + grid-template-columns: 1fr 36px 1fr; + align-items: stretch; + 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: 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); + + .emailHeader { + display: flex; + 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 { + font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; + font-size: 11.5px; + color: $ks-ink-soft; + word-break: break-all; + flex: 1 1 auto; + min-width: 0; + } + + .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; + gap: 6px; + font-size: 14px; + font-weight: 700; + color: $ks-ink; + line-height: 1.3; + } + + .cardRule { + height: 1px; + background: $ks-rule; + margin: 4px 0 2px; + } + + .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.45; + color: $ks-ink-soft; + } + + .cardAttachment { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + margin-top: 4px; + 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; + + .cardAttachmentIcon { + font-size: 11px; + line-height: 1; + } + } +} + +.cardFlagged { + background: linear-gradient(180deg, $ks-crimson-soft 0%, #ffffff 110%); + border-color: $ks-crimson; + + .cardRule { + background: rgba(200, 65, 42, 0.25); + } +} + +// === 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 { + 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; + justify-content: center; + + .cardArrow { + font-size: 22px; + color: $ks-crimson; + 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; + } +} + +.whyBlock { + 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: 6px; + 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: 6px; + pointer-events: none; + } +} + +.defenderBlock { + background: #f4f1e8; + 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) { + $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; + } + + .visualBlock { + background: $dk-card-soft; + border-color: $dk-rule; + + &::before, + &::after { + border-color: $dk-crimson; + } + + .scenario { + color: $dk-text-soft; + } + } + + .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); + + .cardSender, + .cardTimestamp, + .cardPreview { + color: $dk-text-soft; + } + + .emailEnvelope { + color: $dk-crimson; + } + + .emailFromLabel { + color: $dk-text-soft; + } + + .cardSubject { + color: $dk-text; + } + + .cardRule { + background: $dk-rule; + } + + .cardUrgencyDot { + background: $dk-crimson; + box-shadow: 0 0 0 3px $dk-crimson-glow; + } + + .cardAttachment { + background: $dk-card-soft; + border-color: $dk-rule; + color: $dk-text-soft; + } + } + + .cardFlagged { + background: linear-gradient(180deg, $dk-crimson-soft 0%, $dk-card 110%); + border-color: $dk-crimson; + + .cardRule { + background: rgba(229, 114, 84, 0.35); + } + } + + .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; + } + + .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; + } + + .proseBlock { + background: $dk-card; + border-color: $dk-rule; + + p { + color: $dk-text; + } + } + + .whyBlock { + &::before, + &::after { + border-color: $dk-crimson; + } + } + + .defenderBlock { + background: $dk-card-soft; + border-left-color: $dk-crimson; + } + + .defenderLede { + color: $dk-text; + } + + .defenderMoves li { + color: $dk-text; + + &::before { + background: $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 00000000..074bdb7d --- /dev/null +++ b/src/uxcore/components/OffsecBiasView/OffsecBiasView.tsx @@ -0,0 +1,198 @@ +import { OffsecBiasCard, OffsecBiasContent } from '@uxcore/data/biasOffsec'; + +import KemmioCredit from './KemmioCredit'; + +import styles from './OffsecBiasView.module.scss'; + +interface OffsecBiasViewProps { + content: OffsecBiasContent; +} + +const CardBody = ({ card }: { card: OffsecBiasCard }) => { + if (card.kind === 'email') { + return ( + <> +
+ + From + {card.sender} + {card.timestamp && ( + {card.timestamp} + )} +
+
+ {card.flagged && } + {card.subject} +
+
+
{card.preview}
+ {card.attachment && ( +
+ 📎 + {card.attachment} +
+ )} + + ); + } + + 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 ( + <> +
+
+
+ {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; + + return ( +
+
+ {content.visualLabel} +

{content.scenario}

+ +
+
+ {before.tag} +
+ +
+
+ +
+ +
+ +
+ + {after.tag} + +
+ +
+
+
+
+ +
+ {content.whyItWorksLabel} +

{content.whyItWorks}

+
+ +
+ {content.defenseLabel} +

{content.defense.lede}

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

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 + OffSec +
+
{isCoreView && } {isCoreView && ( <> diff --git a/src/uxcore/styles/globals.scss b/src/uxcore/styles/globals.scss index c6fd90a2..7a0330fb 100644 --- a/src/uxcore/styles/globals.scss +++ b/src/uxcore/styles/globals.scss @@ -15,7 +15,6 @@ html { /* width */ &::-webkit-scrollbar { width: 8px; - border-left: 1px solid #fafafa; } /* Track */ @@ -26,7 +25,6 @@ html { /* Handle */ &::-webkit-scrollbar-thumb { border-radius: 5px; - border-left: 1px solid #fafafa; } } diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index 23ceb0c4..d996d585 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -1766,7 +1766,10 @@ const applyHostHighlight = ( export function AskUxCore({ lang }: { lang: Lang }) { const initial = typeof window !== 'undefined' ? loadState() : null; - const [open, setOpen] = useState(initial?.open ?? false); + // Always boot closed. The widget should never reveal itself or its + // effects (host-page highlights, etc.) until the visitor explicitly + // opens the pill — even if the previous session ended with it open. + const [open, setOpen] = useState(false); const [text, setText] = useState(''); const [turns, setTurns] = useState(initial?.turns ?? []); const [loading, setLoading] = useState(false); @@ -2742,8 +2745,17 @@ export function AskUxCore({ lang }: { lang: Lang }) { /* Articles-page experiment: when fresh cards land, flash the matching tiles on the host page so the visitor sees "here, look - at these" in context, not just in the widget. */ - const lastFlashedTurnIdRef = useRef(null); + at these" in context, not just in the widget. + Seed the ref with the last RESTORED turn id so the flash effect + only ever fires for turns the visitor produced in *this* session + — never for stale turns rehydrated from localStorage. Without this + seed, a returning visitor sees host elements light up on page load + with no obvious cause (the panel is closed). */ + const lastFlashedTurnIdRef = useRef( + initial?.turns && initial.turns.length > 0 + ? initial.turns[initial.turns.length - 1].id + : null, + ); useEffect(() => { if (!isHighlightEnabledPage()) return; const last = turns[turns.length - 1]; diff --git a/widget/src/styles.css b/widget/src/styles.css index 91b58fa5..c36626e0 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -1251,6 +1251,17 @@ background: #2c2926; } +/* === Laptop fit (481px – 1280px) === + On smaller laptops a 480px panel chews into the host page's navbar + and other right-aligned chrome. Narrow it without flipping into the + mobile near-full-screen treatment. */ +@media (min-width: 481px) and (max-width: 1280px) { + .ks-aux-panel { + width: 380px; + height: min(620px, calc(100dvh - 120px)); + } +} + /* === Mobile usability (≤480px) === Phone screens can't host a fixed 480×660 panel comfortably. Panel becomes near-full-screen, pill anchors to the bottom-right with a