diff --git a/.claude/commands/qa-canonical.md b/.claude/commands/qa-canonical.md new file mode 100644 index 00000000..5aaeee61 --- /dev/null +++ b/.claude/commands/qa-canonical.md @@ -0,0 +1,42 @@ +You are running a QA pass on keepsimple.io. + +Profile: **canonical** — the desktop half of a full pass. Source of truth for desktop findings. + +Scope: all sections × all locales × desktop. +Time budget: 90-120 min, 30-min hard cap per section. +Output: reports/YYYY-MM-DD.md (and rendered .html) +Diff target: previous canonical run. + +Note: canonical alone is incomplete. Pair with mobile-followup for a full-matrix pass. + +SETUP + +1. Read .claude/keepsimple-qa/SKILL.md in full. This is your playbook for method. +2. Read qa-config.yml for routes, locales, viewports, and any per-section `auth_required:` lists. +3. Read known-issues.md — do not refile anything listed there. +4. Read .claude/keepsimple-qa/PROFILES.md and confirm the canonical profile spec matches what's above. Treat the profile as authoritative for scope; treat the skill as authoritative for method. +5. Confirm Playwright MCP is connected before proceeding. If not, stop and report what's missing. + +EXECUTION + +Run the pass per the skill. Honor the profile's scope exactly. Routes listed under `auth_required:` in their section are recorded as `skipped — auth required (configured)` and never visited. If you must cut scope mid-run, follow the skill's "Run is incomplete" rule — explicit, loud, in the report header. + +OUTPUT + +Produce the report at reports/YYYY-MM-DD.md following the skill's report structure: + +- Metadata header +- Coverage report table +- Findings summary +- Diff vs prior canonical +- Routes covered +- Routes skipped with reason +- Fixed-pass checklist +- JSON finding blocks +- Out-of-scope observations + +Then render HTML: `node .claude/keepsimple-qa/render-report.js reports/YYYY-MM-DD.md`. + +Do NOT auto-update qa-runs/findings-register.md or any other tracking file. The user updates those manually after reviewing the report. + +Return a 5-8 line summary in chat when done: profile, route coverage %, finding counts by severity, persistent vs new breakdown, run status. diff --git a/.claude/commands/qa-deploy-check.md b/.claude/commands/qa-deploy-check.md new file mode 100644 index 00000000..5a5ce81e --- /dev/null +++ b/.claude/commands/qa-deploy-check.md @@ -0,0 +1,53 @@ +You are running a **post-deploy regression check** on keepsimple.io. This is the most token-efficient profile — it only audits routes that could have broken (changed since last run) or were already broken (open findings from the prior run). + +Profile: **deploy-check** — surgical regression pass. + +Scope: + +- **Always include:** every route with an open finding in the most recent canonical / smoke / mobile-followup / full-matrix report. +- **Always include:** every route whose fingerprint differs from the prior batch (i.e. `changed` or `new` per `batch-fingerprint`). +- **Skip:** routes that are both `unchanged` AND have zero open prior findings. + +Time budget: 5 min per route, 30 min hard cap total. Designed to run after every deploy. +Output: reports/YYYY-MM-DD-deploy-check.md (and rendered .html) +Diff target: most recent canonical (or full-matrix) run. + +SETUP + +1. Read .claude/keepsimple-qa/SKILL.md in full. +2. Read qa-config.yml for routes, locales, viewports, and any per-section `auth_required:` lists. +3. Read known-issues.md — do not refile anything listed there. +4. Read .claude/keepsimple-qa/PROFILES.md and confirm the deploy-check profile spec matches what's above. +5. Confirm Playwright MCP is connected before proceeding. If not, stop and report what's missing. + +DECIDE THE SCOPE + +1. Run `node .claude/keepsimple-qa/helper.mjs batch-fingerprint keepsimple --save`. This reads every route from qa-config.yml, fetches each, computes a fingerprint, and compares against the prior batch stored in `qa-runs/state/fingerprints.json`. Output is a JSON array with per-route verdicts: `unchanged | changed | new | errored`. +2. Read the most recent prior report under `reports/`. Extract every finding whose status is open (`persistent`, `persistent (unchanged route)`, `new`, `confirmed`, `not seen this run`). Note their routes. +3. Build the **deploy-check route set** = (routes with `verdict: changed | new` from step 1) ∪ (routes with open prior findings from step 2). +4. Subtract any routes listed under `auth_required:` in their section — those are configured-skip. +5. If the resulting set is empty, write a 5-line "All clear — nothing changed and no open findings" report and exit. This is the happy path post-deploy. + +EXECUTION + +For every route in the deploy-check route set, run the per-route checklist from the skill (snapshot, console+network, click primary elements, fill any forms without submit, scroll, resize) plus the must-work fixed-pass items for any section whose hub route is in the set. Cap at 5 min per route; if you hit the cap mid-route, mark it `partial` and move on. + +For routes NOT in the deploy-check set (unchanged + no open findings), include them in the coverage table with status `unchanged-skip` and `Source: `. + +OUTPUT + +Produce the report at reports/YYYY-MM-DD-deploy-check.md following the skill's report structure: + +- Metadata header (build IDs, change summary from batch-fingerprint) +- **Deploy verdict** at the top: `pass | regressions found | inconclusive` based on whether any new high/critical findings appeared or any prior open findings persisted on changed routes +- Coverage table (every route from qa-config.yml, with per-route status: deep-audited / unchanged-skip / configured-skip) +- Findings summary +- Diff vs prior canonical (status of each prior finding: refuted / persistent / changed) +- JSON finding blocks +- Out-of-scope observations + +Then render HTML: `node .claude/keepsimple-qa/render-report.js reports/YYYY-MM-DD-deploy-check.md`. + +Do NOT auto-update qa-runs/findings-register.md or any other tracking file. The user updates those manually after reviewing the report. + +Return a 5-7 line summary in chat: deploy verdict, route counts (re-audited / skipped-unchanged / skipped-auth-required), prior-findings status (X resolved / Y persistent / Z new), run status. diff --git a/.claude/commands/qa-full-matrix.md b/.claude/commands/qa-full-matrix.md new file mode 100644 index 00000000..cad19737 --- /dev/null +++ b/.claude/commands/qa-full-matrix.md @@ -0,0 +1,40 @@ +You are running a QA pass on keepsimple.io. + +Profile: **full-matrix** — single pass covering everything: every section, every locale, every viewport. + +Scope: all sections × all locales × desktop+mobile. +Time budget: 3-4 hours. May need to be split across multiple Claude Code sessions; if so, sequence them and produce sub-reports per session, then a consolidated final report. +Output: reports/YYYY-MM-DD-full-matrix.md (or reports/YYYY-MM-DD-full-matrix-part-N.md for split runs) (and rendered .html) +Diff target: previous full-matrix run, or most recent canonical+mobile-followup pair. + +SETUP + +1. Read .claude/keepsimple-qa/SKILL.md in full. This is your playbook for method. +2. Read qa-config.yml for routes, locales, viewports, and any per-section `auth_required:` lists. +3. Read known-issues.md — do not refile anything listed there. +4. Read .claude/keepsimple-qa/PROFILES.md and confirm the full-matrix profile spec matches what's above. Treat the profile as authoritative for scope; treat the skill as authoritative for method. +5. Confirm Playwright MCP is connected before proceeding. If not, stop and report what's missing. + +EXECUTION + +Run the pass per the skill. Honor the profile's scope exactly. Routes listed under `auth_required:` in their section are recorded as `skipped — auth required (configured)` and never visited. If the run needs to be split across sessions due to time/context limits, produce part-N reports and call out clearly that a final consolidated report is needed. If you must cut scope mid-run, follow the skill's "Run is incomplete" rule — explicit, loud, in the report header. + +OUTPUT + +Produce the report at reports/YYYY-MM-DD-full-matrix.md (or part-N variant) following the skill's report structure: + +- Metadata header +- Coverage report table +- Findings summary +- Diff vs prior full-matrix or canonical+mobile-followup pair +- Routes covered +- Routes skipped with reason +- Fixed-pass checklist +- JSON finding blocks +- Out-of-scope observations + +Then render HTML: `node .claude/keepsimple-qa/render-report.js reports/YYYY-MM-DD-full-matrix.md` (or the part-N variant). + +Do NOT auto-update qa-runs/findings-register.md or any other tracking file. The user updates those manually after reviewing the report. + +Return a 5-8 line summary in chat when done: profile, route coverage %, finding counts by severity, persistent vs new breakdown, run status. diff --git a/.claude/commands/qa-init.md b/.claude/commands/qa-init.md new file mode 100644 index 00000000..dde88a86 --- /dev/null +++ b/.claude/commands/qa-init.md @@ -0,0 +1,136 @@ +--- +description: Configure (or re-configure) the QA agent for keepsimple.io. Walks through site URL, locales, routes, viewports, and writes qa-config.yml. +--- + +You are the QA agent's setup wizard. Your job is to interactively configure the QA pass by writing or updating `qa-config.yml` at the repo root. + +Do NOT start any QA pass during this wizard. Your only output is the config file (and a friendly recap at the end). + +## Pre-check + +If `qa-config.yml` already exists, ask: "qa-config.yml already exists. Back it up to `qa-config.yml.bak` and rewrite, edit in place, or cancel?" Do NOT silently overwrite. + +If the user picks "edit in place," surface the existing values as defaults at each step so they can keep what's there with a one-word reply. + +## Steps + +Walk the user through these questions a few at a time. Don't dump the whole list. Ask, wait for the answer, ask the next. + +### 1. Site URL + +Ask: "Production site URL? (default: https://keepsimple.io)" + +Then ask: "Staging URL too? (or 'skip')" + +### 2. Locales + +Ask: "Locales to test? (default: en, ru, hy)" + +If non-default, ask: "How are locales URL-prefixed?" + +- **A) default-unprefixed** — en routes are `/uxcore`, ru routes are `/ru/uxcore` _(current default for keepsimple)_ +- **B) all-prefixed** — every route has a locale segment +- **C) no-prefix** — single-locale, no segments + +### 3. Route discovery + +Ask: "How should we discover routes?" + +- **A) Pull from `/sitemap.xml`** +- **B) Crawl from the homepage** (one click deep from `/`) +- **C) Keep the routes in the existing qa-config.yml** _(default if you're re-running)_ +- **D) I'll paste the routes manually** + +If A/B: fetch via Playwright MCP, group discovered URLs by inferred section (path's first segment), present them back, ask: "Confirm these sections + routes? (yes / I want to edit)" + +If D: prompt for routes one section at a time: + +- "Section name (e.g. 'uxcore', 'site'):" +- "Routes for that section, one per line, leading slash, blank line to finish:" +- "Add another section? (yes/no)" + +### 4. Per-section options + +For each section, ask: + +- "Mark this section as `default: true` for smoke runs? (only one section gets this)" +- "Any `primary_interaction:` worth declaring? (e.g. 'open the bias modal on a card', or 'skip')" +- "Any routes in this section that require auth? (comma-separated paths, or 'none')" + +### 5. Viewports + +Ask: "Viewports? (default: desktop 1920×1080, mobile 390×844)" + +### 6. Timezone + +Ask: "Timezone for report timestamps? (default: Asia/Yerevan)" + +### 7. Design system reference + +Ask: "Path to design-system reference doc? (default: uxcore-visual-design-system.md, or 'skip')" + +### 8. Confirm and write + +Show the user the proposed `qa-config.yml` content as a code block. Ask: "Write this to qa-config.yml? (yes/edit)" + +Use this template (fill in the captured values): + +```yaml +# QA agent configuration — read by .claude/skills/keepsimple-qa/SKILL.md +# Generated by /qa-init on YYYY-MM-DD. + +environment: + production: + staging: + +locales: + - + # ... + +locale_url_strategy: + +viewports: + desktop: { width: 1920, height: 1080 } + mobile: { width: 390, height: 844 } + +report_timezone: + +design_system: + +sections: + : + # default: true # uncomment on exactly one section + routes: + - / + - / + # auth_required: + # - / + # primary_interaction: "..." + # ... repeat for each section +``` + +### 9. Recap + +Print: + +``` +✓ qa-config.yml written ({N} sections, {M} locales, {V} viewports) +✓ Default smoke section:
+ +Next steps: + /qa-smoke — quick render check + /qa-locale-smoke — locale routing only + /qa-canonical — desktop pass across all locales + /qa-mobile-followup — mobile pass to pair with canonical + /qa-full-matrix — everything in one report + /qa-deploy-check — surgical post-deploy regression + /qa-retest F#,F# — verify specific finding IDs after a fix +``` + +## Constraints + +- Do NOT begin a QA pass during this wizard. The user must invoke a profile separately. +- Do NOT auto-discover routes by visiting EVERY page on the site — keep discovery to sitemap or a single homepage crawl. +- Do NOT silently overwrite `qa-config.yml`. If one exists, ask first. +- Do NOT install Playwright MCP for the user. If it's not connected, just tell them how to install it (`claude mcp add playwright npx -- @playwright/mcp@latest`) and stop. +- Keep the conversation tight. Two or three questions per turn maximum. diff --git a/.claude/commands/qa-locale-smoke.md b/.claude/commands/qa-locale-smoke.md new file mode 100644 index 00000000..18289953 --- /dev/null +++ b/.claude/commands/qa-locale-smoke.md @@ -0,0 +1,40 @@ +You are running a QA pass on keepsimple.io. + +Profile: **locale-smoke** — confirm locale-prefix routing works across all configured locales. + +Scope: one section × all locales × desktop. Default section is the one marked `default: true` in `qa-config.yml` (or the first section if none is marked). +Time budget: 25 min hard cap. +Output: reports/YYYY-MM-DD-locale-smoke.md (and rendered .html) +Diff target: most recent canonical run. + +SETUP + +1. Read .claude/keepsimple-qa/SKILL.md in full. This is your playbook for method. +2. Read qa-config.yml for routes, locales, viewports, `locale_url_strategy:`, and any per-section `auth_required:` lists. +3. Read known-issues.md — do not refile anything listed there. +4. Read .claude/keepsimple-qa/PROFILES.md and confirm the locale-smoke profile spec matches what's above. Treat the profile as authoritative for scope; treat the skill as authoritative for method. +5. Confirm Playwright MCP is connected before proceeding. If not, stop and report what's missing. + +EXECUTION + +Run the pass per the skill. Honor the profile's scope exactly. Routes listed under `auth_required:` in their section are recorded as `skipped — auth required (configured)` and never visited. If you must cut scope mid-run, follow the skill's "Run is incomplete" rule — explicit, loud, in the report header. + +OUTPUT + +Produce the report at reports/YYYY-MM-DD-locale-smoke.md following the skill's report structure: + +- Metadata header +- Coverage report table +- Findings summary +- Diff vs prior canonical +- Routes covered +- Routes skipped with reason +- Fixed-pass checklist +- JSON finding blocks +- Out-of-scope observations + +Then render HTML: `node .claude/keepsimple-qa/render-report.js reports/YYYY-MM-DD-locale-smoke.md`. + +Do NOT auto-update qa-runs/findings-register.md or any other tracking file. The user updates those manually after reviewing the report. + +Return a 5-8 line summary in chat when done: profile, route coverage %, finding counts by severity, persistent vs new breakdown, run status. diff --git a/.claude/commands/qa-mobile-followup.md b/.claude/commands/qa-mobile-followup.md new file mode 100644 index 00000000..8eb08315 --- /dev/null +++ b/.claude/commands/qa-mobile-followup.md @@ -0,0 +1,42 @@ +You are running a QA pass on keepsimple.io. + +Profile: **mobile-followup** — the mobile half of a full pass. Pairs with canonical to close the full matrix. + +Scope: all sections × all locales × mobile. +Time budget: 90 min, 30-min hard cap per section. +Output: reports/YYYY-MM-DD-mobile.md (and rendered .html) +Diff target: paired canonical run from the same day or build. + +Note: mobile-followup alone is incomplete. Pair with canonical for a full-matrix pass. + +SETUP + +1. Read .claude/keepsimple-qa/SKILL.md in full. This is your playbook for method. +2. Read qa-config.yml for routes, locales, viewports, and any per-section `auth_required:` lists. +3. Read known-issues.md — do not refile anything listed there. +4. Read .claude/keepsimple-qa/PROFILES.md and confirm the mobile-followup profile spec matches what's above. Treat the profile as authoritative for scope; treat the skill as authoritative for method. +5. Confirm Playwright MCP is connected before proceeding. If not, stop and report what's missing. + +EXECUTION + +Run the pass per the skill. Honor the profile's scope exactly. Routes listed under `auth_required:` in their section are recorded as `skipped — auth required (configured)` and never visited. If you must cut scope mid-run, follow the skill's "Run is incomplete" rule — explicit, loud, in the report header. + +OUTPUT + +Produce the report at reports/YYYY-MM-DD-mobile.md following the skill's report structure: + +- Metadata header +- Coverage report table +- Findings summary +- Diff vs paired canonical run +- Routes covered +- Routes skipped with reason +- Fixed-pass checklist +- JSON finding blocks +- Out-of-scope observations + +Then render HTML: `node .claude/keepsimple-qa/render-report.js reports/YYYY-MM-DD-mobile.md`. + +Do NOT auto-update qa-runs/findings-register.md or any other tracking file. The user updates those manually after reviewing the report. + +Return a 5-8 line summary in chat when done: profile, route coverage %, finding counts by severity, persistent vs new breakdown, run status. diff --git a/.claude/commands/qa-retest.md b/.claude/commands/qa-retest.md new file mode 100644 index 00000000..c45dbf71 --- /dev/null +++ b/.claude/commands/qa-retest.md @@ -0,0 +1,48 @@ +You are running a targeted retest on keepsimple.io. + +Profile: **retest** — verify specific findings by ID after a deploy or local fix. + +Scope: only the routes referenced by the named finding IDs (provided by the user in the next message). +Time budget: 5 min per finding ID, 30 min cap. +Output: reports/YYYY-MM-DD-retest-{ids}.md (and rendered .html) +Diff target: report where each finding was last confirmed. + +Before proceeding, ask the user which finding IDs to retest (e.g. "F#7, F#11"). Do not begin browser work until the IDs are confirmed. + +SETUP + +1. Read .claude/keepsimple-qa/SKILL.md in full. This is your playbook for method. +2. Read qa-config.yml for routes, locales, viewports, and any per-section `auth_required:` lists. +3. Read known-issues.md — do not refile anything listed there. +4. Read .claude/keepsimple-qa/PROFILES.md and qa-runs/findings-register.md — the register tells you where each finding was last confirmed. +5. Confirm Playwright MCP is connected before proceeding. If not, stop and report what's missing. + +RETEST METHOD + +For each finding ID provided: + +1. Read the prior report where the finding was last confirmed to understand original reproduction steps, affected routes, and evidence. +2. Reproduce the verification steps on the current production build. +3. Classify the result as one of: + - confirmed (still reproduces, same root cause) + - refuted (no longer reproduces, fix appears to have shipped) + - persistent (reproduces with same signature but you suspect environmental rather than code factors) + - changed (reproduces but with a different signature — note the drift) + +Visit ONLY the routes referenced by the finding IDs. Do not perform broader exploratory testing. This is a focused verification pass, not a coverage pass. If a finding's route is in an `auth_required:` list, mark its retest as `skipped — auth required (configured)` and move on. + +OUTPUT + +Produce the report at reports/YYYY-MM-DD-retest-{ids}.md (replace {ids} with IDs separated by hyphens, e.g. "F5-F7-F11"). The retest report follows a simplified structure: + +- Metadata header (date, build ID, scope as "retest of {ids}") +- Per-finding verification table: ID, last-confirmed report, current status, brief evidence +- Routes touched +- JSON finding blocks ONLY for findings whose status changed (refuted, persistent-with-new-info, changed) +- Build ID note (current vs the build the finding was last confirmed against) + +Then render HTML: `node .claude/keepsimple-qa/render-report.js reports/YYYY-MM-DD-retest-{ids}.md`. + +Do NOT auto-update qa-runs/findings-register.md or any other tracking file. The user updates those manually after reviewing the report. + +Return a 3-5 line summary in chat when done: finding IDs retested, status counts (X confirmed / Y refuted / Z changed), build ID delta if any, anything unexpected. diff --git a/.claude/commands/qa-smoke.md b/.claude/commands/qa-smoke.md new file mode 100644 index 00000000..d0162cbc --- /dev/null +++ b/.claude/commands/qa-smoke.md @@ -0,0 +1,40 @@ +You are running a QA pass on keepsimple.io. + +Profile: **smoke** — fastest possible signal, does the site render at all? + +Scope: one section × default locale × desktop. Default section is the one marked `default: true` in `qa-config.yml` (or the first section if none is marked). Routes from `qa-config.yml`. +Time budget: 15 min hard cap. +Output: reports/YYYY-MM-DD-smoke.md (and rendered .html) +Diff target: most recent canonical run, if any. + +SETUP + +1. Read .claude/keepsimple-qa/SKILL.md in full. This is your playbook for method. +2. Read qa-config.yml for routes, locales, viewports, and any per-section `auth_required:` lists. +3. Read known-issues.md — do not refile anything listed there. +4. Read .claude/keepsimple-qa/PROFILES.md and confirm the smoke profile spec matches what's above. Treat the profile as authoritative for scope; treat the skill as authoritative for method. +5. Confirm Playwright MCP is connected before proceeding. If not, stop and report what's missing. + +EXECUTION + +Run the pass per the skill. Honor the profile's scope exactly. Routes listed under `auth_required:` in their section are recorded as `skipped — auth required (configured)` and never visited. If you must cut scope mid-run, follow the skill's "Run is incomplete" rule — explicit, loud, in the report header. + +OUTPUT + +Produce the report at reports/YYYY-MM-DD-smoke.md following the skill's report structure: + +- Metadata header +- Coverage report table (auth_required routes appear as `skipped — auth required (configured)`) +- Findings summary +- Diff vs prior canonical +- Routes covered +- Routes skipped with reason +- Fixed-pass checklist +- JSON finding blocks +- Out-of-scope observations + +Then render HTML: `node .claude/keepsimple-qa/render-report.js reports/YYYY-MM-DD-smoke.md`. + +Do NOT auto-update qa-runs/findings-register.md or any other tracking file. The user updates those manually after reviewing the report. + +Return a 5-8 line summary in chat when done: profile, route coverage %, finding counts by severity, persistent vs new breakdown, run status. diff --git a/.claude/keepsimple-qa/PROFILES.md b/.claude/keepsimple-qa/PROFILES.md new file mode 100644 index 00000000..bdbca0c5 --- /dev/null +++ b/.claude/keepsimple-qa/PROFILES.md @@ -0,0 +1,70 @@ +# QA Run Profiles + +Each profile defines scope for a single QA pass. The skill (`SKILL.md`) defines method; profiles define scope. Always run profiles in a fresh Claude Code session for the cleanest context. + +The defaults below are starting points — `qa-config.yml` overrides everything. If your project has only one locale and one viewport, the locale-smoke and mobile-followup profiles are no-ops; just run smoke / canonical / full-matrix. + +--- + +## smoke + +**Purpose:** fastest possible signal — does the site render at all? +**Scope:** one section × default locale × desktop. The default section is the homepage `/` plus whatever section is marked `default: true` in `qa-config.yml`. If none is marked, use the first section. +**Time budget:** 15 min hard cap. +**Output:** `reports/YYYY-MM-DD-smoke.md` (+ rendered `.html`). +**Diff target:** most recent canonical run, if any. +**When to use:** post-deploy verification; confirming the site isn't catastrophically broken before a longer run. + +## locale-smoke + +**Purpose:** confirm locale-prefix routing works across all configured locales. +**Scope:** one section × all locales × desktop. +**Default section:** the one marked `default: true`, or the first section. +**Time budget:** 25 min hard cap. +**Output:** `reports/YYYY-MM-DD-locale-smoke.md` (+ `.html`). +**Diff target:** most recent canonical run. +**When to use:** after i18n-related changes, or to validate locale infrastructure before a multi-locale follow-up run. Skip this profile if the project is single-locale. + +## canonical + +**Purpose:** the desktop half of a full pass. Source of truth for desktop findings. +**Scope:** all sections × all locales × desktop. +**Time budget:** 90–120 min, 30-min hard cap per section. +**Output:** `reports/YYYY-MM-DD.md` (+ `.html`). +**Diff target:** previous canonical run. +**When to use:** against a fresh build; primary baseline for findings tracking. Pair with mobile-followup for a complete matrix pass. + +## mobile-followup + +**Purpose:** the mobile half of a full pass. Pairs with canonical to close the full matrix (locales × 2 viewports = 2N combinations). +**Scope:** all sections × all locales × mobile. +**Time budget:** 90 min, 30-min hard cap per section. +**Output:** `reports/YYYY-MM-DD-mobile.md` (+ `.html`). +**Diff target:** paired canonical run from the same day or build. +**When to use:** paired with each canonical run. Canonical alone is incomplete — mobile-followup makes the pass full-matrix. + +## full-matrix + +**Purpose:** a single pass covering everything — every section, every locale, every viewport. Use when you want one report rather than a canonical + mobile-followup pair. +**Scope:** all sections × all locales × desktop + mobile. +**Time budget:** 3–4 hours. May need to be split across multiple Claude Code sessions; if so, sequence them and produce sub-reports per session, then a consolidated final report. +**Output:** `reports/YYYY-MM-DD-full-matrix.md` (or `reports/YYYY-MM-DD-full-matrix-part-N.md` for split runs) (+ `.html`). +**Diff target:** previous full-matrix run, or most recent canonical+mobile-followup pair. +**When to use:** major releases; quarterly baselines; when you want the cleanest single artifact rather than a pair. + +## retest + +**Purpose:** verify specific findings by ID after a deploy or local fix. +**Scope:** only the routes referenced by the named finding IDs (provided by the user). +**Time budget:** 5 min per finding ID, 30 min cap. +**Output:** `reports/YYYY-MM-DD-retest-{ids}.md` (+ `.html`). +**Diff target:** report where each finding was last confirmed. +**When to use:** after deploys, to confirm fixes shipped or regressions persist. + +--- + +## Profile vs scope + +The profile is fixed — `smoke` always means "fastest signal," `canonical` always means "all sections × all locales × desktop." But the _content_ of those scopes (which sections, which locales) comes from `qa-config.yml`. So a single project's `/qa-canonical` covers a different surface area than another project's `/qa-canonical`, and that's the point. + +If `qa-config.yml` doesn't exist, the profile commands will tell the user to run `/qa-init` first. diff --git a/.claude/keepsimple-qa/SKILL.md b/.claude/keepsimple-qa/SKILL.md new file mode 100644 index 00000000..28305c26 --- /dev/null +++ b/.claude/keepsimple-qa/SKILL.md @@ -0,0 +1,181 @@ +--- +name: keepsimple-qa +description: Use when the user asks to run a QA pass, test the site, or check production for regressions on keepsimple.io. Drives a real browser via the Playwright MCP server, visits each route in the test plan, interacts with key features, and produces a structured findings report (md + html). Do NOT use for writing Playwright spec files — that's a different task. +--- + +# QA Agent — Method Playbook + +You are running a manual QA pass on a production website. You drive a real Chromium browser via the Playwright MCP server. Your output is a structured findings report. + +The **profile** that invoked you (a slash command from `.claude/commands/qa-*.md`) defines the **scope**: which routes, locales, and viewports. This skill defines the **method**: how to run any pass. + +If `qa-config.yml` is missing, stop and tell the user to run `/qa-init` first. Do not invent routes. + +## How to run + +1. Read `qa-config.yml` for the route list, locales, viewports, and target environment (production / staging). +2. Read `known-issues.md` if it exists. Anything listed there is NOT a finding — do not report it. +3. Read the file referenced by `qa-config.yml`'s optional `design_system:` field, if set. Use it to spot design-system violations. +4. Run the **fixed pass** first (the must-work flows below), then the **exploratory pass**. +5. Output the findings report at the path defined by your invoking slash command (or by `PROFILES.md` if invoked manually). Filename patterns: + - `reports/YYYY-MM-DD.md` for canonical + - `reports/YYYY-MM-DD-smoke.md` for smoke + - `reports/YYYY-MM-DD-locale-smoke.md` for locale-smoke + - `reports/YYYY-MM-DD-mobile.md` for mobile-followup + - `reports/YYYY-MM-DD-full-matrix.md` for full-matrix + - `reports/YYYY-MM-DD-retest-{ids}.md` for retest (e.g. `retest-F5-F7`) + Use the timezone in `qa-config.yml`'s `report_timezone:` field if set, otherwise UTC. +6. After writing the markdown, render an HTML version: `node .claude/keepsimple-qa/render-report.js reports/.md`. The HTML lives next to the markdown and is what humans read. + +This skill is loaded by an activation prompt — typically a slash command from `.claude/commands/qa-*.md` (one per profile). The slash command tells you which profile to run; this skill tells you how to run any profile. **Profile = scope** (which routes, locales, viewports). **Skill = method** (the pre-flight, fixed pass, exploratory pass, checklists, severity rubric). + +## Pre-flight (run before any pass) + +1. Confirm Playwright MCP is connected. If not, stop and report what's missing. +2. Navigate to the environment URL (production or staging) once. +3. If navigation fails (MCP error, DNS, 5xx), STOP. Write a single critical finding describing the failure, with category `broken-flow`. Do not proceed to the fixed or exploratory pass. +4. Dismiss any blocking UI listed in `known-issues.md` (e.g. cookie consent banners, promo modals, GDPR overlays). +5. Confirm the page title and `` attribute match the expected locale. +6. Capture the build ID(s) — for Next.js sites this is `window.__NEXT_DATA__.buildId`; for other frameworks it might be a meta tag, a ` + + +`; +} + +function stripHeader(md) { + const lines = md.split('\n'); + let i = 0; + while (i < lines.length && !lines[i].startsWith('# ')) i++; + if (i < lines.length) i++; + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === '') { + i++; + continue; + } + if (/^-\s+\*\*[^*]+:\*\*/.test(line)) { + i++; + continue; + } + break; + } + return lines.slice(i).join('\n'); +} + +function main() { + const inputPath = process.argv[2]; + if (!inputPath) { + console.error('Usage: node render-report.js '); + process.exit(1); + } + const absPath = path.resolve(inputPath); + if (!fs.existsSync(absPath)) { + console.error(`File not found: ${absPath}`); + process.exit(1); + } + + const md = fs.readFileSync(absPath, 'utf8'); + const meta = extractHeaderMeta(md); + const { findings, placeholderMd } = extractFindings(md); + const trimmedMd = stripHeader(placeholderMd); + + const coverage = extractCoverageStats(md); + const diff = extractDiffCounts(md); + const incomplete = isRunIncomplete(md); + + marked.setOptions({ + gfm: true, + breaks: false, + headerIds: false, + mangle: false, + }); + let bodyHtml = marked.parse(trimmedMd); + bodyHtml = postProcessHtml(bodyHtml); + + bodyHtml = bodyHtml.replace(//g, (_match, idx) => { + const f = findings[Number(idx)]; + return f ? renderFindingCard(f) : ''; + }); + + bodyHtml = bodyHtml.replace( + /

\s*(

{ + acc[s] = findings.filter( + f => (f.severity || '').toLowerCase() === s, + ).length; + return acc; + }, {}); + + console.log(`Rendered: ${outPath}`); + console.log( + `Findings parsed: ${findings.length} (${SEVERITY_ORDER.map(s => `${s}:${counts[s]}`).join(', ')})`, + ); +} + +main(); diff --git a/.claude/skills/keepsimple/SKILL.md b/.claude/skills/keepsimple/SKILL.md index c71c4a6a..7e1df3f7 100644 --- a/.claude/skills/keepsimple/SKILL.md +++ b/.claude/skills/keepsimple/SKILL.md @@ -106,7 +106,7 @@ Use path aliases for cross-folder imports. Relative imports only within the same - Tailwind, styled-components, CSS-in-JS - Named exports in `index.ts` barrels - State management libraries -- Testing frameworks beyond Cypress +- Testing frameworks beyond Playwright - Import global CSS outside `_app.tsx` - Invent design values — read `keepsimple-style.md` diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..737a5075 --- /dev/null +++ b/.env.example @@ -0,0 +1,87 @@ +# ============================================================ +# UXCore — Environment Variables +# ============================================================ +# Copy this file to `.env.local` and fill in the values you need. +# Most contributors only need a minimal setup — see README. +# +# QUICKSTART (minimum to run the app): +# 1. Copy this file: cp .env.example .env.local +# 2. Generate a secret: openssl rand -base64 32 +# → paste it as NEXTAUTH_SECRET below +# 3. Run the app: yarn dev +# +# Everything else is optional unless you're working on that feature. +# ============================================================ + +# ---------- Environment ---------- +# Options: dev | staging | production +NEXT_PUBLIC_ENV=dev + +# Set to "off" to disable search engine indexing on non-prod envs +NEXT_PUBLIC_INDEXING=off + +# ---------- Domain ---------- +# Your local dev URL (or deployed domain in production) +NEXT_PUBLIC_DOMAIN=http://localhost:3005 + +# ---------- Analytics (optional for contributors) ---------- +# Get a free token at https://mixpanel.com — leave empty to disable analytics +NEXT_PUBLIC_MIXPANEL_TOKEN= + +# Ahrefs Analytics key — leave empty for local development +NEXT_PUBLIC_AHREFS_ANALYTICS_KEY= + +# Google Analytics measurement ID — only used in production with indexing on, +# safe to leave empty for local development +NEXT_PUBLIC_GA_MEASUREMENT_ID= + +# ---------- Backend / API ---------- +# Strapi CMS public URL (used by the browser) — staging instance is fine for dev +NEXT_PUBLIC_STRAPI=https://staging-strapi.keepsimple.io + +# Strapi CMS server-side URL (used by Next.js server) +# Usually the same as NEXT_PUBLIC_STRAPI +STRAPI_URL=https://staging-strapi.keepsimple.io + +# UX Cat API endpoint — staging instance for development +NEXT_PUBLIC_UXCAT_API=https://staging-uxcat.keepsimple.io/ + +# ---------- NextAuth ---------- +# Generate a random secret with: `openssl rand -base64 32` +NEXTAUTH_SECRET= +# Should match your local dev URL +NEXTAUTH_URL=http://localhost:3005 + +# ============================================================ +# OAuth Providers (OPTIONAL for contributors) +# ============================================================ +# You only need these if you're working on authentication features. +# For most UI/feature work, you can leave them empty — the app will +# run without auth, you just won't be able to sign in. +# +# If you do need auth locally, follow the setup guides below to +# create your own OAuth apps (takes ~5 min per provider). +# Set the redirect URL to: +# http://localhost:3005/api/auth/callback/[provider] +# ============================================================ + +# ---------- Google OAuth ---------- +# Setup: https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# ---------- LinkedIn OAuth ---------- +# Setup: https://www.linkedin.com/developers/apps +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= + +# ---------- Discord OAuth ---------- +# Setup: https://discord.com/developers/applications +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +# ---------- Mail.ru OAuth ---------- +# Setup: https://o2.mail.ru/app/ +# Server-only — never prefix with NEXT_PUBLIC_, never reference from a .tsx file. +MAILRU_CLIENT_ID= +MAILRU_CLIENT_SECRET= \ No newline at end of file diff --git a/.github/workflows/cypress-manual.yml b/.github/workflows/cypress-manual.yml deleted file mode 100644 index 3a073e5e..00000000 --- a/.github/workflows/cypress-manual.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Run Cypress Tests - -on: - schedule: - - cron: '0 4 * * 1' # Every Monday at 4:00 AM UTC - workflow_dispatch: - inputs: - target_env: - description: Target environment for Cypress run - required: true - default: staging - type: choice - options: - - staging - - prod - -jobs: - cypress-run: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install dependencies - run: yarn install - - - name: Copy production env file - if: (inputs.target_env || 'staging') == 'prod' - run: | - echo ${{ secrets.ENV_PRODUCTION }} | base64 -d > .env.prod - - - name: Copy staging env file - if: (inputs.target_env || 'staging') == 'staging' - run: | - echo ${{ secrets.ENV_STAGING }} | base64 -d > .env.staging - - - name: Build app - run: npx cross-env NODE_ENV=production APP_ENV=${{ inputs.target_env || 'staging' }} next build - - - name: Run Cypress tests - uses: cypress-io/github-action@v6 - with: - start: npx cross-env NODE_ENV=production APP_ENV=${{ inputs.target_env || 'staging' }} next start -p 3005 - wait-on: 'http://localhost:3005' - wait-on-timeout: 300 - command: yarn cypress:run - env: - NODE_ENV: production - APP_ENV: ${{ inputs.target_env || 'staging' }} - CYPRESS_BASE_URL: http://localhost:3005 diff --git a/.github/workflows/playwright-scheduled.yml b/.github/workflows/playwright-scheduled.yml index 176da1d8..2c93007a 100644 --- a/.github/workflows/playwright-scheduled.yml +++ b/.github/workflows/playwright-scheduled.yml @@ -1,32 +1,24 @@ -name: Playwright scheduled +name: Playwright Tests (scheduled) -# Runs the Playwright suite weekly and on manual dispatch. -# Does NOT gate pull requests — pull-request-check.yml is unrelated. -# See QA_PLAN.md §5 for the full specification. -# -# Mirrors the cypress-manual.yml pattern: the workflow builds *this branch's -# source* with the chosen environment's `.env.` file, starts a local -# `next start` server, and runs Playwright against `http://localhost:3005`. -# This decouples the test from whatever is currently deployed at staging / -# prod URLs — newly added selectors, fixtures, and components are exercised -# against real Strapi data without waiting for a deploy. +# Weekly scheduled run on main + on-demand via workflow_dispatch. +# Does NOT run on PRs (by design — see QA_PLAN.md §7). on: schedule: - # Monday 06:00 UTC = Monday 10:00 Yerevan (UTC+4). + # Monday 06:00 UTC = 10:00 Yerevan (UTC+4) - cron: '0 6 * * 1' workflow_dispatch: inputs: - target_env: - description: 'Which env file to build with (controls Strapi backend, OAuth IDs, etc.)' + environment: + description: Environment to test against type: choice required: true default: staging options: - staging - - prod + - production scope: - description: 'Which tier to run (ignored if spec_path is provided)' + description: Which tier to run type: choice required: true default: all @@ -36,12 +28,12 @@ on: - P1 - P2 spec_path: - description: 'Optional specific spec file or glob (overrides scope)' + description: 'Override: specific test path or grep pattern (leave blank to use scope)' type: string required: false default: '' browser: - description: 'Browser to run against' + description: Browser to use type: choice required: true default: chromium @@ -56,91 +48,90 @@ permissions: jobs: playwright: - name: Playwright (${{ github.event.inputs.target_env || 'staging' }} / ${{ github.event.inputs.scope || 'all' }} / ${{ github.event.inputs.browser || 'chromium' }}) runs-on: ubuntu-latest - timeout-minutes: 45 - env: - CI: 'true' - APP_ENV: ${{ github.event.inputs.target_env || 'staging' }} - # NODE_ENV is intentionally NOT set at the job level — yarn treats - # NODE_ENV=production as a signal to skip devDependencies, which - # would drop @axe-core/playwright (used by tests/p2/a11y.spec.ts). - # The build step and the webServer block in playwright.config.ts - # each wrap their own command in `cross-env NODE_ENV=production`, - # so production mode is still enforced where it matters. - # - # The webServer block in playwright.config.ts reads APP_ENV and runs - # `next start` against the freshly-built `.next/`. baseURL stays at - # the default localhost:3005. + timeout-minutes: 30 + steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node + - name: Setup Node.js uses: actions/setup-node@v4 with: + # Aligned with Dockerfile (node:20.19.0-alpine). README's 18.18.0 + # is stale; Node 20 is the canonical runtime for this repo. node-version: 20 - # Intentionally NOT caching yarn — a stale runner cache was - # leaving @axe-core/playwright out of node_modules even though - # it sat in yarn.lock. Mirrors cypress-manual.yml which also - # opts out of caching. - name: Install dependencies - # Plain `yarn install` (no --frozen-lockfile) — matches the - # cypress-manual.yml pattern. Slower per run (~30-60s vs cached) - # but guarantees a clean node_modules every time. - run: yarn install - - - name: Decode prod env file - if: env.APP_ENV == 'prod' - run: echo "${{ secrets.ENV_PRODUCTION }}" | base64 -d > .env.prod + run: yarn install --frozen-lockfile - - name: Decode staging env file - if: env.APP_ENV == 'staging' - run: echo "${{ secrets.ENV_STAGING }}" | base64 -d > .env.staging - - - name: Install Playwright browsers - run: yarn playwright install --with-deps ${{ github.event.inputs.browser == 'all' && 'chromium firefox webkit' || github.event.inputs.browser || 'chromium' }} - - - name: Build app - run: yarn cross-env NODE_ENV=production APP_ENV=${{ env.APP_ENV }} next build - - - name: Resolve scope path + - name: Resolve test path from scope id: scope + shell: bash run: | - SPEC_PATH="${{ github.event.inputs.spec_path }}" - SCOPE="${{ github.event.inputs.scope || 'all' }}" - if [ -n "$SPEC_PATH" ]; then - echo "path=$SPEC_PATH" >> "$GITHUB_OUTPUT" + spec_path="${{ inputs.spec_path }}" + scope="${{ inputs.scope || 'all' }}" + + if [ -n "$spec_path" ]; then + echo "path=$spec_path" >> "$GITHUB_OUTPUT" else - case "$SCOPE" in - P0) echo "path=tests/p0/" >> "$GITHUB_OUTPUT" ;; - P1) echo "path=tests/p1/" >> "$GITHUB_OUTPUT" ;; - P2) echo "path=tests/p2/" >> "$GITHUB_OUTPUT" ;; - *) echo "path=tests/" >> "$GITHUB_OUTPUT" ;; + case "$scope" in + P0) echo "path=tests/p0" >> "$GITHUB_OUTPUT" ;; + P1) echo "path=tests/p1" >> "$GITHUB_OUTPUT" ;; + P2) echo "path=tests/p2" >> "$GITHUB_OUTPUT" ;; + *) echo "path=tests" >> "$GITHUB_OUTPUT" ;; esac fi - - name: Resolve Playwright projects - id: projects + - name: Resolve browser flag + id: browser + shell: bash run: | - BROWSER="${{ github.event.inputs.browser || 'chromium' }}" - if [ "$BROWSER" = "all" ]; then - echo "args=--project=chromium --project=firefox --project=webkit" >> "$GITHUB_OUTPUT" + browser="${{ inputs.browser || 'chromium' }}" + if [ "$browser" = "all" ]; then + # Empty flag → Playwright runs every project in config. + echo "flag=" >> "$GITHUB_OUTPUT" + echo "install=chromium firefox webkit" >> "$GITHUB_OUTPUT" else - echo "args=--project=$BROWSER" >> "$GITHUB_OUTPUT" + echo "flag=--project=$browser" >> "$GITHUB_OUTPUT" + echo "install=$browser" >> "$GITHUB_OUTPUT" + fi + + - name: Install Playwright browsers + run: yarn playwright install --with-deps ${{ steps.browser.outputs.install }} + + - name: Resolve base URL + id: url + shell: bash + run: | + env="${{ inputs.environment || 'staging' }}" + case "$env" in + production) echo "base=${{ secrets.PLAYWRIGHT_PRODUCTION_URL }}" >> "$GITHUB_OUTPUT" ;; + *) echo "base=${{ secrets.PLAYWRIGHT_STAGING_URL }}" >> "$GITHUB_OUTPUT" ;; + esac + + - name: Verify base URL is configured + shell: bash + run: | + if [ -z "${{ steps.url.outputs.base }}" ]; then + echo "::error::Base URL is empty. Configure PLAYWRIGHT_STAGING_URL / PLAYWRIGHT_PRODUCTION_URL as repo secrets." >&2 + exit 1 fi - - name: Run Playwright - # webServer block in playwright.config.ts spawns `next start` itself - # (because APP_ENV is set), waits for http://localhost:3005, runs - # the suite, and tears the server down at the end. - run: yarn playwright test ${{ steps.scope.outputs.path }} ${{ steps.projects.outputs.args }} + - name: Run Playwright tests + env: + PLAYWRIGHT_BASE_URL: ${{ steps.url.outputs.base }} + # Staging is behind HTTP Basic Auth; production is public. + PLAYWRIGHT_HTTP_USERNAME: ${{ (inputs.environment || 'staging') != 'production' && secrets.PLAYWRIGHT_STAGING_USERNAME || '' }} + PLAYWRIGHT_HTTP_PASSWORD: ${{ (inputs.environment || 'staging') != 'production' && secrets.PLAYWRIGHT_STAGING_PASSWORD || '' }} + PLAYWRIGHT_NO_SERVER: '1' + CI: 'true' + run: yarn playwright test ${{ steps.scope.outputs.path }} ${{ steps.browser.outputs.flag }} - - name: Upload HTML report + - name: Upload Playwright HTML report if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report-${{ github.run_id }} - path: playwright-report + name: playwright-report + path: playwright-report/ retention-days: 14 diff --git a/.gitignore b/.gitignore index 4e234b54..868617fa 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,35 @@ blob-report/ QA_PLAN.md QA_RECON.md TODO.md +marys-notes.md + +# Playwright MCP cache +.playwright-mcp/ + +# Loose QA screenshots at repo root (they belong in reports/screenshots//) +/qa-*.png +/uxcore-*.png + +# QA agent runtime artifacts (regenerable; never commit) +qa-runs/state/ +qa-runs/baselines/ +qa-runs/screenshots/ +qa-runs/auth/ + +# Concierge widget build output (built by `yarn prebuild:widget` before +# next build; serve via CI, never commit the bundle). +/public/ask-ux-core-dev.js +/widget/dist/ +/widget/node_modules/ + +# Personal developer compose files — local-only, never commit. +docker-compose.dev.yml +docker-compose.override.yml + +# Working / scratch docs — keep out of the repo by default. +# (Anything tracked under /docs that pre-dates this rule stays tracked; +# gitignore only blocks new untracked files. Move docs you want +# shared into README/AGENTS/CLAUDE.md or a dedicated published path.) +/docs/ + +.codegraph/ \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..f00f0585 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "playwright": { + "command": "bash", + "args": [ + "-lc", + "source ~/.nvm/nvm.sh && nvm use 20 >/dev/null && exec npx -y @playwright/mcp@latest --headless --browser chrome --executable-path $HOME/.cache/ms-playwright/chromium_headless_shell-1219/chrome-headless-shell-linux64/chrome-headless-shell" + ] + } + } +} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..5bd68117 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19.0 diff --git a/AGENTS.md b/AGENTS.md index 871062fe..114a9e76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ How to work in this codebase. Read this before writing code. | Auth | NextAuth v4 | 4.23.2 | | CMS | Strapi (external) | — | | State | React Context only | — | -| Testing | Cypress E2E only | 14.5.2 | +| Testing | Playwright E2E only | 1.59.1 | | Linting | ESLint flat config + simple-import-sort | 9.x | | Formatting | Prettier | 3.x | | Git hooks | Husky + lint-staged | 9.x | @@ -28,6 +28,37 @@ How to work in this codebase. Read this before writing code. --- +## Content Surfaces + +KeepSimple hosts several distinct content libraries. **Don't conflate them** — the most common mistake is assuming "Articles" is the whole corpus. It isn't. + +| Surface | URL prefix | Source | Notes | +| --------------------------------- | ---------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **UX Core** (cognitive biases) | `/uxcore/-` | Strapi CMS + `/uxcore-api` | World's largest open library of cognitive biases. Each entry has practical examples for product/HR. Public JSON API at `/uxcore-api` — use it instead of scraping. **This is the actual core, not Articles.** | +| **UXCG** (UX Core Guide cases) | `/uxcg/` | Strapi CMS | Business problem → set of relevant biases from UX Core. PDF dumps at `public/keepsimple_/static/uxcg/{en,ru}/*.pdf` (~63×2 cases + games). | +| **UXCP** (Cognitive Persona) | `/uxcp` | Strapi CMS | Tool that builds a Persona using biases. | +| **UXCAT** (self-awareness test) | `/uxcat` | Strapi CMS | Test, with `start-test`, `ongoing`, `test-result` variants. | +| **Articles** | `/articles/` | Strapi CMS | Long-form writing (~25 articles). Markdown mirrors at `public/keepsimple_/llms-full-pages/article/*.md`. | +| **Pyramids / Company Management** | `/company-management` | Strapi CMS | Management framework for remote-first teams. | +| **Tools** (Longevity Protocol …) | `/tools/` | Mixed (Strapi + static) | Small utilities. | +| **AI Atlas** | `/ai-atlas` | `public/ai-atlas/data.json` (+ ru) | The only major surface NOT on Strapi. See "AI Atlas notes" in `CLAUDE.local.md`. | + +### Single-shot ingestion files (LLM-friendly dumps) + +For any task that needs the full corpus (RAG indexing, search, audits, training): + +- **`public/keepsimple_/llms.txt`** — index of every page with a one-line description (the `llms.txt` AI-discovery standard). +- **`public/keepsimple_/llms-full.txt`** — full Markdown dump of all pages in one file. +- **`public/keepsimple_/llms-full-pages/
/*.md`** — the same dump, split per page. + +All three are generated by `scripts/generate-llms-pages.ts` (see "Generated Files" below). Use them instead of crawling `keepsimple.io` or hitting Strapi for each page. + +### Public data API + +- **`/uxcore-api`** — public JSON API over UX Core. Documented at `keepsimple.io/uxcore-api`. Preferred entry point for any integration that needs structured cognitive-bias data. + +--- + ## Folder Structure ``` @@ -457,6 +488,19 @@ The `next.config.js` loads env from `.env.{APP_ENV}` (e.g., `.env.local`, `.env. --- +## Commit Hygiene — never push noise + +Before every commit and before every push, audit `git status` and `git diff --cached --stat` and remove anything that doesn't belong in the change set. Specifically: + +- **Personal developer files** (`docker-compose.dev.yml`, `docker-compose.override.yml`, local `.env.*`, editor configs, scratch notes) MUST NOT be committed. They live only on the dev machine; the repo has no use for them. +- **Working / scratch docs** under `/docs` are gitignored. Anything you want shared belongs in `README.md`, `AGENTS.md`, or a CLAUDE.md — not a loose markdown file in `/docs/`. +- **Build output** (the widget bundle, `.next/`, `dist/`) is gitignored. If it shows up in `git status`, something is wrong with the build script, not the gitignore. +- **Debug artifacts** (revision constants like `READ_LIB_REVISION = 'v4'`, console.logs left over from a debug session, no-op shim exports kept "for safety") get removed before the PR opens — they age into rot otherwise. + +If you find one of these staged or already committed in your branch, drop it with `git rm` (or `git restore --staged`) and add it to `.gitignore` so the next agent can't trip on it. The `.gitignore` is the durable fix; deleting the file alone is not. + +--- + ## Gotchas ### Case sensitivity diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..08a54a1f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +# CLAUDE.md — keepsimple-merged (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. + +## 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: + +- `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 + +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. + +## Everything else + +See `AGENTS.md` for repo conventions, build/test commands, and contribution rules. diff --git a/README.md b/README.md index 6a091746..a6c941b1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ KeepSimple was founded in 2019 by Wolf Alexanyan as a free, public initiative ex - SCSS modules -- Node.js version: 18.18.0 +- Node.js version: 20.19.0 (see `.nvmrc`) ### Additional tooling: @@ -81,7 +81,7 @@ There are **two ways** of creating a branch: ## 📝Folder Structure and Naming Conventions -- cypress - contains all the e2e tests +- tests - contains all the Playwright e2e tests, organized by priority tier (`tests/p0/`, `tests/p1/`, `tests/p2/`) with shared `tests/fixtures/` and `tests/helpers/`. Run with `yarn test:e2e` (or `:p0` / `:p1` / `:p2` for a single tier). - public - contains static assets like images, icons, etc. - src - contains the main application code - api - contains API requests and related code diff --git a/UXCORE_RAG_HANDOFF.md b/UXCORE_RAG_HANDOFF.md new file mode 100644 index 00000000..a896e2df --- /dev/null +++ b/UXCORE_RAG_HANDOFF.md @@ -0,0 +1,202 @@ +# UX Core RAG → KeepSimple Engineering Handoff + +**Status (2026-05-09):** Backend live, indexed, smoke-tested. Ready for the +KeepSimple agent to start building the concierge widget on `keepsimple.io`. + +--- + +## What's live + +- **Service:** `uxcore-rag` (FastAPI + LightRAG + OpenAI gpt-4o-mini) +- **Staging URL:** `https://keepsimple-rag.administration.ae` +- **Auth:** Cloudflare Access (login via Google email). + Currently allows `alexanyanwolf@gmail.com` only — Wolf adds your engineer's + email to the allowlist on request, OR provisions a CF Access service token + for automated calls (preferred for the production proxy). +- **Indexed corpus:** 105 cognitive biases + 63 UXCG questions (English). + RU + HY locales planned in v2 once EN is validated end-to-end. +- **Brain artifact:** stored in named Docker volume `uxcore-rag_lightrag-data`. + Treat the indexed graph as a build artifact — do **not** re-index per + environment (LLM extraction is non-deterministic, graph drifts). + Re-index only when Strapi content meaningfully changes, then publish a + new artifact. + +--- + +## API contract (v0.2) + +All endpoints are JSON, all hit `https://keepsimple-rag.administration.ae`. + +### `GET /healthz` + +Liveness + config flags. No auth. + +```json +{ + "status": "ok", + "version": "0.2.0", + "ingester_configured": true, + "openai_configured": true, + "indexing": false, + "indexed_docs": 168 +} +``` + +### `POST /query/concierge` + +LLM-synthesized answer with retrieved context. **This is what the widget calls.** + +```json +// Request +{ "text": "My team keeps overestimating how clear our messaging is.", "lang": "en" } + +// Response +{ "answer": "...markdown answer..." } +``` + +- `lang` is currently informational only (corpus is EN). When RU + HY ship, + pass the user's locale. +- Answer is Markdown. May contain `[KG]` / `[DC]` LightRAG citation markers — + the widget should strip these client-side before render. (Or render them as + small "source" chips if you want extra credibility cues.) +- Latency: 2–6s typical. Streaming (SSE) is **not** implemented yet — the + request blocks until the answer is complete. SSE is on the v0.3 roadmap if + the widget needs it. + +### `POST /query/retrieve` + +Pure retrieval, no LLM synthesis. Faster, cheaper. Returns the raw context +LightRAG would have fed the LLM. Use this for "show me related biases without +narrative" UIs. + +```json +// Request: same shape as concierge +{ "text": "...", "lang": "en" } +// Response +{ "answer": "...raw context blob..." } +``` + +### `POST /index` (admin only) + +Triggers a full re-index from Strapi. Returns 202 immediately; check +`/index/status` for progress. Requires `Authorization: Bearer `. +**The KeepSimple agent does not need to call this** — Wolf re-indexes on +content changes. + +--- + +## How to call from `keepsimple.io` + +**Don't call directly from the browser.** Cloudflare Access cookies are scoped +to `*.administration.ae` and won't work cross-origin from `keepsimple.io`. Also, +shipping a CF Access service token to the browser would leak it. + +**Correct pattern:** add a thin Next.js API route on `keepsimple.io` that holds +the service token server-side and proxies to our service. + +```ts +// /pages/api/concierge.ts (or /app/api/concierge/route.ts) +export default async function handler(req, res) { + const r = await fetch( + 'https://keepsimple-rag.administration.ae/query/concierge', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID, // server-side + 'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET, // server-side + }, + body: JSON.stringify(req.body), + }, + ); + res.status(r.status).json(await r.json()); +} +``` + +The widget then calls `/api/concierge` on its own origin — no CORS, no leaked +tokens. CORS is enabled on the staging API (allowlist: keepsimple.io, +www.keepsimple.io, localhost:3000, localhost:3005) for direct dev work, but +production should always go through the proxy. + +Wolf provisions the CF service token from the "UX Core RAG" Access app; values +land in keepsimple.io's `.env` as `CF_ACCESS_CLIENT_ID` + `CF_ACCESS_CLIENT_SECRET`. + +--- + +## Caveats & known gaps + +- **Bias source URLs in answers are best-guesses.** The ingester emits + `https://keepsimple.io/uxcg#bias-{slug}` and `#q-{slug}`. If the real + public URL pattern for biases on `keepsimple.io` is different, send the + pattern and I'll re-index with the correct template (cheap — only re-emits + metadata, no re-extraction). +- **No RU / HY yet.** Strapi has the localizations. Adding them is a + one-pass extension (~$2 extra) once EN behavior is validated. +- **No persona / nationality data.** Wolf's third corpus + (persona → nationality biases) doesn't exist in this Strapi instance. + Engineer to confirm whether it lives elsewhere or is a future addition. +- **LightRAG citation markers.** Answers may contain `[KG]`, + `[DC]`, `(Reference: …)` annotations. Strip or stylize as needed. +- **Cost guardrails.** Each concierge query is ~$0.001. Set a Mixpanel-driven + rate limit on the widget if you want to cap exposure. + +--- + +## Top 5 features the brain unlocks + +The widget is feature #1. The other four are next-up product opportunities +the same backend can power without re-indexing. + +1. **Concierge widget** _(this handoff)_ — floating button on every + `keepsimple.io` page; user types a problem in plain words, gets the biases + that explain it + how to use them. +2. **Decision pre-flight** — user pastes a plan ("we're hiring 10 engineers + next quarter, here's the process"), gets the 3–5 biases most likely to + derail it + mitigations. Big for product / HR managers. +3. **Bias awareness quiz** — 8 questions about a recent decision; output a + shareable card showing which biases were at play, with a score. Viral / SEO. +4. **"Ask UX Core" chat** — open-ended multi-turn conversation; users can + drill into any bias, ask for HR vs UX examples, compare two biases. +5. **Interactive bias map** — visual graph of how the 105 biases relate + (LightRAG already builds this graph internally — it's a render away). + Click one, see neighbors, explore by domain. Long dwell time. + +Each of these calls `/query/concierge` (or a small variant) — no new +infrastructure required. + +--- + +## What I need from you to ship the widget + +1. **One Gmail address** for your engineer to access the staging API in the + browser, OR a green-light to mint a CF Access service token for the proxy. +2. **Confirmation of the public bias URL pattern** on `keepsimple.io` (so + citations link correctly). +3. **Any tweaks to the answer post-processing rules** (strip / keep / + stylize the `[KG]` markers). + +Then the widget is just (a) build the proxy route, (b) build the popover UI, +(c) wire `fetch('/api/concierge')`. ~2–3 hours of focused work for a +KeepSimple front-end engineer. + +--- + +## Production deployment (when you're ready) + +Same image, same indexed brain, different env. Two artifacts move: + +1. **The image** — pull `manager/uxcore-rag` (private repo), build, ship. + Or push the Wolf-Server-built image to the KeepSimple registry. +2. **The brain** — snapshot the contents of the `uxcore-rag_lightrag-data` + Docker volume from staging, restore into production's volume. + Wolf can publish a tarball to R2 or push to a registry on demand. + +Production env file pins: + +- `OPENAI_API_KEY` — KeepSimple's own OpenAI key +- `STRAPI_BASE_URL` + `STRAPI_API_TOKEN` — same Strapi (no change) +- `INDEX_AUTH_HEADER` — fresh secret for the prod admin endpoint +- `CORS_ORIGINS` — pin to `https://keepsimple.io` +- `LIGHTRAG_DIR` — wherever the prod volume mounts + +Don't run `/index` in production unless you intend to replace the brain. diff --git a/UXCORE_RAG_PLACEMENT_BRIEF.md b/UXCORE_RAG_PLACEMENT_BRIEF.md new file mode 100644 index 00000000..d5b66e07 --- /dev/null +++ b/UXCORE_RAG_PLACEMENT_BRIEF.md @@ -0,0 +1,231 @@ +# Ask UX Core — design spec (v2) + +**Status:** product decision, 2026-05-10 evening session. Supersedes the +morning version of this file. Backend is live and indexed +(168 docs, see `UXCORE_RAG_HANDOFF.md`). The floating-button test page at +`/concierge-test` was a working spike — **do not ship it as-is**, this spec +replaces it. + +--- + +## Concept + +One sentence: a guide, not a chatbot. Every reply is short, every reply +ends with 2-3 next-step cards, every card click navigates the page under +the widget while the widget stays open and reacts. + +The differentiator: the widget doesn't answer and stop — it walks the +user through the site. + +## Brand + +- **Name on the pill:** "Ask UX Core" + small icon. The UX Core brand + is stronger than KeepSimple itself; it pulls curiosity. +- The widget knows the entire site (UX Core, UXCG, UXCP, Pyramids, + articles, AI Atlas) — not only UX Core. The pill name is a doorway, + not a scope limit. +- **Voice:** professorial, formal "вы" / formal English, but never walls + of text. Reads like a Harvard professor who respects your time. + +## Surface (desktop only for now) + +- **Idle:** pill anchored bottom-right of every page. Icon + "Ask UX Core". + Subtle pulse every few seconds (idle attention; not aggressive). +- **Open:** floating panel ~380×520 px, anchored bottom-right, hovers above + the page. Does not compress page content (a full-height sidebar would + smash the UX Core grid — explicitly rejected). +- **Visual style:** KeepSimple-native (paper, cream, terracotta accents, + editorial type). Note: `/uxcore` is a sibling repo with its own design; + the widget keeps a single identity across both surfaces — it does not + morph per host page. +- Mobile is deferred. Will think later. + +## First screen + +Empty input + cursor. No suggested prompts, no greeting, no page-aware +opener (a page-aware opener was considered and rejected — on a bias page +the example is right there on the page already). + +## One reply, structurally + +1. **Answer text:** ≤ 2 sentences, ~200 chars. No paragraph dumps. +2. **2-3 cards** as next steps, **title only** — no preview line, no type + chip. Clean, like Arc Search results. + +If the user query is vague, the widget may answer with **a single +counter-question** before giving the answer ("the LLM decides by +confidence"). On a clear request, it answers immediately. This is the +"sometimes Socratic" mode and is the principal upgrade Wolf wanted. + +## What a card click does + +The page navigates beneath the widget. The widget stays open and +**reacts**: "Открыл _Anchoring_. Что дальше — пример или приём?" with +new next-step cards. This continuous reaction is what turns the widget +from a chat into a guide. + +If the user navigates via the **site's own nav** (not via a card), the +widget stays silent. It does not chase the user. The next move is the +user's. + +## History within a session + +Conversation is a scrolling chat feed within the browser tab. Closing +the panel (× / Esc) collapses it back to the pill — history is +**preserved** until the tab closes. Reopen → continue where you left off. + +No cross-session memory, no problem-map artifact, no TTS — these were +considered and explicitly cut. A well-built guide does not need them. + +## Empty / not-found state + +When RAG returns nothing strong, the widget says something like: +"Не нашёл точно, но вот близкое" + 1-2 nearest cards. It never goes +silent and never reaches a dead end. + +## Language + +The widget follows the site locale (`useRouter().locale`): +`en` / `ru` (HY falls back to EN as everywhere else on the site). +No in-widget locale selector. + +## Out of scope (possible upgrades, not MVP) + +- Mobile. +- Memory of what the user has already read. +- A "your problem map" shareable artifact. +- TTS / voice answers. +- First-visit tooltip onboarding ("Спросите про UX Core"). +- Page-aware first-screen prompts. +- Diagnostic flow with persona generation (was the original variant #3 + in the three-options discussion; remains the natural follow-up after + this concierge ships and is validated). + +These are deliberately deferred. If the guide is built well, none of +them are needed for the feature to feel premium. + +--- + +## Tech / deployment + +- **Single source of truth:** the widget is a **standalone JS bundle**. + Both `keepsimple-oss` and `UXCoreOSS` repos add one ` + + diff --git a/widget/package.json b/widget/package.json new file mode 100644 index 00000000..349c46a1 --- /dev/null +++ b/widget/package.json @@ -0,0 +1,23 @@ +{ + "name": "ask-ux-core-widget", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.0", + "vite": "^5.4.11" + } +} diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx new file mode 100644 index 00000000..23ceb0c4 --- /dev/null +++ b/widget/src/AskUxCore.tsx @@ -0,0 +1,3804 @@ +import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; + +import { askConcierge, Citation, postCopilotEvent, trackEvent } from './api'; + +type Lang = 'en' | 'ru'; + +type Turn = { + id: string; + query: string; + answer: string; + citations: Citation[]; + suggestions: string[]; + mode: 'answer' | 'clarify'; + isStreaming: boolean; + error?: string; + /* Landing turns are auto-generated when the user clicks a card and + lands on a new page. They render distinctly (small "on this page" + tag + left rule) so the visitor reads them as the team chiming in + about where they just arrived, not a regular Q&A. + + Nav turns are slimmer system breadcrumbs ("→ Now viewing: …") that + fire on any other navigation — back/forward, in-site links, modal + route changes inside UXCoreOSS — so the transcript stays in sync + with where the visitor actually stands. Distinct from landing + because they're cheap (no LLM call) and visually thinner. */ + kind?: 'landing' | 'nav'; + navTitle?: string; + /* Stamped on curated landings (PAGE_LANDINGS) — lets per-page UI + (e.g. the UXCAT Begin-Test CTA) gate on the turn itself instead + of "is this the most-recent spatial turn", so a follow-up nav + turn doesn't strip the CTA off the still-on-page landing. */ + landingKey?: string; +}; + +const STORAGE_KEY = 'ks_aux_state_v2'; +const IDLE_OPACITY_KEY = 'ks_aux_idle_opacity_v1'; // gitleaks:allow +const COLLAPSED_ONCE_KEY = 'ks_aux_collapsed_once_v1'; // gitleaks:allow +const THREAD_ID_KEY = 'ks_aux_thread_id_v1'; // gitleaks:allow + +/* Thread id: persists across reloads in localStorage; survives the + page lifecycle and follows the visitor across tabs. Bumped on every + CLEAR so transcript analytics can group questions into the same + conversation block while still seeing where the visitor wiped and + started over. Lives client-side; server pairs it with the http-only + sid cookie for the canonical visitor identity. */ +const getOrMakeThreadId = (): string => { + if (typeof window === 'undefined') return ''; + try { + const existing = localStorage.getItem(THREAD_ID_KEY); + if (existing) return existing; + const fresh = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `th-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + localStorage.setItem(THREAD_ID_KEY, fresh); + return fresh; + } catch { + return `th-${Date.now()}`; + } +}; +const rotateThreadId = (): string => { + const fresh = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `th-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + try { + localStorage.setItem(THREAD_ID_KEY, fresh); + } catch { + /* localStorage disabled — keep the in-memory id */ + } + return fresh; +}; +const loadCollapsedOnce = (): boolean => { + try { + return localStorage.getItem(COLLAPSED_ONCE_KEY) === '1'; + } catch { + return false; + } +}; + +/* Per-page example-question chips. Generated as a free side-effect of + the landing LLM call (no extra LLM cost) and cached by canonical + pathname so a returning visitor sees them instantly on re-open. */ +const SUGG_KEY_PREFIX = 'ks_aux_sugg:'; +const canonicalPathKey = (raw: string): string => { + try { + const u = new URL(raw, window.location.origin); + let p = u.pathname.replace(/^\/(ru|hy|en)(?=\/|$)/, ''); + p = p.replace(/\/+$/, ''); + return p.toLowerCase() || '/'; + } catch { + return '/'; + } +}; +const loadSuggestions = (url: string): string[] => { + try { + const raw = localStorage.getItem(SUGG_KEY_PREFIX + canonicalPathKey(url)); + if (!raw) return []; + const arr = JSON.parse(raw); + return Array.isArray(arr) + ? (arr as unknown[]).filter( + (s): s is string => typeof s === 'string' && s.trim().length > 0, + ) + : []; + } catch { + return []; + } +}; +const saveSuggestions = (url: string, suggestions: string[]) => { + try { + localStorage.setItem( + SUGG_KEY_PREFIX + canonicalPathKey(url), + JSON.stringify(suggestions.slice(0, 4)), + ); + } catch { + /* ignore */ + } +}; +const IDLE_OPACITY_STEPS = [0.3, 0.55, 0.85] as const; +const DEFAULT_IDLE_OPACITY = 0.55; +const loadIdleOpacity = (): number => { + try { + const raw = localStorage.getItem(IDLE_OPACITY_KEY); + const n = raw ? parseFloat(raw) : NaN; + if (IDLE_OPACITY_STEPS.includes(n as 0.3 | 0.55 | 0.85)) return n; + } catch { + /* ignore */ + } + return DEFAULT_IDLE_OPACITY; +}; +const LAST_PAGE_KEY = 'ks_aux_last_page_v1'; + +const TITLE_SPLIT_RE = /\s+[—–|·]\s+/; +/* Strip trailing brand suffix ("X — Keep It Simple", "X | UX Core") and + leading parenthetical counts ("(3) X") so transcript breadcrumbs read + as the bias/page name alone. */ +const cleanPageTitle = (raw: string): string => { + if (!raw) return ''; + const s = raw.replace(/^\(\d+\)\s*/, '').trim(); + return s.split(TITLE_SPLIT_RE)[0].trim(); +}; + +/* Scan the host page for a "recommended questions" section (UX Core + bias cards ship one) and return a random question from it. Pure DOM + read, runs on the visitor's browser, no server call. Returns null + when nothing plausible is on the page so the chip stays hidden. + + The heuristic looks for any heading (h1-h4) whose text matches + "recommended questions" / "рекомендуемые вопросы" / "recommended + question", then collects li / a / p siblings up to the next heading + and picks one at random. Falls back silently on any DOM oddity. */ +const RECQ_HEADING_RE = + /(recommended|suggested|related|further)\s+(questions?|reading|topics?)|(?:рекомендуемы[ея]|похожи[ея]|связанны[ея])\s+вопрос/i; +const harvestRecommendedQuestion = (): string | null => { + if (typeof document === 'undefined') return null; + try { + const headings = Array.from( + document.querySelectorAll('h1, h2, h3, h4'), + ) as HTMLElement[]; + const match = headings.find(h => RECQ_HEADING_RE.test(h.textContent || '')); + if (!match) return null; + const items: string[] = []; + const blockTag = (el: Element) => + /^(H1|H2|H3|H4)$/.test(el.tagName) && el !== match; + let cur: Element | null = match.nextElementSibling; + let hops = 0; + while (cur && hops < 12 && !blockTag(cur)) { + cur.querySelectorAll('li, a, p').forEach(node => { + const txt = (node.textContent || '').replace(/\s+/g, ' ').trim(); + if (!txt) return; + if (txt.length < 12 || txt.length > 180) return; + if (!/\?\s*$/.test(txt)) return; + items.push(txt); + }); + cur = cur.nextElementSibling; + hops += 1; + } + const uniq = Array.from(new Set(items)); + if (uniq.length === 0) return null; + return uniq[Math.floor(Math.random() * uniq.length)]; + } catch { + return null; + } +}; + +type LastPage = { url: string; title: string }; +const loadLastPage = (): LastPage | null => { + try { + const raw = localStorage.getItem(LAST_PAGE_KEY); + if (!raw) return null; + const data = JSON.parse(raw) as LastPage; + if (typeof data?.url !== 'string' || typeof data?.title !== 'string') + return null; + return data; + } catch { + return null; + } +}; +const saveLastPage = (p: LastPage) => { + try { + localStorage.setItem(LAST_PAGE_KEY, JSON.stringify(p)); + } catch { + /* ignore */ + } +}; + +type PendingLanding = { + url: string; + title: string; + prevQuery: string; + prevAnswer: string; + /* Id of the placeholder landing turn we appended optimistically on + card click — the LLM response replaces this turn instead of + appending a fresh one, so the visitor sees the new NOW VIEWING + marker (and the greyed history) the instant they click, not + after the round-trip. */ + placeholderId?: string; + /* Tab that originated the card click. localStorage is shared across + tabs, so without this another tab could consume the pending + landing and fire a duplicate landing fetch. The originating tab + reads its own id back after navigating (sessionStorage survives + same-tab nav) and consumes; any other tab sees a mismatch and + leaves it alone. */ + tabId?: string; + /* ms-since-epoch when written. Pending landings older than 60s are + stale (the originating tab probably closed) and get dropped on + read so they don't haunt the next reload. */ + createdAt?: number; +}; + +const TAB_ID_KEY = 'ks-aux:tab-id'; + +/* Per-tab identity, kept in sessionStorage so it survives same-tab + reloads but dies with the tab. Used to scope one-shot bridges + (pendingLanding) to the tab that wrote them. */ +const getTabId = (): string => { + if (typeof window === 'undefined') return ''; + try { + const existing = sessionStorage.getItem(TAB_ID_KEY); + if (existing) return existing; + const fresh = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `t-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + sessionStorage.setItem(TAB_ID_KEY, fresh); + return fresh; + } catch { + return ''; + } +}; + +const PENDING_LANDING_MAX_AGE_MS = 60_000; + +type Persisted = { + open: boolean; + turns: Turn[]; + awaitingRelevance?: boolean; + pendingLanding?: PendingLanding | null; +}; + +const loadState = (): Persisted | null => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const data = JSON.parse(raw) as Persisted; + if (!Array.isArray(data?.turns)) return null; + return { + open: !!data.open, + turns: data.turns.map(t => ({ ...t, isStreaming: false })), + awaitingRelevance: !!data.awaitingRelevance, + pendingLanding: data.pendingLanding ?? null, + }; + } catch { + return null; + } +}; + +const saveState = (state: Persisted) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // quota / disabled — silently skip + } +}; + +const TEXT: Record> = { + en: { + pillLabel: 'Ask anything', + pillLabelReturning: 'Your Copilot', + relevancePrompt: 'Was this relevant?', + placeholder: 'Ask anything about career, UX, decisions, biases…', + send: 'Ask', + networkErr: "Couldn't reach the server. Try again.", + rateErr: 'A bit too many requests. Wait a minute.', + serverErr: 'Something broke. Try again.', + empty: "We'll walk you through", + retry: 'Retry', + yourPick: 'Your pick', + landingLabel: 'On this page', + navLabel: 'Now viewing', + viewedLabel: 'Viewed', + relevancy: 'Relevancy', + readingLabel: 'Learning', + atHomeLabel: 'Learning', + collapseLabel: 'Collapse', + clearLabel: 'Clear', + immersionLabel: 'Immersion', + immersionHigh: 'High', + immersionMedium: 'Medium', + immersionLow: 'Low', + uxcatNudge: 'Try it. Might be a gamechanger.', + uxcatCta: 'Begin Test', + }, + ru: { + pillLabel: 'Спросите что угодно', + pillLabelReturning: 'Ваш Copilot', + relevancePrompt: 'Это было полезно?', + placeholder: 'Спросите про карьеру, UX, решения, искажения…', + send: 'Спросить', + networkErr: 'Не получилось дотянуться до сервера. Попробуйте ещё раз.', + rateErr: 'Многовато за раз. Подождите минуту.', + serverErr: 'Что-то сломалось. Попробуйте ещё раз.', + empty: 'Проведём вас по сайту', + retry: 'Повторить', + yourPick: 'Ваш выбор', + landingLabel: 'На этой странице', + navLabel: 'Сейчас открыто', + viewedLabel: 'Просмотрено', + relevancy: 'Релевантность', + readingLabel: 'Изучаем', + atHomeLabel: 'Изучаем', + collapseLabel: 'Свернуть', + clearLabel: 'Очистить', + immersionLabel: 'Погружение', + immersionHigh: 'Высокое', + immersionMedium: 'Среднее', + immersionLow: 'Низкое', + uxcatNudge: 'Попробуйте — может изменить всё.', + uxcatCta: 'Начать тест', + }, +}; + +const stripMarkers = (raw: string): string => + raw + .replace(/\[(KG|DC|no-context)\]/g, '') + .replace(/\(Reference:\s*https?:\/\/[^\s)]+\)/gi, '') + .trim(); + +type HomepageStarter = { + q: string; + a: string; + cards: Citation[]; +}; + +/* First-touch homepage starter Q&As. Carve-out from the normal + server-driven concierge pipeline (see docs/widget-architecture.md): + on the homepage, the empty-state chips are replaced with three + hand-crafted questions whose answers + cards render locally — no + LLM call, no retrieval. Pristine brand copy, zero latency, zero + hallucination risk on the three questions where first-impression + storytelling matters most. Pipeline resumes for free-form asks. */ +const HOMEPAGE_STARTERS: Record = { + en: [ + { + q: 'What does keepsimple actually make?', + a: "keepsimple is an open-source movement at the intersection of cognitive science, product, and engineering — running since 2019. The flagship is **UX Core**, the world's largest free library of cognitive biases and nudging strategies (used at Duke, Harvard, MIT, Google, Amazon).", + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'UX Core, the flagship bias library', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: "full transparency: our AI agents' orchestration", + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'long-form on cog sci, product, decisions', + }, + ], + }, + { + q: 'How is this project completely free?', + a: 'No paywalls, no ads, no investors — keepsimple has been free since day one in 2019. It runs on a small team plus a community of contributors and supporters. The code is open-source, the content is under Creative Commons. The deal is simple: if it helped you, pass it on, contribute, or chip in.', + cards: [ + { + title: 'Contributors', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'the people who keep this open', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: "full transparency: our AI agents' orchestration", + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'our take on the bigger questions', + }, + ], + }, + { + q: "Where do I start if I'm new here?", + a: "The lowest-friction entry is the **UX Awareness Test** — about 10 minutes, you'll spot a surprising number of biases at play around you. From there: **UX Core** is the bias library, with text and visual examples of how each one shows up. **UXCG** lets you evaluate your own organization for the mistakes those biases drive. And **Articles** is where we lay out our take on the bigger questions.", + cards: [ + { + title: 'Awareness Test', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: + 'take the 10-min Awareness Test and spot numerous biases around us', + }, + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'browse the bias library with text + visual examples', + }, + { + title: 'UXCG', + url: '/uxcg', + type: 'project', + nominated: true, + blurb: 'evaluate your organization for the mistakes biases drive', + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'learn our take on critical matters', + }, + ], + }, + ], + ru: [ + { + q: 'Чем занимается keepsimple?', + a: 'keepsimple — open-source движение на стыке когнитивной науки, продукта и инженерии, с 2019 года. Флагман — **UX Core**, крупнейшая в мире бесплатная библиотека когнитивных искажений и стратегий нуджинга (её используют в Duke, Harvard, MIT, Google, Amazon).', + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'флагманская библиотека искажений', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'полная прозрачность: оркестрация наших AI-агентов', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'длинные тексты про когнитивную науку, продукт, решения', + }, + ], + }, + { + q: 'Почему всё это бесплатно?', + a: 'Никаких пейволлов, рекламы или инвесторов — keepsimple бесплатен с первого дня в 2019. Проект держится на небольшой команде и сообществе контрибьюторов и саппортеров. Код открыт, контент под Creative Commons. Договор простой: если помогло — расскажи дальше, поучаствуй или поддержи.', + cards: [ + { + title: 'Контрибьюторы', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'люди, которые держат это открытым', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'полная прозрачность: оркестрация наших AI-агентов', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'наша позиция по большим вопросам', + }, + ], + }, + { + q: 'С чего начать, если я тут впервые?', + a: 'Самый простой вход — **тест осознанности** (UXCAT). Минут 10 — и заметишь удивительно много искажений вокруг себя. Дальше: **UX Core** — библиотека искажений с текстом и визуальными примерами того, как каждое проявляется. **UXCG** даёт оценить собственную организацию на ошибки, которые эти искажения порождают. А **Articles** — место, где мы раскладываем нашу позицию по большим вопросам.', + cards: [ + { + title: 'Тест осознанности', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: '10-минутный тест осознанности, заметь искажения вокруг', + }, + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'библиотека искажений: текст + визуальные примеры', + }, + { + title: 'UXCG', + url: '/uxcg', + type: 'project', + nominated: true, + blurb: 'оцени свою организацию на ошибки от искажений', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'наша позиция по критическим вопросам', + }, + ], + }, + ], +}; + +type PageLanding = { + message: string; + cards: Citation[]; +}; + +/* Curated per-page landings. When the visitor lands on one of these + paths (organic nav OR following a card click) and we haven't already + shown this page's curated landing this session, the widget renders + a hand-crafted message + cards locally instead of asking the server + landing endpoint. Server landing keeps running for everything else. + + Once-per-session is keyed off canonical pathname (locale-stripped) + in sessionStorage — clears on tab close. Revisits within the same + session get no landing turn at all (not curated, not server) so + the visitor isn't nagged by repeated greetings. */ +const PAGE_LANDINGS: Record> = { + en: { + '/uxcore': { + message: + "You're in **UX Core** — our open library of cognitive biases, each mapped to real product and HR scenarios with debiasing strategies. If you're not sure where to start, the 10-minute Awareness Test gives you a personal pulse on which biases bend your decisions today.", + cards: [ + { + title: 'Awareness Test', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: + 'The only science-backed awareness test. <7 minutes of your time needed', + }, + { + title: 'UXCG', + url: '/uxcg', + type: 'project', + nominated: true, + blurb: '1000+ nudging examples for your org/startup', + }, + { + title: 'Persona Map', + url: '/uxcp', + type: 'project', + nominated: true, + blurb: 'Find your nationality and learn more about your neighbours', + }, + ], + }, + '/tools/longevity-protocol': { + message: + "You're in the **Longevity Protocol** — our take on long-haul health, distilled into a small set of practices we actually run on ourselves. Same principle as the rest of keepsimple: smart defaults beat willpower.", + cards: [ + { + title: 'Tools', + url: '/tools', + type: 'project', + nominated: true, + blurb: 'Other small utilities we built and opened up', + }, + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'Cognitive backbone behind the protocol', + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Long-form on decisions and discipline', + }, + ], + }, + '/tools': { + message: + '**Tools** is the workshop — small utilities we built for ourselves and opened up. Each one solves a sharp, real problem we hit; nothing here for showroom reasons.', + cards: [], + }, + '/ai-atlas': { + message: + "You're in the **AI Atlas** — the orbital map of everything we run, who runs it, and how the agents talk to each other. Open transparency layer; nothing hidden, nothing aspirational.", + cards: [ + { + title: 'Terminal', + url: '/ai-atlas#terminal', + type: 'aiatlas', + nominated: true, + blurb: 'Plenty of tips and tricks are in hands of the Terminal', + }, + { + title: 'Tools', + url: '/ai-atlas#tools', + type: 'aiatlas', + nominated: true, + blurb: 'And a bunch of tweaks here', + }, + { + title: 'Contributors', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'All humans behind the project', + }, + ], + }, + '/articles': { + message: + '**Articles** — the long-form ledger, mostly Wolf, public since 2014. Cognitive science, product, project management — written when we have something to say, not on a publishing schedule.', + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'The bias library that grew out of these notes', + }, + { + title: 'Awareness Test', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: 'Find your own biases first', + }, + { + title: 'Contributors', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Who chips in alongside Wolf', + }, + ], + }, + }, + ru: { + '/uxcore': { + message: + 'Ты в **UX Core** — открытой библиотеке когнитивных искажений, каждое привязано к реальным продуктовым и HR-сценариям и снабжено стратегиями дебайзинга. Если не знаешь с чего начать — 10-минутный тест осознанности даст персональный срез: какие искажения гнут твои решения сегодня.', + cards: [ + { + title: 'Тест осознанности', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: + 'Единственный научно-обоснованный тест осознанности. Меньше 7 минут твоего времени', + }, + { + title: 'UXCG', + url: '/uxcg', + type: 'project', + nominated: true, + blurb: '1000+ примеров нуджинга для твоей компании или стартапа', + }, + { + title: 'Persona Map', + url: '/uxcp', + type: 'project', + nominated: true, + blurb: 'Найди свою национальность и узнай больше про своих соседей', + }, + ], + }, + '/tools/longevity-protocol': { + message: + 'Ты в **Longevity Protocol** — это наш взгляд на долгое здоровье, упакованный в небольшой набор практик, которые мы сами на себе и используем. Тот же принцип что и в остальном keepsimple: умные дефолты бьют силу воли.', + cards: [ + { + title: 'Tools', + url: '/tools', + type: 'project', + nominated: true, + blurb: 'Другие маленькие утилиты которые мы собрали и открыли', + }, + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'Когнитивный костяк за протоколом', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Длинные тексты про решения и дисциплину', + }, + ], + }, + '/tools': { + message: + '**Tools** — это мастерская: маленькие утилиты, которые мы собрали для себя и открыли наружу. Каждая решает реальную острую проблему; ничего здесь не лежит "для витрины".', + cards: [], + }, + '/ai-atlas': { + message: + 'Ты в **AI Atlas** — это орбитальная карта всего, что мы запускаем: кто что делает и как наши агенты общаются друг с другом. Открытый слой прозрачности; ничего не спрятано, ничего вымышленного.', + cards: [ + { + title: 'Терминал', + url: '/ai-atlas#terminal', + type: 'aiatlas', + nominated: true, + blurb: 'Куча подсказок и фишек в руках Терминала', + }, + { + title: 'Tools', + url: '/ai-atlas#tools', + type: 'aiatlas', + nominated: true, + blurb: 'И целая куча твиков вот здесь', + }, + { + title: 'Контрибьюторы', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Все люди за этим проектом', + }, + ], + }, + '/articles': { + message: + '**Статьи** — длинный публичный журнал, в основном Wolf, открыт с 2014. Когнитивная наука, продукт, проект-менеджмент — пишется когда есть что сказать, а не по расписанию.', + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'Библиотека искажений, выросшая из этих заметок', + }, + { + title: 'Тест осознанности', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: 'Сначала найди собственные искажения', + }, + { + title: 'Контрибьюторы', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Кто пишет вместе с Wolf-ом', + }, + ], + }, + }, +}; + +const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; +const curatedLandingPathKey = (rawUrl: string): string | null => { + try { + const u = new URL(rawUrl, window.location.origin); + let p = u.pathname.replace(/^\/(ru|hy|en)(?=\/|$)/, ''); + p = p.replace(/\/+$/, ''); + return p || '/'; + } catch { + return null; + } +}; +const getCuratedLandingFor = ( + rawUrl: string, + lang: Lang, +): { key: string; entry: PageLanding } | null => { + const key = curatedLandingPathKey(rawUrl); + if (!key) return null; + const entry = PAGE_LANDINGS[lang][key]; + return entry ? { key, entry } : null; +}; +const hasCuratedLandingFired = (key: string): boolean => { + try { + const raw = sessionStorage.getItem(CURATED_LANDING_FIRED_KEY) || '{}'; + return !!JSON.parse(raw)[key]; + } catch { + return false; + } +}; +const markCuratedLandingFired = (key: string) => { + try { + const raw = sessionStorage.getItem(CURATED_LANDING_FIRED_KEY) || '{}'; + const obj = JSON.parse(raw); + obj[key] = Date.now(); + sessionStorage.setItem(CURATED_LANDING_FIRED_KEY, JSON.stringify(obj)); + } catch { + /* sessionStorage disabled — fall through; landing will fire each visit */ + } +}; + +/* ────────────────────────────────────────────────────────────────── + Identity query triggers — works on any page. + ────────────────────────────────────────────────────────────────── + When the visitor's free-text question matches one of the canonical + "about us" clusters (what is keepsimple / is it free / who made + this / why open-source / how do you make money / etc.), we render + a hand-crafted answer locally instead of asking the LLM. Reason: + identity questions are brand-critical, the answer should never + drift, and the LLM round-trip is wasted tokens for a question + whose answer is fixed. Pipeline still runs for everything else. + ────────────────────────────────────────────────────────────────── */ +type IdentityTrigger = { + key: string; + patterns: RegExp[]; + answer: string; + cards: Citation[]; +}; + +const IDENTITY_TRIGGERS: Record = { + en: [ + { + key: 'what-is-keepsimple', + patterns: [ + /\bwhat\s+(is|are)\s+keepsimple\b/i, + /\bwhat'?s\s+keepsimple\b/i, + /\btell\s+me\s+about\s+keepsimple\b/i, + /\bwhat\s+is\s+this\s+(site|project|place)\b/i, + /\bwhat\s+do\s+you\s+(do|make|build)\b/i, + ], + answer: + "keepsimple is an open-source movement at the intersection of cognitive science, product, and engineering — running since 2019. The flagship is **UX Core**, the world's largest free library of cognitive biases and nudging strategies (used at Duke, Harvard, MIT, Google, Amazon).", + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'The flagship bias library', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: "Full transparency: our AI agents' orchestration", + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Long-form on cog sci, product, decisions', + }, + ], + }, + { + key: 'is-it-free', + patterns: [ + /\bis\s+(it|this|keepsimple)\s+(really\s+|actually\s+)?free\b/i, + /\bhow\s+(is|can)\s+(it|this)\s+(be\s+)?free\b/i, + /\bpaywall/i, + /\bpricing\b/i, + /\bhow\s+much\s+(does\s+it\s+cost|to\s+use)/i, + /\bsubscription\b/i, + /\bpremium\s+(tier|plan)/i, + /\bcost\s+(of|to)\s+(use|access)/i, + ], + answer: + 'Free since day one in 2019. No paywalls, no ads, no investors, no premium tier. The code is open-source, content under Creative Commons. Wolf funds the project from his own pocket; supporters chip in if they want to.', + cards: [ + { + title: 'Contributors', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'The people who keep this open', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: "Full transparency: our AI agents' orchestration", + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Our take on the bigger questions', + }, + ], + }, + { + key: 'where-do-i-start', + patterns: [ + /\bwhere\s+(do|should)\s+i\s+start\b/i, + /\b(i'?m|i\s+am)\s+new\b/i, + /\bwhere\s+to\s+begin\b/i, + /\bhow\s+do\s+i\s+(use|start|begin)\b/i, + /\bfirst\s+time\s+here\b/i, + /\bnew\s+(here|to\s+this)\b/i, + ], + answer: + "The lowest-friction entry is the **UX Awareness Test** — about 10 minutes, you'll spot a surprising number of biases at play around you. From there: **UX Core** is the bias library. **UXCG** lets you audit your own organisation. If you'd rather read first, **Articles** holds the long-form thinking.", + cards: [ + { + title: 'Awareness Test', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: 'Take the 10-min Awareness Test', + }, + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'Browse the bias library', + }, + { + title: 'UXCG', + url: '/uxcg', + type: 'project', + nominated: true, + blurb: 'Audit your organisation', + }, + ], + }, + { + key: 'who-made-this', + patterns: [ + /\bwho\s+(made|built|created|runs|owns|started|founded)\s+(this|keepsimple|it)\b/i, + /\bwho(?:'?s|\s+is)\s+wolf\b/i, + /\bwho(?:'?s|\s+is)\s+behind\s+keepsimple\b/i, + /\b(the\s+)?(team|founder|creator|author)\s+(of|behind)\s+keepsimple\b/i, + /\bwho\s+(writes|maintains)\s+(this|keepsimple)\b/i, + ], + answer: + '**Wolf Alexanyan** founded keepsimple in 2019 and runs it as the lead. A small core team plus a wider community of contributors keep the work going. Day-to-day faces are on the Contributors page.', + cards: [ + { + title: 'Contributors', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Everyone behind the project', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'The orbital map of what we run', + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: "Wolf's long-form thinking", + }, + ], + }, + { + key: 'why-open-source', + patterns: [ + /\bwhy\s+(open[-\s]?source|free|give\s+(it|this)\s+away|gratis)\b/i, + /\bwhat'?s?\s+the\s+catch\b/i, + /\bwhy\s+(no\s+ads|do\s+you\s+do\s+this)\b/i, + /\b(open[-\s]?source)\s+(reasoning|philosophy|why)\b/i, + ], + answer: + "No catch. keepsimple is open-source because that's how the knowledge stays trustworthy and usable — anyone can read the source, fork it, contribute, or run their own copy. The deal is simple: if it helps you, pass it on.", + cards: [ + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: "Full transparency: our AI agents' orchestration", + }, + { + title: 'Contributors', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'The people who keep this open', + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Long-form on the philosophy behind the project', + }, + ], + }, + { + key: 'credibility', + patterns: [ + /\bhow\s+many\s+(users|readers|people)\b/i, + /\b\d+[k,]?\s*(thousand|million)?\s+(users|readers)\b/i, + /\bcredib(le|ility)\b/i, + /\b(reputation|reputable)\b/i, + /\bwho\s+(uses|reads)\s+(this|keepsimple|you)\b/i, + /\bis\s+this\s+(real|legit)\b/i, + /\b(reference|cited)\s+(at|by)\b/i, + ], + answer: + "300,000+ readers worldwide. **UX Core** is referenced at Duke, Harvard Business School, MIT, Google, Yandex, Amazon. Open-source movement since 2019; everything on the site is the same one team's work, no licensing tricks.", + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'The library referenced at Duke, Harvard, MIT, Google', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Full transparency on what we run', + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Long-form, public since 2014', + }, + ], + }, + { + key: 'how-to-contribute', + patterns: [ + /\bhow\s+(can|do)\s+i\s+(help|contribute|donate|support)\b/i, + /\bdonat(e|ion|ions)\b/i, + /\bsupport\s+keepsimple\b/i, + /\b(contribute|contribut(or|ion))\b/i, + /\bsponsor\b/i, + /\bcan\s+i\s+(help|join)\b/i, + ], + answer: + 'Three ways. (1) Spread the word — link any page, cite UX Core, write about us. (2) Fork on GitHub or open a PR. (3) Support financially through the Contributors page. All optional, none required.', + cards: [ + { + title: 'Contributors', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'How to chip in, financial or otherwise', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'What we run — pick a piece to help with', + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Read the work, cite the pieces that helped you', + }, + ], + }, + { + key: 'business-model', + patterns: [ + /\bhow\s+do\s+(you|they)\s+make\s+money\b/i, + /\b(business|revenue|monetiz(e|ation))\s+model\b/i, + /\bare\s+you\s+(profitable|funded)\b/i, + /\bwho\s+(funds|pays\s+for)\s+(this|keepsimple)\b/i, + /\bhow\s+is\s+(this|keepsimple)\s+funded\b/i, + /\bwhere\s+does\s+the\s+money\s+come\s+from\b/i, + ], + answer: + "Short answer: we don't make money on keepsimple. Wolf funds the project from his own pocket, solely. No ads, no paid tier, no investor pressure on what we build.", + cards: [ + { + title: 'Contributors', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Where supporters can chip in if they want', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Full transparency on the work and the people', + }, + { + title: 'Articles', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Long-form on why we build it this way', + }, + ], + }, + ], + ru: [ + { + key: 'what-is-keepsimple', + patterns: [ + /\bчто\s+(такое|за)\s+keepsimple\b/i, + /\bрасскажи\s+(про|о)\s+keepsimple\b/i, + /\bчто\s+это\s+за\s+(проект|сайт|штука|место)\b/i, + /\bчем\s+(вы\s+)?занимаетесь\b/i, + ], + answer: + 'keepsimple — open-source движение на стыке когнитивной науки, продукта и инженерии, с 2019 года. Флагман — **UX Core**, крупнейшая в мире бесплатная библиотека когнитивных искажений и стратегий нуджинга (её используют в Duke, Harvard, MIT, Google, Amazon).', + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'Флагманская библиотека искажений', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Полная прозрачность: оркестрация наших AI-агентов', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Длинные тексты про когнитивную науку, продукт, решения', + }, + ], + }, + { + key: 'is-it-free', + patterns: [ + /\b(это|оно|keepsimple)\s+(правда\s+|реально\s+|действительно\s+)?бесплат/i, + /\bкак\s+(оно|это)\s+(может\s+быть\s+)?бесплат/i, + /\bплатно\b/i, + /\bстоимост/i, + /\bподписк/i, + /\bпремиум/i, + /\bсколько\s+стоит/i, + ], + answer: + 'Бесплатно с первого дня в 2019. Никаких пейволлов, рекламы, инвесторов, премиум-тарифа. Код открыт, контент под Creative Commons. Wolf финансирует проект из своего кармана; саппортеры подкидывают если хотят.', + cards: [ + { + title: 'Контрибьюторы', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Люди, которые держат это открытым', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Полная прозрачность: оркестрация наших AI-агентов', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Наша позиция по большим вопросам', + }, + ], + }, + { + key: 'where-do-i-start', + patterns: [ + /\bс\s+чего\s+начать\b/i, + /\bя\s+(тут|здесь)\s+(впервые|новый|нов)\b/i, + /\bкак\s+(начать|пользоваться|использовать)\b/i, + /\bпервый\s+раз\s+здесь\b/i, + /\bкуда\s+(идти|нажать)\s+(сначала|сперва)\b/i, + ], + answer: + 'Самый простой вход — **тест осознанности** (UXCAT), минут 10, заметишь удивительно много искажений вокруг. Дальше: **UX Core** — библиотека искажений. **UXCG** — оцени собственную организацию. Если хочется сначала почитать — **Статьи**.', + cards: [ + { + title: 'Тест осознанности', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: '10-минутный тест осознанности', + }, + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'Библиотека искажений', + }, + { + title: 'UXCG', + url: '/uxcg', + type: 'project', + nominated: true, + blurb: 'Оцени свою организацию', + }, + ], + }, + { + key: 'who-made-this', + patterns: [ + /\bкто\s+(создал|сделал|ведёт|ведет|основал|руководит|стоит)\b/i, + /\bкто\s+такой\s+wolf\b/i, + /\bкто\s+за\s+(этим|keepsimple)\b/i, + /\b(команд|основател|автор)/i, + /\bкто\s+пишет\s+(это|keepsimple)\b/i, + ], + answer: + '**Wolf Alexanyan** основал keepsimple в 2019 и ведёт его как лид. Небольшая core-команда плюс более широкое сообщество контрибьюторов держат работу на ходу. Кто что делает — на странице Contributors.', + cards: [ + { + title: 'Контрибьюторы', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Все люди за проектом', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Орбитальная карта того, что мы запускаем', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Длинные тексты от Wolf-а', + }, + ], + }, + { + key: 'why-open-source', + patterns: [ + /\bпочему\s+(open[-\s]?source|открыт|бесплат|без\s+рекламы)/i, + /\bзачем\s+(делать|открыт|бесплат)/i, + /\bв\s+ч[её]м\s+подвох\b/i, + /\bкакая\s+выгода\b/i, + ], + answer: + 'Никакого подвоха. keepsimple — open-source потому что только так знание остаётся честным и пригодным: каждый может прочитать исходник, форкнуть, поучаствовать, запустить свою копию. Договор простой: если помогло — расскажи дальше.', + cards: [ + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Полная прозрачность: оркестрация наших AI-агентов', + }, + { + title: 'Контрибьюторы', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Люди, которые держат это открытым', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Длинные тексты про философию проекта', + }, + ], + }, + { + key: 'credibility', + patterns: [ + /\bсколько\s+(пользовател|читател|людей|у\s+вас)/i, + /\b\d+[\s,]?(\d+)?\s*(тысяч|миллион|к|млн)\s+(пользоват|читател)/i, + /\bкто\s+(пользуется|читает|использует)\s+(этим|keepsimple|вами)/i, + /\bрепутац/i, + /\bправда\s+ли/i, + /\bкто\s+вас\s+знает/i, + /\b(ссыла|цитиру)\s+(на|вас)/i, + ], + answer: + '300 000+ читателей по миру. **UX Core** упоминают в Duke, Harvard Business School, MIT, Google, Яндексе, Amazon. Open-source движение с 2019; всё на сайте — работа одной команды, никаких лицензионных трюков.', + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: + 'Библиотека, на которую ссылаются в Duke, Harvard, MIT, Google', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Полная прозрачность того, что мы делаем', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Длинные тексты, публичны с 2014', + }, + ], + }, + { + key: 'how-to-contribute', + patterns: [ + /\bкак\s+(могу\s+)?(помочь|поучаств|поддерж|donat)/i, + /\b(контрибь|задонат|пожертвовать)/i, + /\bкак\s+(вписаться|включиться|присоединиться)/i, + /\bподдержать\s+keepsimple/i, + ], + answer: + 'Три способа. (1) Расскажи дальше — поделись страницей, сошлись на UX Core, напиши про нас. (2) Форкни на GitHub или открой PR. (3) Поддержи финансово через Contributors. Всё опционально, ничего не обязательно.', + cards: [ + { + title: 'Контрибьюторы', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Как помочь — финансово или иначе', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Что мы делаем — выбери кусок чтобы помочь', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Прочитай работу, сошлись на куски, которые помогли', + }, + ], + }, + { + key: 'business-model', + patterns: [ + /\bкак\s+(вы|они)\s+(зарабат|деньг|монетиз)/i, + /\b(бизнес|финанс|монетиз)\s+(модел|схем)/i, + /\bкто\s+(финансирует|оплачивает|спонсирует)/i, + /\bза\s+чей\s+счёт\b/i, + /\bоткуда\s+деньги\b/i, + ], + answer: + 'Коротко: мы не зарабатываем на keepsimple. Wolf финансирует проект из своего кармана, и только. Никакой рекламы, никаких платных тарифов, никакого давления инвесторов на то, что мы делаем.', + cards: [ + { + title: 'Контрибьюторы', + url: '/contributors', + type: 'project', + nominated: true, + blurb: 'Где саппортеры могут подкинуть если захотят', + }, + { + title: 'AI Atlas', + url: '/ai-atlas', + type: 'project', + nominated: true, + blurb: 'Полная прозрачность про работу и людей', + }, + { + title: 'Статьи', + url: '/articles', + type: 'project', + nominated: true, + blurb: 'Длинные тексты про то, почему мы делаем это так', + }, + ], + }, + ], +}; + +const matchIdentityTrigger = ( + query: string, + lang: Lang, +): IdentityTrigger | null => { + const q = (query || '').trim(); + if (q.length < 3) return null; + for (const trig of IDENTITY_TRIGGERS[lang]) { + for (const re of trig.patterns) { + if (re.test(q)) return trig; + } + } + return null; +}; + +type TypeKey = + | 'bias' + | 'article' + | 'persona' + | 'case' + | 'game' + | 'uxcg' + | 'pyramid' + | 'aiatlas' + | 'project'; + +/* Three-bucket trust signal. LightRAG scores are cosine sims, not + probabilities — five buckets with precise thresholds implied a + precision that wasn't there. Honest mapping: + strong (≥0.50) → 3/3 green, fair (≥0.30) → 2/3 yellow, + weak (≥0.15) → 1/3 red. Below 0.15 = server already filtered out. */ +const TIER_DOTS = 3; +const SCORE_TIERS = [ + { min: 0.5, dots: 3, color: '#4ea83a' }, + { min: 0.3, dots: 2, color: '#d9b13a' }, + { min: 0.15, dots: 1, color: '#d04a3a' }, +]; +const NOMINATED_TIER = { min: 0.5, dots: 3, color: '#4ea83a' }; +const tierFor = (score: number, nominated?: boolean) => { + if (nominated) return NOMINATED_TIER; + return ( + SCORE_TIERS.find(t => score >= t.min) ?? SCORE_TIERS[SCORE_TIERS.length - 1] + ); +}; + +const TYPE_INFO: Record = { + bias: { en: 'Bias', ru: 'Искажение', color: '#c75d3e' }, + article: { en: 'Article', ru: 'Статья', color: '#3a6e8f' }, + persona: { en: 'Persona', ru: 'Персона', color: '#5b8c5a' }, + case: { en: 'Case', ru: 'Кейс', color: '#8a5a3b' }, + game: { en: 'Game', ru: 'Игра', color: '#b8902f' }, + uxcg: { en: 'UXCG', ru: 'UXCG', color: '#7a4ea8' }, + pyramid: { en: 'Pyramid', ru: 'Пирамида', color: '#5a5a5a' }, + aiatlas: { en: 'AI Atlas', ru: 'AI Atlas', color: '#1f3a5f' }, + project: { en: 'Project', ru: 'Проект', color: '#8a2f3a' }, +}; + +const detectType = ( + type: string | undefined, + url: string | undefined, +): TypeKey | null => { + const t = (type || '').toLowerCase().trim(); + const u = (url || '').toLowerCase(); + /* URL-shape overrides for project-index destinations that look like + their detail-page sibling. /articles is the list, not an article; + /uxcore is the bias library, not a bias. Without this, an upstream + mistag would label the surface card with the wrong color/word. */ + const path = (() => { + try { + return new URL(u, 'http://x').pathname.replace(/\/+$/, ''); + } catch { + return u.replace(/\/+$/, ''); + } + })(); + if (path === '/articles') return 'project'; + if (path === '/uxcore') return 'project'; + if (path === '/uxcg') return 'project'; + if (path === '/uxcp') return 'project'; + if (path === '/uxcat') return 'project'; + if (path === '/uxcore-api') return 'project'; + if (path === '/company-management') return 'project'; + if (path === '/ai-atlas') return 'project'; + if (t === 'bias') return 'bias'; + if (t === 'article') return 'article'; + if (t === 'persona') return 'persona'; + if (t === 'case') return 'case'; + if (t === 'game') return 'game'; + if (t === 'question' || t === 'uxcg') return 'uxcg'; + if (t === 'pyramid') return 'pyramid'; + if (t === 'aiatlas' || t === 'ai_atlas') return 'aiatlas'; + if (t === 'project') return 'project'; + if (u.includes('/ai-atlas')) return 'aiatlas'; + if (u.includes('/articles/')) return 'article'; + if (u.includes('/uxcg')) return 'uxcg'; + if (u.includes('/uxcore')) return 'bias'; + if (u.includes('/company-management')) return 'pyramid'; + return null; +}; + +const rewriteToCurrentHost = (raw: string): string => { + if (!raw) return raw; + try { + const u = new URL(raw, window.location.origin); + if (u.host === 'keepsimple.io' || u.host === 'www.keepsimple.io') { + u.host = window.location.host; + u.protocol = window.location.protocol; + } + return u.toString(); + } catch { + return raw; + } +}; + +const errCode = (e: unknown): 'network' | 'rate' | 'server' => { + const msg = e instanceof Error ? e.message : ''; + if (msg === 'rate') return 'rate'; + if (msg === 'network') return 'network'; + return 'server'; +}; + +/* Strip locale prefix + trailing slash so two anchors pointing at the + same article ("/articles/foo", "/ru/articles/foo/") collapse. */ +const canonicalPathOf = (raw: string): string => { + try { + const u = new URL(raw, window.location.origin); + let p = u.pathname.replace(/^\/(ru|hy|en)(?=\/|$)/, ''); + p = p.replace(/\/+$/, ''); + return p.toLowerCase(); + } catch { + return ''; + } +}; + +/* Slug → readable title. "anchoring-effect" → "Anchoring effect". + Used to derive a trustworthy spatial title from the URL when the + host page's H1 is the project home heading instead of the entity + the visitor is actually reading (UX Core bias modals, UXCG case + modals). */ +const slugToTitle = (slug: string): string => { + const words = slug.replace(/-/g, ' ').trim(); + if (!words) return ''; + return words.charAt(0).toUpperCase() + words.slice(1); +}; + +/* On UX Core bias pages and UXCG case pages, the bias/case is rendered + as a modal overlay on top of the project home, so document H1 stays + on the project name ("UX Core") and would mislabel NOW VIEWING. + Derive the spatial title from the URL slug instead — it's the + deterministic, hash-independent identity of the entity in view. + Returns null for paths where the page H1 is correct. */ +const deriveSpatialTitleFromUrl = (rawPathname: string): string | null => { + const p = rawPathname + .toLowerCase() + .replace(/^\/(ru|hy|en)(?=\/|$)/, '') + .replace(/\/+$/, ''); + const bias = p.match(/^\/uxcore\/\d+-(.+)$/); + if (bias) return slugToTitle(bias[1]); + const uxcg = p.match(/^\/uxcg\/([^/]+)$/); + if (uxcg) return slugToTitle(uxcg[1]); + return null; +}; + +/* Hash fragment without the leading "#", lowercased. Empty when the + URL has no anchor. Used to prefer in-page entities (the AgentsForge + diamond on /ai-atlas, etc.) over the navigation tab that just + reloads the same page. */ +const hashOf = (raw: string): string => { + try { + const u = new URL(raw, window.location.origin); + return u.hash.replace(/^#/, '').toLowerCase(); + } catch { + return ''; + } +}; + +/* Host-DOM highlight: active on every page. The matching helper + returns nothing when there are no anchors to the card, so pages + without matches degrade silently. Pure client-side, no extra + server cost. */ +const isHighlightEnabledPage = (): boolean => { + return typeof window !== 'undefined' && typeof document !== 'undefined'; +}; + +/* Find elements on the host page that represent the card. Hash wins: + /ai-atlas#agentsforge prefers the actual diamond entity (an element + with id="agentsforge" OR an anchor whose href ends in #agentsforge) + over the generic nav tab pointing at /ai-atlas. Falls back to plain + pathname-matching anchors when no hash is present or no specific + element is found. */ +/* True for any node living inside the widget's own DOM (panel, pill, + cards, suggestions, …). Used to keep the host-page highlighter + from glowing the widget's own card anchors — they all live under + ancestors whose classes start with `ks-aux-`. */ +const isInsideWidget = (el: Element | null): boolean => { + let cur: Element | null = el; + while (cur) { + const cls = cur.className; + if (typeof cls === 'string' && /(?:^|\s)ks-aux-/.test(cls)) return true; + cur = cur.parentElement; + } + return false; +}; + +const findHostMatches = (cardUrl: string): HTMLElement[] => { + if (typeof document === 'undefined') return []; + const targetPath = canonicalPathOf(cardUrl); + const targetHash = hashOf(cardUrl); + if (targetHash) { + const out: HTMLElement[] = []; + const idMatch = document.getElementById(targetHash); + if (idMatch instanceof HTMLElement && !isInsideWidget(idMatch)) + out.push(idMatch); + document.querySelectorAll('a[href]').forEach(a => { + if (isInsideWidget(a)) return; + if (hashOf(a.href) !== targetHash) return; + if ( + targetPath && + canonicalPathOf(a.href) && + canonicalPathOf(a.href) !== targetPath + ) + return; + if (!out.includes(a)) out.push(a); + }); + if (out.length > 0) return out; + } + if (!targetPath) return []; + const out: HTMLAnchorElement[] = []; + document.querySelectorAll('a[href]').forEach(a => { + if (isInsideWidget(a)) return; + if (canonicalPathOf(a.href) === targetPath) out.push(a); + }); + return out; +}; +/* Back-compat alias. */ +const findHostAnchors = findHostMatches; + +const HIGHLIGHT_CLASS = 'ks-aux-host-highlight'; +const FLASH_CLASS = 'ks-aux-host-highlight-flash'; + +/* Some host pages wrap clickable text in an anchor styled + `display: contents` (e.g., UX Core's bias chips). Such an anchor has + no box of its own, so the highlight needs a sibling/ancestor that + does render a box. + + We walk UP rather than down because the visible inner element on + UX Core uses clip-path: polygon(...) for its hexagonal shape, and + clip-path clips ANY rendered effect on the same element (outline, + box-shadow, even filters). Applying the highlight to the chip's + wrapper instead lets `filter: drop-shadow()` render a glow around + the children's actual polygon shape — clip-path on a descendant + doesn't reach into the parent's filter pass. */ +const isContents = (el: Element): boolean => { + if (typeof window === 'undefined') return false; + return window.getComputedStyle(el).display === 'contents'; +}; +const resolveRenderable = (el: HTMLElement): HTMLElement => { + if (typeof window === 'undefined') return el; + if (!isContents(el)) return el; + let cur: HTMLElement | null = el.parentElement; + for (let i = 0; i < 4 && cur; i += 1) { + if (!isContents(cur)) return cur; + cur = cur.parentElement; + } + return el; +}; + +type HighlightHandle = { + targets: HTMLElement[]; + cleanup: () => void; +}; + +const GLOW_CLASS = 'ks-aux-host-highlight-glow'; + +/* Hover-prefetch: inject for a card's destination + so the navigation feels instant when the visitor clicks. Throttled + ~80ms so a flicker-hover doesn't waste bandwidth, and de-duped per + URL per session. Same-origin only (no point prefetching off-host). */ +const prefetchedUrls = new Set(); +const ensurePrefetchHead = (): HTMLHeadElement | null => { + if (typeof document === 'undefined') return null; + return document.head; +}; +const prefetchOnce = (url: string): void => { + if (!url || prefetchedUrls.has(url)) return; + const head = ensurePrefetchHead(); + if (!head) return; + try { + const u = new URL(url, window.location.origin); + if (u.origin !== window.location.origin) return; + const link = document.createElement('link'); + link.rel = 'prefetch'; + link.href = u.toString(); + link.as = 'document'; + head.appendChild(link); + prefetchedUrls.add(url); + } catch { + /* malformed url — skip */ + } +}; + +/* When we had to walk up from a `display: contents` anchor, the matched + element wraps complex children — likely with clip-path or transforms + (UX Core bias chips). Outline + box-shadow on the wrapper renders at + the wrapper's own box, which is zero-sized. Drop-shadow renders + around the rendered output of children, so it traces the polygon + shape. For ordinary block anchors (Articles tiles etc.) we keep the + crisp outline + halo — drop-shadow there would look soft and lose + the "this one" pointer. */ +const applyHostHighlight = ( + els: HTMLElement[], + flash: boolean, +): HighlightHandle => { + const resolved = els.map(el => { + const target = resolveRenderable(el); + return { target, glow: target !== el }; + }); + resolved.forEach(({ target, glow }) => { + target.classList.add(HIGHLIGHT_CLASS); + if (glow) target.classList.add(GLOW_CLASS); + if (flash) { + target.classList.remove(FLASH_CLASS); + /* Force reflow so the same anchor can flash twice in a row. */ + void target.offsetWidth; + target.classList.add(FLASH_CLASS); + } + }); + return { + targets: resolved.map(r => r.target), + cleanup: () => { + resolved.forEach(({ target }) => { + target.classList.remove(HIGHLIGHT_CLASS); + target.classList.remove(GLOW_CLASS); + target.classList.remove(FLASH_CLASS); + }); + }, + }; +}; + +export function AskUxCore({ lang }: { lang: Lang }) { + const initial = typeof window !== 'undefined' ? loadState() : null; + const [open, setOpen] = useState(initial?.open ?? false); + const [text, setText] = useState(''); + const [turns, setTurns] = useState(initial?.turns ?? []); + const [loading, setLoading] = useState(false); + const [awaitingRelevance, setAwaitingRelevance] = useState( + initial?.awaitingRelevance ?? false, + ); + + const pendingLandingRef = useRef( + initial?.pendingLanding ?? null, + ); + + const lastPageRef = useRef(null); + + useEffect(() => { + saveState({ + open, + turns, + awaitingRelevance, + pendingLanding: pendingLandingRef.current, + }); + }, [open, turns, awaitingRelevance]); + const inputRef = useRef(null); + const feedRef = useRef(null); + const stickToBottomRef = useRef(true); + const justNavigatedRef = useRef(false); + const organicAbortRef = useRef(null); + const [idleOpacity, setIdleOpacity] = useState(() => + loadIdleOpacity(), + ); + const [immersionOpen, setImmersionOpen] = useState(false); + const immersionMenuRef = useRef(null); + useEffect(() => { + if (!immersionOpen) return; + const onDocClick = (e: MouseEvent) => { + const root = immersionMenuRef.current; + if (root && !root.contains(e.target as Node)) setImmersionOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setImmersionOpen(false); + }; + document.addEventListener('mousedown', onDocClick); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDocClick); + document.removeEventListener('keydown', onKey); + }; + }, [immersionOpen]); + const [collapsedOnce, setCollapsedOnce] = useState(() => + loadCollapsedOnce(), + ); + const [pageSuggestions, setPageSuggestions] = useState(() => + typeof window !== 'undefined' ? loadSuggestions(window.location.href) : [], + ); + /* Random recommended question harvested from the host page's own + "recommended questions" section (bias-card pages have one). Pure + DOM read, refreshed on URL flips so it always matches the current + page. null when no plausible section is on the page. */ + const [recommendedQ, setRecommendedQ] = useState(() => + typeof window !== 'undefined' ? harvestRecommendedQuestion() : null, + ); + useEffect(() => { + try { + localStorage.setItem(IDLE_OPACITY_KEY, String(idleOpacity)); + } catch { + /* ignore */ + } + }, [idleOpacity]); + const t = TEXT[lang]; + + /* Live "Reading:

" header label. Prefers the page H1 (shorter, + truer to what the visitor sees) and falls back to the cleaned + document.title. Refreshes on URL/title flips so it always matches + the page the widget is actually grounded on. */ + const readCurrentLabel = (): string => { + if (typeof document === 'undefined') return ''; + const urlTitle = deriveSpatialTitleFromUrl(window.location.pathname); + if (urlTitle) return urlTitle.slice(0, 80); + const h1 = document.querySelector('h1'); + const txt = h1?.textContent?.replace(/\s+/g, ' ').trim(); + if (txt) return txt.slice(0, 80); + return cleanPageTitle(document.title || '').slice(0, 80); + }; + const isHomePath = (): boolean => { + if (typeof window === 'undefined') return false; + return /^\/(?:ru|hy|en)?\/?$/i.test(window.location.pathname); + }; + const isOnUxcatRoot = (): boolean => { + if (typeof window === 'undefined') return false; + const p = window.location.pathname + .toLowerCase() + .replace(/^\/(ru|hy|en)(?=\/|$)/, '') + .replace(/\/+$/, ''); + return p === '/uxcat'; + }; + const [readingLabel, setReadingLabel] = useState(() => + readCurrentLabel(), + ); + const [atHome, setAtHome] = useState(() => isHomePath()); + const [onUxcatRoot, setOnUxcatRoot] = useState(() => + isOnUxcatRoot(), + ); + /* Per-conversation thread id. Survives reloads (localStorage), + bumped on CLEAR. Passed up the chain so server-side analytics + groups Q&A turns correctly. */ + const threadIdRef = useRef(getOrMakeThreadId()); + const onBeginUxcatTest = () => { + trackEvent('uxcat_begin_test_click', {}); + let hasToken = false; + try { + hasToken = !!localStorage.getItem('accessToken'); + } catch { + /* localStorage disabled — fall through to navigation; the + server-side page guard will handle it. */ + } + if (!hasToken) { + /* Mirror the in-page /uxcat begin-test CTA: when anonymous, + open the host page's LogInModal via custom event rather than + bounce through /uxcat/start-test → /uxcat. UXCatLayout listens + and opens its modal. */ + trackEvent('uxcat_begin_test_auth_gate', {}); + window.dispatchEvent( + new CustomEvent('ks-aux-request-login', { + detail: { source: 'widget-uxcat', next: '/uxcat/start-test' }, + }), + ); + return; + } + const target = rewriteToCurrentHost('/uxcat/start-test'); + window.location.href = target; + }; + useEffect(() => { + if (typeof document === 'undefined') return; + const update = () => { + setReadingLabel(readCurrentLabel()); + setAtHome(isHomePath()); + setOnUxcatRoot(isOnUxcatRoot()); + }; + update(); + const onUrl = () => setTimeout(update, 280); + window.addEventListener('popstate', onUrl); + window.addEventListener('ks-aux-urlchange', onUrl); + const mo = new MutationObserver(() => { + setReadingLabel(readCurrentLabel()); + }); + mo.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); + const h1Watch = new MutationObserver(update); + const h1 = document.querySelector('h1'); + if (h1) + h1Watch.observe(h1, { + childList: true, + characterData: true, + subtree: true, + }); + return () => { + window.removeEventListener('popstate', onUrl); + window.removeEventListener('ks-aux-urlchange', onUrl); + mo.disconnect(); + h1Watch.disconnect(); + }; + }, []); + + /* Mirror the host site's light/dark theme. KeepSimpleOSS toggles + `document.body.classList.toggle('darkTheme', ...)`. We watch for + that flag and any equivalent (data-theme="dark", .dark for + other hosts the widget might land on later) so the widget never + looks out of place. */ + const detectDark = (): boolean => { + if (typeof document === 'undefined') return false; + const b = document.body; + const h = document.documentElement; + return ( + b?.classList.contains('darkTheme') || + b?.classList.contains('dark') || + h?.classList.contains('darkTheme') || + h?.classList.contains('dark') || + b?.getAttribute('data-theme') === 'dark' || + h?.getAttribute('data-theme') === 'dark' + ); + }; + const [isDark, setIsDark] = useState(() => detectDark()); + useEffect(() => { + if (typeof document === 'undefined') return; + const update = () => setIsDark(detectDark()); + update(); + const mo = new MutationObserver(update); + mo.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'data-theme'], + }); + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'data-theme'], + }); + return () => mo.disconnect(); + }, []); + + useEffect(() => { + if (!open) return; + /* Only auto-focus the input on an empty panel. With history, focus + would pop the mobile keyboard on top of the answer the user came + back to read. */ + if (turns.length === 0) inputRef.current?.focus(); + /* Snap to bottom and KEEP snapping while content is still laying + out. Initial render → snap. Font loads or image decodes that + change feed height → ResizeObserver fires → snap again. After + 1.5s or once the user touches the scroll themselves, we stop + fighting them. */ + const snap = () => { + const el = feedRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + stickToBottomRef.current = true; + }; + requestAnimationFrame(() => requestAnimationFrame(snap)); + let stopped = false; + let userScrolled = false; + const onUserScroll = () => { + userScrolled = true; + }; + const el = feedRef.current; + el?.addEventListener('wheel', onUserScroll, { passive: true }); + el?.addEventListener('touchmove', onUserScroll, { passive: true }); + const ro = + el && typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + if (stopped || userScrolled) return; + snap(); + }) + : null; + if (ro && el) { + ro.observe(el); + const inner = el.firstElementChild; + if (inner instanceof Element) ro.observe(inner); + /* Observe each direct child too so streamed text growth catches. */ + Array.from(el.children).forEach(c => ro.observe(c)); + } + const fonts = ( + document as unknown as { fonts?: { ready?: Promise } } + ).fonts; + fonts?.ready?.then(() => { + if (!stopped && !userScrolled) requestAnimationFrame(snap); + }); + const stopT = window.setTimeout(() => { + stopped = true; + ro?.disconnect(); + }, 1500); + return () => { + stopped = true; + window.clearTimeout(stopT); + ro?.disconnect(); + el?.removeEventListener('wheel', onUserScroll); + el?.removeEventListener('touchmove', onUserScroll); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + /* On mobile, lock the page behind the open panel so two-finger swipes + inside the feed don't drag the host page. iOS Safari rubber-band + bypasses overscroll-behavior, so we pin position:fixed on body and + restore the original scroll offset on close. Also tracks the + visualViewport so the panel resizes above the on-screen keyboard + and the input never gets covered. */ + useEffect(() => { + if (typeof window === 'undefined' || typeof document === 'undefined') + return; + const isMobile = window.matchMedia('(max-width: 480px)').matches; + if (!open || !isMobile) return; + const scrollY = window.scrollY; + const body = document.body; + const root = document.documentElement; + const prev = { + position: body.style.position, + top: body.style.top, + width: body.style.width, + overflow: body.style.overflow, + }; + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.width = '100%'; + body.style.overflow = 'hidden'; + + const setVh = () => { + const vv = window.visualViewport; + const h = (vv && vv.height) || window.innerHeight; + const occluded = vv + ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) + : 0; + root.style.setProperty('--ks-aux-vh', `${h}px`); + root.style.setProperty('--ks-aux-bottom-offset', `${occluded}px`); + root.style.setProperty('--ks-aux-panel-h', `${Math.max(220, h - 96)}px`); + }; + setVh(); + const vv = window.visualViewport; + vv?.addEventListener('resize', setVh); + vv?.addEventListener('scroll', setVh); + + return () => { + body.style.position = prev.position; + body.style.top = prev.top; + body.style.width = prev.width; + body.style.overflow = prev.overflow; + window.scrollTo(0, scrollY); + root.style.removeProperty('--ks-aux-vh'); + root.style.removeProperty('--ks-aux-bottom-offset'); + root.style.removeProperty('--ks-aux-panel-h'); + vv?.removeEventListener('resize', setVh); + vv?.removeEventListener('scroll', setVh); + }; + }, [open]); + + /* Watch the host page's URL and document.title and inject a "→ Now + viewing: X" system breadcrumb into the transcript every time the + visitor moves. Covers three flows: + (a) Cross-page reload: compare persisted lastPage to current. + (b) In-page route push/replace (SPA / hash modal). + (c) Title-only swap (modal opens without URL change). + Suppressed when a landing turn is about to fire for the same hop + (widget-card click) — landing already explains the move, so a nav + chip would be redundant. */ + useEffect(() => { + if (typeof window === 'undefined') return; + + /* Spatial markers always speak in the page's own H1. Page title + is intentionally ignored — too often it carries brand suffixes + or templated junk that misrepresents the page. If no H1 is + found, we return empty and skip the nav entirely. + + Homepage exception: the H1 there is "Wolf Alexanyan" which + reads weird as a destination label. We hardcode "Keep Simple" + for any homepage path (/, /ru, /hy, /en). */ + const HOME_LABEL = 'Keep Simple'; + const isHome = (): boolean => + /^\/(?:ru|hy|en)?\/?$/i.test(window.location.pathname); + const currentDisplayTitle = (_rawTitleFallback: string): string => { + if (isHome()) return HOME_LABEL; + const urlTitle = deriveSpatialTitleFromUrl(window.location.pathname); + if (urlTitle) return urlTitle.slice(0, 200); + const h1 = document.querySelector('h1')?.textContent; + const fromH1 = h1?.replace(/\s+/g, ' ').trim() ?? ''; + return fromH1.slice(0, 200); + }; + + const appendNav = (rawTitle: string) => { + const cleaned = currentDisplayTitle(rawTitle); + if (!cleaned) return; + setTurns(cur => { + const last = cur[cur.length - 1]; + if (last?.kind === 'nav' && last.navTitle === cleaned) return cur; + justNavigatedRef.current = true; + return [ + ...cur, + { + id: `nav-${Date.now()}`, + query: '', + answer: '', + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: false, + kind: 'nav', + navTitle: cleaned, + }, + ]; + }); + }; + + /* Organic-nav explainer: every time the visitor arrives on a new + page (back/forward, in-site link, modal route), fire a short + team-voice line that orients them. Distinct from the card-click + landing — it's an aside, not a "you came from our card" prompt. + Aborts in flight if another nav happens before the response. */ + const fireOrganicLanding = (rawUrl: string, rawTitle: string) => { + organicAbortRef.current?.abort(); + + /* Curated-landing carve-out (PAGE_LANDINGS): on the listed + pages we render a hand-crafted message + cards locally and + skip the server landing entirely. Once-per-session — revisit + within the same tab gets nothing (no curated, no server). */ + const curated = getCuratedLandingFor(rawUrl, lang); + if (curated) { + if (hasCuratedLandingFired(curated.key)) return; + markCuratedLandingFired(curated.key); + justNavigatedRef.current = true; + const urlTitle = (() => { + try { + return deriveSpatialTitleFromUrl(new URL(rawUrl).pathname); + } catch { + return null; + } + })(); + const turnId = `land-${Date.now()}`; + setTurns(cur => [ + ...cur, + { + id: turnId, + query: '', + answer: '', + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: true, + kind: 'landing', + navTitle: urlTitle || cleanPageTitle(rawTitle), + landingKey: curated.key, + }, + ]); + const { message, cards } = curated.entry; + window.setTimeout(() => { + const typer = createTypewriter(turnId); + typer.push(message); + typer.finish(() => { + setTurns(prev => + prev.map(tt => + tt.id === turnId + ? { + ...tt, + answer: message, + citations: cards, + isStreaming: false, + } + : tt, + ), + ); + }); + }, 2200); + return; + } + + const ctrl = new AbortController(); + organicAbortRef.current = ctrl; + fetch('/api/concierge-landing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + signal: ctrl.signal, + body: JSON.stringify({ + url: rawUrl, + title: rawTitle, + prevQuery: '', + prevAnswer: '', + lang, + mode: 'organic', + }), + }) + .then(r => r.json()) + .then((data: { text?: string; suggestions?: unknown }) => { + const sugs = Array.isArray(data?.suggestions) + ? (data.suggestions as unknown[]).filter( + (s): s is string => + typeof s === 'string' && s.trim().length > 0, + ) + : []; + if (sugs.length > 0) { + setPageSuggestions(sugs); + saveSuggestions(rawUrl, sugs); + } + const text = (data?.text || '').trim(); + if (!text) return; + justNavigatedRef.current = true; + /* Prefer URL-slug-derived title (bias / UXCG case slug) + over document.title — on UX Core modal-overlay pages the + title can read as the project home. */ + const urlTitle = (() => { + try { + return deriveSpatialTitleFromUrl(new URL(rawUrl).pathname); + } catch { + return null; + } + })(); + const turnId = `land-${Date.now()}`; + setTurns(cur => [ + ...cur, + { + id: turnId, + query: '', + answer: '', + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: true, + kind: 'landing', + navTitle: urlTitle || cleanPageTitle(rawTitle), + }, + ]); + const finalText = text; + const typer = createTypewriter(turnId); + typer.push(finalText); + typer.finish(() => { + setTurns(prev => + prev.map(tt => + tt.id === turnId + ? { ...tt, answer: finalText, isStreaming: false } + : tt, + ), + ); + }); + }) + .catch(() => { + /* aborted or upstream fail — silent */ + }); + }; + + const currentPage: LastPage = { + url: window.location.href, + title: document.title, + }; + + /* Page-movement analytics. Goals: + - dwell = real visible-time on a page before the visitor moves + within the site. Pure attention signal, no wall-clock idle. + - tab_close = its own event, with the same activeMs payload, so + the timeline can show "× closed tab after 32s active reading" + instead of a misleading 2-hour dwell. + Accumulation only ticks while document.visibilityState is + 'visible' — hidden tabs do not inflate the number. */ + const activeMsRef = { current: 0 }; + const lastVisibleAtRef = { + current: document.visibilityState === 'visible' ? Date.now() : 0, + }; + const sealedRef = { current: false }; + const flushActive = () => { + if (lastVisibleAtRef.current > 0) { + activeMsRef.current += Date.now() - lastVisibleAtRef.current; + lastVisibleAtRef.current = 0; + } + }; + const resetPageTimers = () => { + activeMsRef.current = 0; + lastVisibleAtRef.current = + document.visibilityState === 'visible' ? Date.now() : 0; + sealedRef.current = false; + }; + const onVisibility = () => { + if (document.visibilityState === 'visible') { + lastVisibleAtRef.current = Date.now(); + } else { + flushActive(); + } + }; + const firePageView = () => { + resetPageTimers(); + postCopilotEvent({ + kind: 'page_view', + threadId: threadIdRef.current, + lang, + }); + }; + const fireDwell = () => { + flushActive(); + const activeMs = activeMsRef.current; + const lp = lastPageRef.current; + if (!lp) return; + /* Drop sub-half-second blips — those are router transitions or + debounce-window false starts, not real attention. */ + if (activeMs < 500) return; + postCopilotEvent({ + kind: 'dwell', + threadId: threadIdRef.current, + lang, + meta: { + activeMs, + pageUrl: lp.url, + pageTitle: lp.title, + }, + }); + }; + const fireTabClose = () => { + if (sealedRef.current) return; + sealedRef.current = true; + flushActive(); + const activeMs = activeMsRef.current; + const lp = lastPageRef.current; + if (!lp) return; + postCopilotEvent({ + kind: 'tab_close', + threadId: threadIdRef.current, + lang, + meta: { + activeMs, + pageUrl: lp.url, + pageTitle: lp.title, + }, + }); + }; + + /* Mount-time cross-page diff. Skip when pendingLanding is set — + landing effect handles that hop. */ + const prior = loadLastPage(); + const hasPendingLanding = !!pendingLandingRef.current; + if ( + !hasPendingLanding && + prior && + cleanPageTitle(prior.title) && + cleanPageTitle(prior.title) !== cleanPageTitle(currentPage.title) + ) { + appendNav(currentPage.title); + fireOrganicLanding(currentPage.url, currentPage.title); + } + lastPageRef.current = currentPage; + saveLastPage(currentPage); + firePageView(); + + let timer: ReturnType | null = null; + const check = () => { + const url = window.location.href; + const title = document.title; + const cleaned = cleanPageTitle(title); + const lastCleaned = cleanPageTitle(lastPageRef.current?.title || ''); + const lastUrl = lastPageRef.current?.url || ''; + /* Title-only changes (loading dots, async updates) are not a + navigation — bail before we emit a dwell or swap state. */ + if (url === lastUrl) return; + const next = { url, title }; + /* Seal dwell on the OUTGOING page before we swap the ref. */ + fireDwell(); + lastPageRef.current = next; + saveLastPage(next); + if (!cleaned || cleaned === lastCleaned) return; + /* Swap suggestions to whatever we cached for the new URL so the + empty-feed pills feel instant; the landing fetch refreshes + them when it returns. */ + setPageSuggestions(loadSuggestions(url)); + setRecommendedQ(harvestRecommendedQuestion()); + appendNav(title); + fireOrganicLanding(url, title); + firePageView(); + }; + + /* Outbound-link capture: when the visitor clicks an anchor whose + href points to a different origin, log it so we can see where + they go after KeepSimple. Same-origin clicks are covered by the + page_view event that fires on the destination. */ + const onDocClick = (e: MouseEvent) => { + try { + const t = e.target; + if (!(t instanceof Element)) return; + const a = t.closest('a[href]') as HTMLAnchorElement | null; + if (!a) return; + const href = a.href; + if (!href || href.startsWith('javascript:')) return; + const u = new URL(href, window.location.href); + if (u.origin === window.location.origin) return; + const anchorText = (a.textContent || '').trim().slice(0, 200); + postCopilotEvent({ + kind: 'outbound_click', + threadId: threadIdRef.current, + lang, + meta: { + href: u.href.slice(0, 500), + anchorText, + target: a.target || '_self', + }, + }); + } catch { + /* never block the click */ + } + }; + document.addEventListener('click', onDocClick, true); + const onChange = () => { + if (timer) clearTimeout(timer); + /* Debounce — title often lags URL by a frame in client-side + routers, and rapid title swaps (loading dots) shouldn't each + emit a nav turn. */ + timer = setTimeout(check, 220); + }; + + const origPush = window.history.pushState; + const origReplace = window.history.replaceState; + window.history.pushState = function (...args) { + const r = origPush.apply(this, args as Parameters); + window.dispatchEvent(new Event('ks-aux-urlchange')); + return r; + }; + window.history.replaceState = function (...args) { + const r = origReplace.apply(this, args as Parameters); + window.dispatchEvent(new Event('ks-aux-urlchange')); + return r; + }; + window.addEventListener('popstate', onChange); + window.addEventListener('hashchange', onChange); + window.addEventListener('ks-aux-urlchange', onChange); + + let titleObs: MutationObserver | null = null; + const titleEl = document.querySelector('title'); + if (titleEl && typeof MutationObserver !== 'undefined') { + titleObs = new MutationObserver(onChange); + titleObs.observe(titleEl, { childList: true, subtree: true }); + } + + const onUnload = () => { + if (lastPageRef.current) saveLastPage(lastPageRef.current); + /* Emit tab_close (sealedRef guards against beforeunload + + pagehide both firing). sendBeacon path inside postCopilotEvent + survives unload. */ + fireTabClose(); + }; + window.addEventListener('beforeunload', onUnload); + window.addEventListener('pagehide', onUnload); + document.addEventListener('visibilitychange', onVisibility); + + return () => { + if (timer) clearTimeout(timer); + window.removeEventListener('popstate', onChange); + window.removeEventListener('hashchange', onChange); + window.removeEventListener('ks-aux-urlchange', onChange); + window.removeEventListener('beforeunload', onUnload); + window.removeEventListener('pagehide', onUnload); + document.removeEventListener('visibilitychange', onVisibility); + document.removeEventListener('click', onDocClick, true); + titleObs?.disconnect(); + window.history.pushState = origPush; + window.history.replaceState = origReplace; + }; + }, []); + + /* On mount: if the previous page tucked away a pending-landing (user + clicked a card and we navigated here), fetch a short team line about + this page and append it as a fresh turn — no nav chip, just the + team chiming in. */ + useEffect(() => { + const pending = pendingLandingRef.current; + if (!pending) return; + /* Only the tab that wrote the pending landing consumes it. Other + tabs see a foreign tab-id and leave it alone — prevents a + middle-clicked card in tab A from triggering a duplicate + landing fetch in tab B. */ + const myTab = getTabId(); + const sameTab = !pending.tabId || pending.tabId === myTab; + const fresh = + !pending.createdAt || + Date.now() - pending.createdAt < PENDING_LANDING_MAX_AGE_MS; + if (!sameTab) return; + if (!fresh) { + pendingLandingRef.current = null; + return; + } + pendingLandingRef.current = null; + + /* Curated-landing carve-out (PAGE_LANDINGS): same logic as + organic landing — if the destination has a curated entry and + it hasn't fired this session, replace the click-time placeholder + with the curated message + cards locally and skip the server. + If it already fired this session, drop the placeholder silently. */ + { + const curated = getCuratedLandingFor(window.location.href, lang); + if (curated) { + const placeholderId = pending.placeholderId; + if (hasCuratedLandingFired(curated.key)) { + if (placeholderId !== undefined) { + setTurns(cur => cur.filter(tt => tt.id !== placeholderId)); + } + return; + } + markCuratedLandingFired(curated.key); + justNavigatedRef.current = true; + const resolvedTitle = (() => { + if (/^\/(?:ru|hy|en)?\/?$/i.test(window.location.pathname)) { + return 'Keep Simple'; + } + const urlTitle = deriveSpatialTitleFromUrl(window.location.pathname); + if (urlTitle) return urlTitle.slice(0, 200); + const h1 = document + .querySelector('h1') + ?.textContent?.replace(/\s+/g, ' ') + .trim(); + if (h1) return h1.slice(0, 200); + return cleanPageTitle(pending.title) || ''; + })(); + let landingTurnId = `land-${Date.now()}`; + setTurns(cur => { + const idx = + placeholderId !== undefined + ? cur.findIndex(tt => tt.id === placeholderId) + : -1; + if (idx >= 0) { + landingTurnId = cur[idx].id; + const next = cur.slice(); + next[idx] = { + ...next[idx], + isStreaming: true, + answer: '', + navTitle: resolvedTitle || next[idx].navTitle, + landingKey: curated.key, + }; + return next; + } + return [ + ...cur, + { + id: landingTurnId, + query: '', + answer: '', + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: true, + kind: 'landing', + navTitle: resolvedTitle, + landingKey: curated.key, + }, + ]; + }); + const { message, cards } = curated.entry; + const targetId = landingTurnId; + window.setTimeout(() => { + const typer = createTypewriter(targetId); + typer.push(message); + typer.finish(() => { + setTurns(prev => + prev.map(tt => + tt.id === targetId + ? { + ...tt, + answer: message, + citations: cards, + isStreaming: false, + } + : tt, + ), + ); + }); + }, 2200); + return; + } + } + + let cancelled = false; + fetch('/api/concierge-landing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + url: window.location.href, + title: document.title, + prevQuery: pending.prevQuery, + prevAnswer: pending.prevAnswer, + lang, + }), + }) + .then(r => r.json()) + .then((data: { text?: string; suggestions?: unknown }) => { + if (cancelled) return; + const sugs = Array.isArray(data?.suggestions) + ? (data.suggestions as unknown[]).filter( + (s): s is string => typeof s === 'string' && s.trim().length > 0, + ) + : []; + if (sugs.length > 0) { + setPageSuggestions(sugs); + saveSuggestions(window.location.href, sugs); + } + const text = (data?.text || '').trim(); + const resolvedTitle = (() => { + /* Homepage exception: H1 there reads as a personal name, + force "Keep Simple" so the marker reads as the brand + destination. */ + if (/^\/(?:ru|hy|en)?\/?$/i.test(window.location.pathname)) { + return 'Keep Simple'; + } + /* Prefer URL-slug-derived title for UX Core biases and UXCG + cases — those are modal overlays whose H1 is the project + home heading. */ + const urlTitle = deriveSpatialTitleFromUrl(window.location.pathname); + if (urlTitle) return urlTitle.slice(0, 200); + const h1 = document + .querySelector('h1') + ?.textContent?.replace(/\s+/g, ' ') + .trim(); + if (h1) return h1.slice(0, 200); + const fromCard = cleanPageTitle(pending.title); + if (fromCard) return fromCard; + return ''; + })(); + const placeholderId = pending.placeholderId; + justNavigatedRef.current = true; + let landingTurnId: string | null = null; + setTurns(cur => { + /* Replace the optimistic placeholder we dropped on click; + back-compat fallback appends a fresh turn for old + pending-landings that lack an id. */ + const idx = + placeholderId !== undefined + ? cur.findIndex(tt => tt.id === placeholderId) + : -1; + if (idx >= 0) { + if (!text) { + /* LLM returned nothing — drop the placeholder so we + don't leave a permanent skeleton. */ + return cur.filter((_, i) => i !== idx); + } + landingTurnId = cur[idx].id; + const next = cur.slice(); + next[idx] = { + ...next[idx], + /* Keep isStreaming true — the typewriter below fills + the text in and clears the flag on completion. */ + isStreaming: true, + answer: '', + navTitle: resolvedTitle || next[idx].navTitle, + }; + return next; + } + if (!text) return cur; + const freshId = `land-${Date.now()}`; + landingTurnId = freshId; + return [ + ...cur, + { + id: freshId, + query: '', + answer: '', + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: true, + kind: 'landing', + navTitle: resolvedTitle, + }, + ]; + }); + if (landingTurnId && text) { + const finalText = text; + const targetId = landingTurnId; + const typer = createTypewriter(targetId); + typer.push(finalText); + typer.finish(() => { + setTurns(prev => + prev.map(tt => + tt.id === targetId + ? { ...tt, answer: finalText, isStreaming: false } + : tt, + ), + ); + }); + } + }) + .catch(() => { + /* landing line is best-effort — clear placeholder skeleton */ + const placeholderId = pending.placeholderId; + if (placeholderId === undefined) return; + setTurns(cur => cur.filter(tt => tt.id !== placeholderId)); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onFeedScroll = () => { + const el = feedRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distanceFromBottom < 40; + }; + + const justSubmittedRef = useRef(false); + useEffect(() => { + const el = feedRef.current; + if (!el) return; + if (justSubmittedRef.current || justNavigatedRef.current) { + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + justSubmittedRef.current = false; + justNavigatedRef.current = false; + stickToBottomRef.current = true; + } else if (stickToBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [turns]); + + /* 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); + useEffect(() => { + if (!isHighlightEnabledPage()) return; + const last = turns[turns.length - 1]; + if (!last || last.kind === 'nav' || last.isStreaming) return; + if (!last.citations || last.citations.length === 0) return; + if (lastFlashedTurnIdRef.current === last.id) return; + lastFlashedTurnIdRef.current = last.id; + const matched: HTMLElement[] = []; + for (const c of last.citations) { + const hits = findHostAnchors(c.url); + for (const h of hits) if (!matched.includes(h)) matched.push(h); + } + if (matched.length === 0) return; + const handle = applyHostHighlight(matched, true); + const t = window.setTimeout(() => { + handle.targets.forEach(el => el.classList.remove(FLASH_CLASS)); + }, 1800); + /* Highlight is persistent until the visitor mouses onto the + highlighted host element — then THAT element's halo clears. + Hovering or clicking inside the widget never affects host + highlights; only direct host-side intent does. */ + const handlers = handle.targets.map(el => { + const onEnter = () => { + el.classList.remove(HIGHLIGHT_CLASS); + el.classList.remove(FLASH_CLASS); + el.removeEventListener('mouseenter', onEnter); + }; + el.addEventListener('mouseenter', onEnter); + return { el, onEnter }; + }); + return () => { + window.clearTimeout(t); + handlers.forEach(({ el, onEnter }) => + el.removeEventListener('mouseenter', onEnter), + ); + handle.cleanup(); + }; + }, [turns]); + + /* Typewriter constants — every bit of bot-authored text in the + widget (concierge stream, homepage starters, landing turns) runs + through the same throttle so the panel reads at one consistent + tempo. 1 char / 22ms ≈ 45 chars/sec — smooth char-by-char reveal, + reads as deliberate rather than firehosed. */ + const STREAM_CHUNK = 1; + const STREAM_TICK = 22; + const SETTLE_MS = 200; + + /* Streaming typewriter — accepts a growing target via push() and + drips it into the named turn at the typewriter tempo. finish() + marks the target final and runs onDone once the displayed text + has caught up. Works for both live server streams (where the + target keeps growing) and pre-canned text (single push). */ + const createTypewriter = (turnId: string) => { + let target = ''; + let displayed = ''; + let timerActive = false; + let streamDone = false; + let pendingDone: (() => void) | null = null; + + const advance = () => { + if (displayed.length < target.length) { + const next = Math.min(displayed.length + STREAM_CHUNK, target.length); + displayed = target.slice(0, next); + setTurns(prev => + prev.map(tt => + tt.id === turnId ? { ...tt, answer: displayed } : tt, + ), + ); + } + if (displayed.length < target.length) { + window.setTimeout(advance, STREAM_TICK); + return; + } + timerActive = false; + if (streamDone && pendingDone) { + const cb = pendingDone; + pendingDone = null; + window.setTimeout(cb, SETTLE_MS); + } + }; + const kick = () => { + if (timerActive) return; + if (displayed.length >= target.length) return; + timerActive = true; + advance(); + }; + + return { + push: (next: string) => { + target = next; + kick(); + }, + finish: (onDone: () => void) => { + streamDone = true; + if (displayed.length >= target.length) { + window.setTimeout(onDone, SETTLE_MS); + } else { + pendingDone = onDone; + kick(); + } + }, + }; + }; + + /* Homepage carve-out: render a starter Q&A as a local Turn. + Mimics the real concierge pipeline visually — a short "thinking" + beat with the streaming caret, then the answer types in chunks + through the shared typewriter, then the cards land. */ + const runStarter = (starter: HomepageStarter) => { + trackEvent('homepage_starter_clicked', { lang, q: starter.q }); + const id = `${Date.now()}-starter`; + const emptyTurn: Turn = { + id, + query: starter.q, + answer: '', + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: true, + }; + justSubmittedRef.current = true; + setTurns(prev => [...prev, emptyTurn]); + + /* Thinking pause tuned to the real concierge's average latency + so the carve-out reads at the same tempo as a live round-trip. */ + const THINK_MS = 2200; + + window.setTimeout(() => { + const typer = createTypewriter(id); + typer.push(starter.a); + typer.finish(() => { + setTurns(prev => + prev.map(tt => + tt.id === id + ? { + ...tt, + answer: starter.a, + citations: starter.cards, + isStreaming: false, + } + : tt, + ), + ); + }); + }, THINK_MS); + }; + + const runQuery = async (query: string, replaceTurnId?: string) => { + setLoading(true); + const id = replaceTurnId ?? `${Date.now()}`; + const newTurn: Turn = { + id, + query, + answer: '', + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: true, + }; + justSubmittedRef.current = true; + setTurns(prev => + replaceTurnId + ? prev.map(tt => (tt.id === replaceTurnId ? newTurn : tt)) + : [...prev, newTurn], + ); + + trackEvent('query_sent', { lang, retry: !!replaceTurnId }); + + /* Identity-trigger short-circuit: brand-critical "about us" + questions (what is keepsimple / is it free / who's Wolf / etc.) + get a hand-crafted answer rendered locally — no LLM call, no + drift. Same think-pause + typewriter as homepage starters so it + reads like a live response. Fires on any page. */ + const identityHit = matchIdentityTrigger(query, lang); + if (identityHit) { + trackEvent('identity_trigger_hit', { + lang, + key: identityHit.key, + }); + window.setTimeout(() => { + const typer = createTypewriter(id); + typer.push(identityHit.answer); + typer.finish(() => { + setTurns(prev => + prev.map(tt => + tt.id === id + ? { + ...tt, + answer: identityHit.answer, + citations: identityHit.cards, + isStreaming: false, + } + : tt, + ), + ); + setLoading(false); + }); + }, 2200); + return; + } + + try { + /* Send last 6 finished turns so follow-ups like "how do I do that?" + have anchor context. Nav turns are interleaved so the LLM sees + the journey (e.g., asked about anchors → moved to Mental + Accounting → now asking again). Skip the in-flight turn. */ + const history = turns + .filter(tt => tt.id !== id && !tt.isStreaming) + .filter(tt => + tt.kind === 'nav' ? !!tt.navTitle : !!(tt.query && tt.answer), + ) + .slice(-6) + .map(tt => + tt.kind === 'nav' + ? { q: '', a: '', nav: tt.navTitle ?? '' } + : { q: tt.query, a: tt.answer.slice(0, 400) }, + ); + /* Collect URLs of cards we've shown in recent turns so the server + can tell the LLM to prefer fresh recommendations and not loop + the same surface cards each turn. */ + const recentCardUrls = Array.from( + new Set( + turns + .filter(tt => tt.id !== id) + .slice(-4) + .flatMap(tt => tt.citations.map(c => c.url)), + ), + ); + /* The most recent card the visitor actually clicked, with its + relevance tier. Lets the server's follow-up-question rule + fire when we handed them a soft match (1/3 or 2/3 dots), + since that's the right moment to re-orient. */ + const lastPick = (() => { + for (let i = turns.length - 1; i >= 0; i -= 1) { + const tt = turns[i]; + if (tt.id === id) continue; + const picked = tt.citations.find(c => c.picked); + if (!picked) continue; + const tier = tierFor(picked.score ?? 0, picked.nominated); + const tierName: 'high' | 'mid' | 'low' = + tier.dots >= 3 ? 'high' : tier.dots === 2 ? 'mid' : 'low'; + return { + url: picked.url, + title: picked.title, + tier: tierName, + }; + } + return null; + })(); + /* Server tokens often arrive in bursts; route them through the + shared typewriter so every answer types in at the same steady + tempo as the homepage starters. Cards/suggestions attach in + finish() once the displayed text catches up. */ + const typer = createTypewriter(id); + const onChunk = (current: string) => { + typer.push(stripMarkers(current)); + }; + const result = await askConcierge( + query, + lang, + history, + recentCardUrls, + '/api/concierge', + onChunk, + lastPick, + threadIdRef.current, + ); + const cleaned = stripMarkers(result.answer); + typer.push(cleaned); + typer.finish(() => { + setTurns(prev => + prev.map(tt => + tt.id === id + ? { + ...tt, + answer: cleaned, + citations: result.citations, + suggestions: result.suggestions, + mode: result.mode, + isStreaming: false, + } + : tt, + ), + ); + trackEvent('answer_received', { + lang, + citations: result.citations.length, + mode: result.mode, + }); + }); + } catch (e) { + const code = errCode(e); + const errText = + code === 'rate' + ? t.rateErr + : code === 'network' + ? t.networkErr + : t.serverErr; + setTurns(prev => + prev.map(tt => + tt.id === id ? { ...tt, isStreaming: false, error: errText } : tt, + ), + ); + trackEvent('answer_error', { lang, code }); + } finally { + setLoading(false); + } + }; + + const submit = async (e?: FormEvent) => { + if (e) e.preventDefault(); + const query = text.trim(); + if (!query || loading) return; + setText(''); + await runQuery(query); + }; + + const onCardClick = (citation: Citation) => { + if (!citation.url) return; + trackEvent('card_click', { url: citation.url, type: citation.type }); + const tier: 'high' | 'mid' | 'low' = citation.nominated + ? 'high' + : (citation.score ?? 0) >= 0.5 + ? 'high' + : (citation.score ?? 0) >= 0.3 + ? 'mid' + : 'low'; + postCopilotEvent({ + kind: 'card_click', + threadId: threadIdRef.current, + lang, + cardClicked: { + title: citation.title, + url: citation.url, + tier, + }, + }); + const isMobile = + typeof window !== 'undefined' && + window.matchMedia('(max-width: 480px)').matches; + const target = rewriteToCurrentHost(citation.url); + /* Same-pathname + has hash → in-page jump. The host's own JS + handlers (Atlas dossier opener, accordion expanders, etc.) + fire on hashchange and the user stays where they are, no + reload, no card-click landing fetch. */ + let parsed: URL | null = null; + try { + parsed = new URL(target, window.location.origin); + } catch { + parsed = null; + } + const samePath = parsed && parsed.pathname === window.location.pathname; + const hasHash = !!parsed?.hash; + const updatedTurns = turns.map(tt => ({ + ...tt, + citations: tt.citations.map(c => + c.url === citation.url ? { ...c, picked: true } : c, + ), + })); + if (samePath && hasHash) { + setTurns(updatedTurns); + saveState({ + open: !isMobile, + turns: updatedTurns, + awaitingRelevance: false, + pendingLanding: null, + }); + window.location.hash = parsed!.hash; + return; + } + const lastAnswer = [...turns].reverse().find(tt => tt.query && tt.answer); + /* Optimistic landing: append the new NOW VIEWING marker as a + streaming-state landing turn BEFORE we navigate. Past content + greys immediately, the visitor sees "we're moving you" instead + of staring at the old answer until the LLM round-trip returns. + On the destination mount, the landing fetch replaces this + turn's content by id. */ + const placeholderId = `land-${Date.now()}`; + const optimisticTurn: Turn = { + id: placeholderId, + query: '', + answer: '', + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: true, + kind: 'landing', + navTitle: cleanPageTitle(citation.title) || citation.title, + }; + const turnsWithOptimistic = [...updatedTurns, optimisticTurn]; + const pendingLanding: PendingLanding = { + url: citation.url, + title: citation.title, + prevQuery: lastAnswer?.query ?? '', + prevAnswer: lastAnswer?.answer ?? '', + placeholderId, + tabId: getTabId(), + createdAt: Date.now(), + }; + saveState({ + open: !isMobile, + turns: turnsWithOptimistic, + awaitingRelevance: isMobile, + pendingLanding, + }); + window.location.href = target; + }; + + const onSuggestionClick = (suggestion: string) => { + if (loading) return; + trackEvent('suggestion_click', { lang }); + runQuery(suggestion); + }; + + const onClose = () => { + trackEvent('panel_close', { turns: turns.length }); + setOpen(false); + if (!collapsedOnce) { + setCollapsedOnce(true); + try { + localStorage.setItem(COLLAPSED_ONCE_KEY, '1'); + } catch { + /* ignore */ + } + } + }; + + const [copied, setCopied] = useState(false); + + const onClearAll = () => { + setTurns([]); + setText(''); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + trackEvent('clear_all', {}); + const oldThreadId = threadIdRef.current; + threadIdRef.current = rotateThreadId(); + postCopilotEvent({ + kind: 'clear', + threadId: threadIdRef.current, + oldThreadId, + lang, + }); + }; + const onCopyTranscript = async () => { + const lines: string[] = []; + for (const turn of turns) { + if (turn.kind === 'nav') { + lines.push(`[NAV] → ${turn.navTitle ?? ''}`); + lines.push(''); + continue; + } + if (turn.query) lines.push(`[USER] ${turn.query}`); + if (turn.error) { + lines.push(`[ERROR] ${turn.error}`); + } else if (turn.answer) { + lines.push(`[BOT · ${turn.mode}] ${turn.answer}`); + } + if (turn.mode === 'clarify' && turn.suggestions.length > 0) { + lines.push(`[SUGGESTIONS] ${turn.suggestions.join(' | ')}`); + } + if (turn.mode === 'answer' && turn.citations.length > 0) { + lines.push('[CARDS]'); + for (const c of turn.citations) { + const tk = detectType(c.type, c.url); + const label = tk ? TYPE_INFO[tk].en : 'Link'; + lines.push(` - ${label}: ${c.title} → ${c.url}`); + } + } + lines.push(''); + } + lines.push(`[META] page: ${window.location.href} | lang: ${lang}`); + const text = lines.join('\n').trim(); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // give up silently + } + document.body.removeChild(ta); + } + }; + + const onOpen = () => { + trackEvent('pill_open', { + hasHistory: turns.length > 0, + relevancePrompt: awaitingRelevance, + }); + setAwaitingRelevance(false); + setOpen(true); + }; + + return ( +
+
+
+ + + + {/* Immersion is the existing idle-opacity preference in a + quieter shape: a small opacity-style icon next to CLEAR. + The icon's inner fill reflects the current opacity, so the + control hints at its own state at a glance. Click → small + menu with Low / Medium / High. */} + {(() => { + const levels: Array<{ + key: 'low' | 'medium' | 'high'; + value: 0.85 | 0.55 | 0.3; + label: string; + }> = [ + { key: 'low', value: 0.85, label: t.immersionLow }, + { key: 'medium', value: 0.55, label: t.immersionMedium }, + { key: 'high', value: 0.3, label: t.immersionHigh }, + ]; + return ( +
+ + {immersionOpen && ( +
+ {levels.map(l => ( + + ))} +
+ )} +
+ ); + })()} + + +
+ + {turns.length === 0 ? ( +
+ {t.empty} + {(() => { + /* Homepage carve-out: replace server-driven page + suggestions with hand-crafted first-touch starters + (HOMEPAGE_STARTERS). Click → runStarter renders a + local Q&A turn with pre-canned answer + cards. */ + if (atHome) { + const starters = HOMEPAGE_STARTERS[lang]; + return ( +
+ {starters.map((s, i) => ( + + ))} +
+ ); + } + /* Merge the page's own "recommended question" (harvested + from the host DOM — bias cards ship one) into the empty- + state chips. Leads the list when present so the visitor + sees a page-anchored prompt first. Deduped against the + cached suggestions. Caps at 3 total. */ + const merged: string[] = []; + const seen = new Set(); + const push = (s: string) => { + const k = s.replace(/\s+/g, ' ').trim().toLowerCase(); + if (!k || seen.has(k)) return; + seen.add(k); + merged.push(s); + }; + if (recommendedQ) push(recommendedQ); + for (const s of pageSuggestions) push(s); + const display = merged.slice(0, 3); + if (display.length === 0) return null; + return ( +
+ {display.map((s, i) => ( + + ))} +
+ ); + })()} +
+ ) : ( +
+ {(() => { + let lastSpatialIdx = -1; + turns.forEach((tt, i) => { + if (tt.kind === 'nav' || tt.kind === 'landing') + lastSpatialIdx = i; + }); + /* Nav chip + landing turn fire on the same hop and both + carry the same page title. The landing turn already + prints a "Viewed: X" header, so the nav chip becomes a + duplicate the moment a landing for the same page + arrives. Skip the nav whenever a same-page landing + follows it before the next nav. */ + const navTitleEq = ( + a: string | undefined, + b: string | undefined, + ) => + cleanPageTitle(a || '').toLowerCase() === + cleanPageTitle(b || '').toLowerCase(); + const skipNavIdx = new Set(); + turns.forEach((tt, i) => { + if (tt.kind !== 'nav') return; + for (let j = i + 1; j < turns.length; j += 1) { + const nx = turns[j]; + if (nx.kind === 'nav') break; + if ( + nx.kind === 'landing' && + navTitleEq(nx.navTitle, tt.navTitle) + ) { + skipNavIdx.add(i); + break; + } + } + }); + return turns.map((turn, idx) => { + if (turn.kind === 'nav' && skipNavIdx.has(idx)) return null; + const isCurrentSpatial = + (turn.kind === 'nav' || turn.kind === 'landing') && + idx === lastSpatialIdx; + const isBeforeCurrent = + lastSpatialIdx >= 0 && idx < lastSpatialIdx; + const dimClass = isBeforeCurrent ? ' ks-aux-pre-current' : ''; + if (turn.kind === 'nav') { + return ( +
+ {isCurrentSpatial && } + + {isCurrentSpatial ? t.navLabel : t.viewedLabel} + + :{' '} + {isCurrentSpatial ? ( + {turn.navTitle} + ) : ( + {turn.navTitle} + )} +
+ ); + } + return ( +
+ {turn.kind === 'landing' && ( +
+ {isCurrentSpatial && } + + {isCurrentSpatial ? t.navLabel : t.viewedLabel} + + {turn.navTitle && ( + <> + :{' '} + {isCurrentSpatial ? ( + {turn.navTitle} + ) : ( + {turn.navTitle} + )} + + )} +
+ )} + {turn.query &&
{turn.query}
} + {turn.error ? ( +
+ {turn.error} + +
+ ) : turn.isStreaming && !turn.answer ? ( +
+ ) : ( +
+ {turn.answer} + {turn.isStreaming && ( +
+ )} + {!turn.isStreaming && !turn.error && ( + <> + {turn.kind === 'landing' && + turn.landingKey === '/uxcat' && + onUxcatRoot && ( +
+ + {t.uxcatNudge} + + +
+ )} + {turn.mode === 'clarify' && + turn.suggestions.length > 0 && ( +
+ {turn.suggestions.map((s, i) => ( + + ))} +
+ )} + {turn.mode === 'answer' && + turn.citations.length > 0 && ( +
+ {(() => { + /* Sort by perceived strength: nominated (LLM + hand-pick or curated surface) outranks any + scored card, then library cards descend by + LightRAG score. Stable for equal keys so + server order acts as the tie-breaker. */ + const rank = (c: Citation) => + c.nominated ? 1e6 : (c.score ?? 0); + return [...turn.citations] + .map((c, idx) => ({ c, idx })) + .sort((a, b) => { + const d = rank(b.c) - rank(a.c); + return d !== 0 ? d : a.idx - b.idx; + }) + .map(x => x.c); + })().map((c, i) => { + const tk = detectType(c.type, c.url); + const info = tk ? TYPE_INFO[tk] : null; + const href = rewriteToCurrentHost(c.url); + let prefetchTimer: ReturnType< + typeof setTimeout + > | null = null; + const onHoverIn = () => { + if (prefetchTimer) + clearTimeout(prefetchTimer); + prefetchTimer = setTimeout(() => { + prefetchOnce(href); + }, 80); + }; + const onHoverOut = () => { + if (prefetchTimer) { + clearTimeout(prefetchTimer); + prefetchTimer = null; + } + }; + return ( + { + e.preventDefault(); + onCardClick(c); + }} + > + {c.picked && ( + + {t.yourPick} + + )} + + {info && ( + + {info[lang]}: + + )} + + {c.title} + + + {c.blurb && ( + + {c.blurb} + + )} + {c.why && ( + + {c.why} + + )} + {(typeof c.score === 'number' || + c.nominated) && + (() => { + const tier = tierFor( + c.score ?? 0, + c.nominated, + ); + return ( + + {Array.from( + { length: TIER_DOTS }, + (_, d) => d, + ).map(d => ( + + ))} + + ); + })()} + + ); + })} +
+ )} + + )} +
+ ); + }); + })()} +
+ )} + +
+