From e5abbf4a11782046f2aa414c2e9813f8b0d51961 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 28 Apr 2026 20:28:59 +0400 Subject: [PATCH 01/87] fix(seo): use locale-prefixed URL for JSON-LD and twitter:url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two SEO bugs in SeoGenerator emitted URLs without the active locale prefix. Search engines indexing structured data on RU/HY pages would record the wrong canonical, leading to either a hard 404 (when slugs differ between locales — e.g. uxcore Russian transliterations) or silent locale-dilution (when slugs are the same — e.g. /ru/articles/awareness-test). Both issues stem from `router.asPath` returning the canonical (un-prefixed) path under Next.js Pages Router i18n. The existing `originalUrl` already prepends `localePath` correctly and is the source of truth used by the `` and `` tags. 1. JSON-LD `"url"`: schema generator received `pageUrl` built from `domain + router.asPath`, dropping the `/ru` or `/hy` prefix. Now reuses `originalUrl` so JSON-LD matches canonical. 2. ``: built from `https://keepsimple.io/${localePath}${alternateLink}` — a literal `/` between domain and `localePath` produced double-slash URLs (`https://keepsimple.io//ru/...` for RU, `https://keepsimple.io//...` for EN). Now also uses `originalUrl` for consistency and to eliminate the divergent-builder pattern that produced the original bug. Verified on /ru/articles/awareness-test and /ru/tools/longevity-protocol/about-project that JSON-LD `url` previously emitted the unprefixed path; rendered HTML before fix attached in the linkinator scan at reports/links-2026-04-28.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/SeoGenerator/SeoGenerator.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/SeoGenerator/SeoGenerator.tsx b/src/components/SeoGenerator/SeoGenerator.tsx index e882d0e8..437fc7bb 100644 --- a/src/components/SeoGenerator/SeoGenerator.tsx +++ b/src/components/SeoGenerator/SeoGenerator.tsx @@ -166,11 +166,10 @@ const SeoGenerator: FC = ({ const originalUrl = process.env.NEXT_PUBLIC_DOMAIN + localePath + cleanURL(alternateLink); const favIcon = `${process.env.NEXT_PUBLIC_DOMAIN}${favIconPath}`; - const pageUrl = `${process.env.NEXT_PUBLIC_DOMAIN}${router.asPath}`; const schema = generateSchema( title, stripHTML(description), - pageUrl, + originalUrl, favIcon, createdDate, modifiedDate, @@ -290,10 +289,7 @@ const SeoGenerator: FC = ({ : ogTags?.ogImage?.data?.attributes?.staticUrl } /> - + + + +`; +} + +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..9c6e078a 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,17 @@ 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/ 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..020ac7c0 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 | 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/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index 3e181cc6..00000000 --- a/cypress.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { defineConfig } from 'cypress'; -import { GoogleSocialLogin } from 'cypress-social-logins/src/Plugins'; - -export default defineConfig({ - e2e: { - baseUrl: process.env.CYPRESS_BASE_URL || 'http://localhost:3005', - setupNodeEvents(on, config) { - on('task', { - // We don't need this for now. - GoogleSocialLogin, - }); - return config; - }, - env: { - googleRefreshToken: process.env.GOOGLE_REFRESH_TOKEN, - googleClientId: process.env.GOOGLE_CLIENT_ID, - googleClientSecret: process.env.GOOGLE_CLIENT_SECRET, - STRAPI_URL: - process.env.STRAPI_URL || - process.env.CYPRESS_STRAPI_URL || - 'https://strapi.keepsimple.io', - }, - }, -}); diff --git a/cypress/cypress.json b/cypress/cypress.json deleted file mode 100644 index 14290219..00000000 --- a/cypress/cypress.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "baseUrl": "http://localhost:3005", - "chromeWebSecurity": false -} diff --git a/cypress/e2e/articles/articles-links.spec.cy.ts b/cypress/e2e/articles/articles-links.spec.cy.ts deleted file mode 100644 index 4a0c2b62..00000000 --- a/cypress/e2e/articles/articles-links.spec.cy.ts +++ /dev/null @@ -1,52 +0,0 @@ -describe('External Links from API', () => { - let routes: string[] = []; - - before(() => { - const strapiUrl = - Cypress.env('STRAPI_URL') || 'https://strapi.keepsimple.io'; - const apiUrl = `${strapiUrl}/api/articles?locale=en&fields[1]=newUrl`; - - cy.request(apiUrl).then(response => { - routes = response.body.data.map( - item => - `${Cypress.config().baseUrl}/articles/${item.attributes.newUrl}` || - '/', - ); - }); - }); - - it('should check external links on every route from API', () => { - routes.forEach(route => { - cy.wait(1000); - cy.visit(route); - - cy.get('a').each($a => { - const message = $a.text(); - const href = $a.prop('href'); - - expect($a, message) - .to.have.attr('href') - .and.not.match(/undefined|null|^$/); - - if ( - href && - href.startsWith('http') && - !href.includes('http://localhost:3005') && - !href.includes('linkedin.com') && - !href.includes('facebook.com') && - !href.includes('/uxcore') - ) { - cy.request({ - url: href, - failOnStatusCode: false, - }).then(response => { - cy.log( - `Checking external link: ${href} - Status: ${response.status}`, - ); - expect(response.status).to.be.oneOf([200, 301, 302, 403]); - }); - } - }); - }); - }); -}); diff --git a/cypress/e2e/articles/articles.cy.ts b/cypress/e2e/articles/articles.cy.ts deleted file mode 100644 index 4cd98fca..00000000 --- a/cypress/e2e/articles/articles.cy.ts +++ /dev/null @@ -1,37 +0,0 @@ -describe('template spec', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(`${Cypress.config().baseUrl}/articles`); - }); - - it('Should show a h1', () => { - cy.checkH1(); - }); - - it("Should click and scroll to 'UX Core' section", () => { - cy.scrollToSection('UX Core'); - }); - - it("Should click and scroll to 'Thoughts' section", () => { - cy.scrollToSection('Thoughts'); - }); - - it("Should click and scroll to 'Project Management' section", () => { - cy.scrollToSection('Project Management'); - }); - - it('Should check all internal links', () => { - cy.get('a').each($a => { - const message = $a.text(); - expect($a, message).to.have.attr('href').not.contain('undefined'); - }); - }); - - it('Should toggle Show Less Button', () => { - cy.get('[data-cy="show more-less button"]').click(); - }); - - it('Should toggle Show More Button', () => { - cy.get('[data-cy="show more-less button"]').click(); - }); -}); diff --git a/cypress/e2e/articles/what-is-ux-core.cy.ts b/cypress/e2e/articles/what-is-ux-core.cy.ts deleted file mode 100644 index b4d84365..00000000 --- a/cypress/e2e/articles/what-is-ux-core.cy.ts +++ /dev/null @@ -1,21 +0,0 @@ -describe('template spec', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(`${Cypress.config().baseUrl}/articles/what-is-ux-core`); - }); - - it('Should show a h1', () => { - cy.checkH1(); - }); - - it('verifies all image src URLs are valid', () => { - cy.validateAllImages(); - }); - - it('zooms in on the first image and zooms out on second click', () => { - cy.get('[data-cy="zoom-trigger"]').first().click(); - cy.get('[data-cy="zoomed-image"]:visible').should('exist'); - cy.get('[data-cy="zoomed-image"]:visible').click(); - cy.get('[data-cy="zoomed-image"]:visible').should('not.exist'); - }); -}); diff --git a/cypress/e2e/company-management/company-management.cy.ts b/cypress/e2e/company-management/company-management.cy.ts deleted file mode 100644 index aef1327e..00000000 --- a/cypress/e2e/company-management/company-management.cy.ts +++ /dev/null @@ -1,51 +0,0 @@ -describe('template spec', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(`${Cypress.config().baseUrl}/company-management`); - }); - - it('Should show a h1', () => { - cy.checkH1(); - }); - - it('Should start playing a music', () => { - cy.playAudio(); - }); - - it('should switch pyramids correctly', () => { - cy.checkPyramidChange('1', '2', '0'); - }); - - it('checks external links on a specific page', () => { - const singleRoute = `${Cypress.config().baseUrl}/company-management`; - - cy.visit(singleRoute); - - cy.get('a').each($a => { - const href = $a.prop('href'); - const message = $a.text(); - - expect($a, message) - .to.have.attr('href') - .and.not.match(/undefined|null|^$/); - - if ( - href && - href.startsWith('http') && - !href.includes('localhost') && - !href.includes('linkedin.com') && - !href.includes('facebook.com') - ) { - cy.request({ - url: href, - failOnStatusCode: false, - }).then(response => { - cy.log( - `Checking external link: ${href} - Status: ${response.status}`, - ); - expect(response.status).to.be.oneOf([200, 301, 302, 403]); - }); - } - }); - }); -}); diff --git a/cypress/e2e/config.ts b/cypress/e2e/config.ts deleted file mode 100644 index c86050f4..00000000 --- a/cypress/e2e/config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from 'cypress'; - -export default defineConfig({ - e2e: { - setupNodeEvents() { - // setup code - }, - baseUrl: 'http://localhost:3005', - viewportWidth: 1920, - viewportHeight: 900, - }, -}); - -export const viewports = [ - { name: 'Desktop', width: 1920, height: 920 }, - { name: 'Mobile', width: 375, height: 667 }, -]; diff --git a/cypress/e2e/cross-browser.spec.cy.ts b/cypress/e2e/cross-browser.spec.cy.ts deleted file mode 100644 index a3baab77..00000000 --- a/cypress/e2e/cross-browser.spec.cy.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Keeping this for the future when we want to implement cross-browser testing -// -// const viewports = [ -// { name: 'Desktop', width: 1920, height: 920 }, -// { name: 'Mobile', width: 375, height: 667 }, -// ]; -// -// const browsers = ['chrome', 'firefox', 'edge']; -// -// beforeEach(() => { -// cy.on('uncaught:exception', err => { -// cy.log(`Uncaught exception: ${err.message}`); -// return false; -// }); -// }); -// -// describe('Route Tests Across Browsers', () => { -// browsers.forEach(browser => { -// describe(`Cross-browser tests in ${browser}`, () => { -// routesToCheck.forEach(route => { -// describe(`Testing route: ${route}`, () => { -// viewports.forEach(vp => { -// it(`should load the route ${route} on ${vp.name} in ${browser}`, () => { -// Cypress.browser.name = browser; -// -// cy.viewport(vp.width, vp.height); -// cy.visit(route); -// cy.url().should('include', route); -// cy.visit(route, { timeout: 20000 }); -// cy.get('section').should('exist'); -// }); -// }); -// }); -// }); -// }); -// }); -// }); diff --git a/cypress/e2e/landing-page/landing-page.cy.ts b/cypress/e2e/landing-page/landing-page.cy.ts deleted file mode 100644 index f5c85d43..00000000 --- a/cypress/e2e/landing-page/landing-page.cy.ts +++ /dev/null @@ -1,38 +0,0 @@ -describe('template spec', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(`${Cypress.config().baseUrl}`); - }); - - it('Should show a h1', () => { - cy.checkH1(); - }); - - it('should check external links and their accessibility', () => { - cy.checkExternalLinks(['linkedin.com', 'facebook.com']); - }); - - it('should check Our Tools', () => { - cy.get('[data-test-id="tool"]').click({ multiple: true }); - }); - - it('should start Serenity Mode', () => { - cy.get('[data-test-id="start-serenity"]').click(); - cy.get('audio').should('have.prop', 'paused', false); - - cy.get('[data-test-id="exit-serenity"]').click(); - }); - - it('should change theme color', () => { - cy.get('[data-test-id="theme-toggle"]').click(); - cy.get('body').should('have.class', 'darkTheme keepsimplePagesDark'); - }); - - it('should change the language to Russian', () => { - cy.get('[data-test-id="language-toggle"]', { timeout: 5000 }) - .should('exist') - .click(); - cy.url().should('include', '/ru'); - cy.checkH1(); - }); -}); diff --git a/cypress/e2e/longevity-protocol/about-project.cy.ts b/cypress/e2e/longevity-protocol/about-project.cy.ts deleted file mode 100644 index 0551cc56..00000000 --- a/cypress/e2e/longevity-protocol/about-project.cy.ts +++ /dev/null @@ -1,65 +0,0 @@ -export {}; -const PAGE = '/tools/longevity-protocol/about-project'; -const DEFAULT_DNA_SRC = '/keepsimple_/assets/longevity/dna/default.mp4'; - -describe('About Project – /tools/longevity-protocol/about-project', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. DNA canvas: default.mp4 is present, autoplaying and looping - it('renders the default DNA video with autoplay and loop', () => { - cy.get(`video[src="${DEFAULT_DNA_SRC}"]`) - .should('exist') - .should($video => { - expect($video).to.have.attr('autoplay'); - expect($video).to.have.attr('loop'); - }); - }); - - // 3. All images load without undefined or broken src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - - // 4. Basic stats section contains all 5 stats with non-empty values - it('shows all basic stats with non-empty values', () => { - cy.get('[data-cy="basic-stats"]').should('exist'); - cy.get('[data-cy="stat-item"]').should('have.length', 5); - - cy.get('[data-cy="stat-value"]').each($span => { - const text = $span.text().trim(); - expect(text, 'Stat value should not be empty').to.not.be.empty; - expect(text, 'Stat value should not contain "undefined"').to.not.include( - 'undefined', - ); - }); - }); - - // 5. All internal and external links are valid (reuses checkPageLinks command) - it('has no broken internal or external links', () => { - cy.checkPageLinks(); - }); -}); diff --git a/cypress/e2e/longevity-protocol/diet.cy.ts b/cypress/e2e/longevity-protocol/diet.cy.ts deleted file mode 100644 index 569a0bb1..00000000 --- a/cypress/e2e/longevity-protocol/diet.cy.ts +++ /dev/null @@ -1,162 +0,0 @@ -const PAGE = '/tools/longevity-protocol/habits/diet'; - -describe('Diet – /tools/longevity-protocol/habits/diet', () => { - describe('Desktop (1920x900)', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. Japanese text existence - it('has Japanese text rendered', () => { - cy.checkJapaneseText(); - }); - - // 3. WhatToEatOrAvoid – click second selectable item, checkbox state updates - it('updates checkbox state when clicking a WhatToEatOrAvoid item', () => { - // Items with role="checkbox" only exist in the "what to eat" section - cy.scrollTo(0, 1600); - cy.get('[data-cy="diet-checkbox"]').eq(0).scrollIntoView(); - - // First item starts selected – checkmark is present - cy.get('[data-cy="diet-checkbox"]') - .eq(0) - .find('[data-cy="diet-checkmark"]') - .should('exist'); - - // Click the second selectable item - cy.get('[data-cy="diet-checkbox"]') - .eq(1) - .closest('[data-cy="what-to-eat-or-avoid"]') - .click({ force: true }); - - // Second item is now checked - cy.get('[data-cy="diet-checkbox"]') - .eq(1) - .find('[data-cy="diet-checkmark"]') - .should('exist'); - - // First item is now unchecked - cy.get('[data-cy="diet-checkbox"]') - .eq(0) - .find('[data-cy="diet-checkmark"]') - .should('not.exist'); - }); - - // 4. DietResults – click second item → active states update + YourDiet data changes - it('activates second DietResults item and updates YourDiet data', () => { - // Scroll to the DietResults component - cy.get('[data-cy="diet-results-item"]').first().scrollIntoView(); - cy.wait(300); - - // Dismiss the cookie banner so it does not cover the result items - cy.get('[data-cy="cookie-box-accept"]').click(); - - // Initial state: first item (id=1) is active, second is not - cy.get('[data-cy="diet-results-item"]') - .eq(0) - .should('have.attr', 'data-active', 'true'); - cy.get('[data-cy="diet-results-item"]') - .eq(1) - .should('have.attr', 'data-active', 'false'); - - // Capture the current YourDiet selected id before the change - cy.get('[data-cy="your-diet"]') - .invoke('attr', 'data-selected-id') - .then(idBefore => { - // Click the inner img of the second item; the img click bubbles to the - // parent div's onClick and avoids any ::after overlay that may block the hit - cy.get('[data-cy="diet-results-item"]') - .eq(1) - .find('img') - .click({ force: true }); - - cy.wait(300); - - // Active states have flipped - cy.get('[data-cy="diet-results-item"]') - .eq(1) - .should('have.attr', 'data-active', 'true'); - cy.get('[data-cy="diet-results-item"]') - .eq(0) - .should('have.attr', 'data-active', 'false'); - - // YourDiet animation was triggered - cy.get('[data-cy="your-diet"]').should( - 'have.attr', - 'data-active', - 'true', - ); - - // YourDiet is now displaying a different diet entry (data-selected-id changed) - cy.get('[data-cy="your-diet"]') - .invoke('attr', 'data-selected-id') - .should('not.equal', idBefore); - }); - }); - // 5. All internal and external links are valid (reuses shared checkPageLinks command) - it('has no broken internal or external links', () => { - cy.checkPageLinks(); - }); - - // 6. All images load – no undefined in src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - }); - - describe('Mobile (390x844)', () => { - beforeEach(() => { - cy.viewport(390, 844); - cy.visit(PAGE); - }); - - // Mobile modal – tapping the heart image in WhatToEatOrAvoid opens the - // AboutTheProduct modal; closing via the modal close icon dismisses it - it('opens and closes the mobile AboutTheProduct modal on heart image tap', () => { - // Dismiss the cookie banner before any interaction - cy.get('[data-cy="cookie-box-accept"]').click(); - - // Scroll to the first WhatToEatOrAvoid card that has a heart trigger - cy.get('[data-cy="heart-trigger"]').first().scrollIntoView(); - - // Tap the heart image – on mobile this opens the modal instead of a tooltip - cy.get('[data-cy="heart-trigger"]') - .first() - .find('img') - .click({ force: true }); - - // AboutTheProduct content is now visible inside the portal modal - cy.get('[data-cy="about-product"]').should('be.visible'); - - // Close via the modal close icon - cy.get('[data-cy="modal-close-icon"]').click(); - - // Modal and its content are gone - cy.get('[data-cy="about-product"]').should('not.exist'); - }); - }); -}); - -export {}; diff --git a/cypress/e2e/longevity-protocol/environment.cy.ts b/cypress/e2e/longevity-protocol/environment.cy.ts deleted file mode 100644 index a1632b12..00000000 --- a/cypress/e2e/longevity-protocol/environment.cy.ts +++ /dev/null @@ -1,51 +0,0 @@ -export {}; -const PAGE = '/tools/longevity-protocol/environment'; -const DEFAULT_DNA_SRC = '/keepsimple_/assets/longevity/dna/blue.mp4'; - -describe('Environment – /tools/longevity-protocol/environment', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. DNA canvas: default.mp4 is present, autoplaying and looping - it('renders the default DNA video with autoplay and loop', () => { - cy.get(`video[src="${DEFAULT_DNA_SRC}"]`) - .should('exist') - .should($video => { - expect($video).to.have.attr('autoplay'); - expect($video).to.have.attr('loop'); - }); - }); - - // 3. All images load without undefined or broken src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - - // 4. All internal and external links are valid (reuses checkPageLinks command) - it('has no broken internal or external links', () => { - cy.checkPageLinks(); - }); -}); diff --git a/cypress/e2e/longevity-protocol/lifestyle.cy.ts b/cypress/e2e/longevity-protocol/lifestyle.cy.ts deleted file mode 100644 index 15123575..00000000 --- a/cypress/e2e/longevity-protocol/lifestyle.cy.ts +++ /dev/null @@ -1,97 +0,0 @@ -const PAGE = '/tools/longevity-protocol/habits/lifestyle'; -const RED_DNA_SRC = '/keepsimple_/assets/longevity/dna/red.mp4'; -describe('Lifestyle – /tools/longevity-protocol/habits/lifestyle', () => { - // 1–4, 6–7: desktop viewport - describe('Desktop (1920x900)', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. Japanese text existence - it('has Japanese text rendered', () => { - cy.checkJapaneseText(); - }); - - // 3. DNA layer: red.mp4 is loaded for habits pages and has autoplay + loop - it('renders the red DNA video with autoplay and loop', () => { - cy.get(`video[src="${RED_DNA_SRC}"]`) - .should('exist') - .should($video => { - expect($video).to.have.attr('autoplay'); - expect($video).to.have.attr('loop'); - }); - }); - - // 4. "Why do this" tooltip appears on desktop. - // react-tooltip v5 lazy-renders children; the content only exists after hover. - // it('shows why-do-this tooltip with content on hover (desktop)', () => { - // cy.visit(PAGE); - - // cy.get('[data-cy="why-do-this-trigger"]') - // .first() - // .should('be.visible') - // .scrollIntoView() - // .trigger('mouseenter', { force: true }) - // .trigger('mouseover', { force: true }); - // cy.wait(10000); - - // cy.get('[data-cy="why-do-this-content"]', { timeout: 10000 }).should( - // 'be.visible', - // ); - // }); - // // 5. All internal and external links are valid - // it('has no broken internal or external links', () => { - // cy.checkPageLinks(); - // }); - - // 6. All images load without undefined or broken src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - }); - - // 7. "Why do this" modal opens and closes on mobile - describe('Mobile (390x844)', () => { - beforeEach(() => { - cy.viewport(390, 844); - cy.visit(PAGE); - }); - - it('opens and closes the Why do this modal on mobile', () => { - cy.scrollTo(0, 400); - cy.get('[data-cy="why-do-this-trigger"]').first().click({ force: true }); - cy.wait(3000); - - cy.get('[data-cy="why-do-this-content"]').should('be.visible'); - - cy.get('[data-cy="cookie-box-accept"]').click(); - cy.get('[data-cy="why-do-this-modal-close"]').click(); - - cy.get('[data-cy="why-do-this-modal"]').should('not.exist'); - }); - }); -}); - -export {}; diff --git a/cypress/e2e/longevity-protocol/mobile-navigation.cy.ts b/cypress/e2e/longevity-protocol/mobile-navigation.cy.ts deleted file mode 100644 index 7ae39c3f..00000000 --- a/cypress/e2e/longevity-protocol/mobile-navigation.cy.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * MobileNavigation – tests for the "Next" button (BorderedPill) and the - * getNextNavItem / buildNavOrder ordering logic. - * - * buildNavOrder expands the "Habits" entry into its six sub-pages, skips the - * external AI-Assistant link, and produces this sequence: - * - * about-project → lifestyle → study → diet → workout → - * sleep → supplements → environment → results → (wraps to about-project) - */ - -const BASE = '/tools/longevity-protocol'; - -/** Full ordered sequence that buildNavOrder builds */ -const NAV_ORDER = [ - { path: `${BASE}/about-project`, name: 'About Project' }, - { path: `${BASE}/habits/lifestyle`, name: 'Lifestyle' }, - { path: `${BASE}/habits/study`, name: 'Study' }, - { path: `${BASE}/habits/diet`, name: 'Diet' }, - { path: `${BASE}/habits/workout`, name: 'Workout' }, - { path: `${BASE}/habits/sleep`, name: 'Sleep' }, - { path: `${BASE}/habits/supplements`, name: 'Supplements' }, - { path: `${BASE}/environment`, name: 'Environment' }, - { path: `${BASE}/results`, name: 'Results' }, -]; - -describe('MobileNavigation', () => { - beforeEach(() => { - // Mobile viewport – MobileNavigation is only rendered on small screens - cy.viewport(390, 844); - }); - - // ─── Next button: appearance ────────────────────────────────────────────── - - describe('Next button appearance (lines 246-253)', () => { - it('is visible on about-project', () => { - cy.visit(`${BASE}/about-project`); - cy.get('[data-cy="mobile-next-button"]').should('be.visible'); - }); - - it('shows the "Next:" prefix text', () => { - cy.visit(`${BASE}/about-project`); - cy.get('[data-cy="mobile-next-button"]').should('contain.text', 'Next:'); - }); - - it('shows "Lifestyle" as the next page from about-project', () => { - cy.visit(`${BASE}/about-project`); - cy.get('[data-cy="mobile-next-button"]').should( - 'contain.text', - 'Lifestyle', - ); - }); - - it('shows "About Project" as the next page from results (wrap-around)', () => { - cy.visit(`${BASE}/results`); - cy.get('[data-cy="mobile-next-button"]').should( - 'contain.text', - 'About Project', - ); - }); - }); - - // ─── getNextNavItem: full navigation order (lines 130-145) ──────────────── - - describe('getNextNavItem navigation order (lines 130-145)', () => { - NAV_ORDER.forEach((current, idx) => { - const next = NAV_ORDER[(idx + 1) % NAV_ORDER.length]; - - it(`${current.name} → next button shows "${next.name}"`, () => { - cy.visit(current.path); - cy.get('[data-cy="mobile-next-button"]').should( - 'contain.text', - next.name, - ); - }); - }); - }); - - // ─── Next button: click navigates to correct URL ────────────────────────── - - describe('Next button click navigation (lines 246-253)', () => { - it('navigates from about-project to lifestyle on click', () => { - cy.visit(`${BASE}/about-project`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/habits/lifestyle'); - }); - - it('navigates from lifestyle to study on click', () => { - cy.visit(`${BASE}/habits/lifestyle`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/habits/study'); - }); - - it('navigates from study to diet on click', () => { - cy.visit(`${BASE}/habits/study`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/habits/diet'); - }); - - it('navigates from diet to workout on click', () => { - cy.visit(`${BASE}/habits/diet`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/habits/workout'); - }); - - it('navigates from workout to sleep on click', () => { - cy.visit(`${BASE}/habits/workout`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/habits/sleep'); - }); - - it('navigates from sleep to supplements on click', () => { - cy.visit(`${BASE}/habits/sleep`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/habits/supplements'); - }); - - it('navigates from supplements to environment on click', () => { - cy.visit(`${BASE}/habits/supplements`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/environment'); - }); - - it('navigates from environment to results on click', () => { - cy.visit(`${BASE}/environment`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/results'); - }); - - it('wraps from results back to about-project on click', () => { - cy.visit(`${BASE}/results`); - cy.get('[data-cy="mobile-next-button"]').click(); - cy.url({ timeout: 10000 }).should('include', '/about-project'); - }); - }); - - // ─── Nav dropdown + Habits sub-menu flow ───────────────────────────────── - - describe('Nav dropdown → Habits sub-menu → Lifestyle route', () => { - it('opens the nav, expands Habits, clicks Lifestyle and lands on the correct route', () => { - cy.visit(`${BASE}/about-project`); - - // 1. Click the active-page toggle to open the nav dropdown - cy.get('[data-cy="mobile-nav-toggle"]').click(); - - // Nav list should now be open (contains the Habits item) - cy.get('[data-cy="mobile-habits-toggle"]').should('be.visible'); - - // 2. Click the Habits item to expand its sub-menu - cy.get('[data-cy="mobile-habits-toggle"]').click(); - - // Sub-nav dropdown should now be visible with all habit pages - cy.get('[data-cy="mobile-subnav"]').should('be.visible'); - - // 3. Click the first sub-nav item (Lifestyle) - cy.get('[data-cy="mobile-subnav"]').find('a').first().click(); - - // 4. URL should change to the Lifestyle page - cy.url({ timeout: 10000 }).should( - 'include', - '/tools/longevity-protocol/habits/lifestyle', - ); - }); - }); -}); - -export {}; diff --git a/cypress/e2e/longevity-protocol/navigation.cy.ts b/cypress/e2e/longevity-protocol/navigation.cy.ts deleted file mode 100644 index 74973f14..00000000 --- a/cypress/e2e/longevity-protocol/navigation.cy.ts +++ /dev/null @@ -1,34 +0,0 @@ -export {}; -const PAGES = [ - '/tools/longevity-protocol/about-project', - '/tools/longevity-protocol/habits/lifestyle', - '/tools/longevity-protocol/habits/study', - '/tools/longevity-protocol/habits/diet', - '/tools/longevity-protocol/habits/workout', - '/tools/longevity-protocol/habits/sleep', - '/tools/longevity-protocol/habits/supplements', - '/tools/longevity-protocol/environment', - '/tools/longevity-protocol/results', -]; - -describe('Longevity Protocol – Navigation', () => { - PAGES.forEach(path => { - it(`loads ${path} without errors`, () => { - cy.visit(path); - cy.get('main').should('exist'); - // No uncaught JS errors (Cypress catches these by default) - }); - }); - - it('navigates between habit sub-pages via nav/sidebar', () => { - cy.visit('/tools/longevity-protocol/habits/lifestyle'); - cy.contains('a', /diet/i).click(); - cy.url().should('include', '/habits/diet'); - }); - - it('does not 404 on any protocol page', () => { - PAGES.forEach(path => { - cy.request(path).its('status').should('eq', 200); - }); - }); -}); diff --git a/cypress/e2e/longevity-protocol/results.cy.ts b/cypress/e2e/longevity-protocol/results.cy.ts deleted file mode 100644 index d0c5bc73..00000000 --- a/cypress/e2e/longevity-protocol/results.cy.ts +++ /dev/null @@ -1,51 +0,0 @@ -export {}; -const PAGE = '/tools/longevity-protocol/results'; -const DEFAULT_DNA_SRC = '/keepsimple_/assets/longevity/dna/red-and-blue.mp4'; - -describe('Results – /tools/longevity-protocol/results', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. DNA canvas: default.mp4 is present, autoplaying and looping - it('renders the default DNA video with autoplay and loop', () => { - cy.get(`video[src="${DEFAULT_DNA_SRC}"]`) - .should('exist') - .should($video => { - expect($video).to.have.attr('autoplay'); - expect($video).to.have.attr('loop'); - }); - }); - - // 3. All images load without undefined or broken src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - - // 4. All internal and external links are valid (reuses checkPageLinks command) - it('has no broken internal or external links', () => { - cy.checkPageLinks(); - }); -}); diff --git a/cypress/e2e/longevity-protocol/sleep.cy.ts b/cypress/e2e/longevity-protocol/sleep.cy.ts deleted file mode 100644 index 82486c0f..00000000 --- a/cypress/e2e/longevity-protocol/sleep.cy.ts +++ /dev/null @@ -1,79 +0,0 @@ -const PAGE = '/tools/longevity-protocol/habits/sleep'; - -describe('Sleep – /tools/longevity-protocol/habits/sleep', () => { - describe('Desktop (1920x900)', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. Japanese text existence - it('has Japanese text rendered', () => { - cy.checkJapaneseText(); - }); - - // 3. All internal and external links are valid (reuses shared checkPageLinks command) - it('has no broken internal or external links', () => { - cy.checkPageLinks(); - }); - - // 4. All images load – no undefined in src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - }); - - // 5. Mobile – open chart modal and close via the modal close icon - describe('Mobile (390x844)', () => { - beforeEach(() => { - cy.viewport(390, 844); - cy.visit(PAGE); - }); - - it('opens the sleep chart modal and closes it via the close icon', () => { - // Scroll down so the button is reachable (it renders below the supplements section) - cy.get('[data-cy="open-chart-btn"]') - .scrollIntoView() - .should('be.visible'); - - cy.get('[data-cy="open-chart-btn"]').click(); - - // Modal is rendered immediately; html2canvas fills the image asynchronously - cy.get('[data-cy="sleep-chart-modal"]').should('be.visible'); - - // Wait for html2canvas to produce the data-URL – src changes from '' to data:image/png... - cy.get('[data-cy="sleep-chart-img"]', { timeout: 15000 }) - .should('have.attr', 'src') - .and('not.be.empty') - .and('not.include', 'undefined'); - - // Close via the modal's X icon - cy.get('[data-cy="modal-close-icon"]').click({ force: true }); - - cy.get('[data-cy="sleep-chart-modal"]').should('not.exist'); - }); - }); -}); - -export {}; diff --git a/cypress/e2e/longevity-protocol/study.cy.ts b/cypress/e2e/longevity-protocol/study.cy.ts deleted file mode 100644 index cf25c726..00000000 --- a/cypress/e2e/longevity-protocol/study.cy.ts +++ /dev/null @@ -1,99 +0,0 @@ -const PAGE = '/tools/longevity-protocol/habits/study'; - -describe('Study – /tools/longevity-protocol/habits/study', () => { - describe('Desktop (1920x900)', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. Japanese text existence - it('has Japanese text rendered', () => { - cy.checkJapaneseText(); - }); - - // 3. StudySection page-switcher click → FlipCard wrapper becomes visible - it('shows FlipCard after clicking the page-switcher image', () => { - cy.scrollTo(0, 800); - cy.get('[data-cy="page-switcher"]').first().click(); - - cy.get('[data-cy="flip-card-wrapper"]').first().should('be.visible'); - }); - - // 4. All internal and external links are valid (reuses shared checkPageLinks command) - it('has no broken internal or external links', () => { - cy.checkPageLinks(); - }); - - // 5. All images load – no undefined in src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - }); - - describe('Mobile (390x844)', () => { - beforeEach(() => { - cy.viewport(390, 844); - cy.visit(PAGE); - }); - - // 6. Scroll to second StudySection, expand clamped HTMLClamp via show-more button - it('expands clamped content via show-more button in second StudySection', () => { - cy.get('[data-cy="study-section"]').eq(1).scrollIntoView(); - - cy.get('[data-cy="study-section"]') - .eq(1) - .find('[data-cy="show-more-btn"]') - .should('be.visible') - .click(); - - cy.get('[data-cy="study-section"]') - .eq(1) - .find('[data-cy="html-clamp-content"]') - .should('have.attr', 'data-expanded', 'true'); - }); - - // 7. Scroll to second StudySection, open FlipCard modal via Learn more (BorderedPill), - // then close it via the Close BorderedPill inside the modal - it('opens and closes FlipCard modal via Learn more / Close buttons in second StudySection', () => { - cy.get('[data-cy="study-section"]').eq(1).scrollIntoView(); - - cy.get('[data-cy="study-section"]') - .eq(1) - .find('[data-cy="learn-more-btn"]') - .should('be.visible') - .click({ force: true }); - - cy.get('[data-cy="study-flip-card-modal"]').should('be.visible'); - cy.get('[data-cy="cookie-box-accept"]').click(); - cy.get('[data-cy="study-close-btn"]') - .should('be.visible') - .click({ force: true }); - - cy.get('[data-cy="study-flip-card-modal"]').should('not.exist'); - }); - }); -}); - -export {}; diff --git a/cypress/e2e/longevity-protocol/supplements.cy.ts b/cypress/e2e/longevity-protocol/supplements.cy.ts deleted file mode 100644 index 6188548e..00000000 --- a/cypress/e2e/longevity-protocol/supplements.cy.ts +++ /dev/null @@ -1,78 +0,0 @@ -const PAGE = '/tools/longevity-protocol/habits/supplements'; - -describe('Supplements – /tools/longevity-protocol/habits/supplements', () => { - describe('Desktop (1920x900)', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. Japanese text existence - it('has Japanese text rendered', () => { - cy.checkJapaneseText(); - }); - - // 3. All internal and external links are valid (reuses shared checkPageLinks command) - it('has no broken internal or external links', () => { - cy.checkPageLinks(); - }); - - // 4. All images load – no undefined in src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - }); - - // 5. Mobile – open chart modal and close via the modal close icon - describe('Mobile (390x844)', () => { - beforeEach(() => { - cy.viewport(390, 844); - cy.visit(PAGE); - }); - - it('opens the supplements chart modal and closes it via the close icon', () => { - cy.get('[data-cy="open-chart-btn"]') - .scrollIntoView() - .should('be.visible'); - - cy.get('[data-cy="open-chart-btn"]').click(); - - // Modal opens immediately; html2canvas fills the image asynchronously - cy.get('[data-cy="supplements-chart-modal"]').should('be.visible'); - - // Wait for html2canvas to produce the data-URL - cy.get('[data-cy="supplements-chart-img"]', { timeout: 15000 }) - .should('have.attr', 'src') - .and('not.be.empty') - .and('not.include', 'undefined'); - - // Close via the modal's X icon - cy.get('[data-cy="modal-close-icon"]').click({ force: true }); - - cy.get('[data-cy="supplements-chart-modal"]').should('not.exist'); - }); - }); -}); - -export {}; diff --git a/cypress/e2e/longevity-protocol/workout.cy.ts b/cypress/e2e/longevity-protocol/workout.cy.ts deleted file mode 100644 index e897e715..00000000 --- a/cypress/e2e/longevity-protocol/workout.cy.ts +++ /dev/null @@ -1,183 +0,0 @@ -// ACTIVITY_LEVELS (from src/constants/longevity.ts): -// index 0 → Novice : totalMinutesPerWeek=240, minutesPerSession=65 -// index 4 → Elite : totalMinutesPerWeek=90, minutesPerSession=30 -// -// STOPS (WeeklyWorkout): [0, 75, 150, 225, 300] -// BRAIN_AGE_TABLE baselines: [20, 32, 45, 55, 67, 78, 90] -// index 1 (default) → baseline=32: active=29, sedentary=37 -// index 2 → baseline=45: active=40, sedentary=53 - -const PAGE = '/tools/longevity-protocol/habits/workout'; - -describe('Workout – /tools/longevity-protocol/habits/workout', () => { - describe('Desktop (1920x900)', () => { - beforeEach(() => { - cy.viewport(1920, 900); - cy.visit(PAGE); - }); - - // 1. H1 existence - it('has a visible H1', () => { - cy.checkH1(); - }); - - // 2. Japanese text existence - it('has Japanese text rendered', () => { - cy.checkJapaneseText(); - }); - - // 3. All internal and external links are valid (reuses shared checkPageLinks command) - it('has no broken internal or external links', () => { - cy.checkPageLinks(); - }); - - // 4. All images load – no undefined in src paths - it('has no images with undefined or broken src paths', () => { - cy.get('img').each($img => { - const src = $img.attr('src'); - - expect(src, 'img src should be defined').to.not.be.undefined; - expect( - src, - `img src should not contain "undefined": ${src}`, - ).to.not.include('undefined'); - - if (src && src.startsWith('/')) { - cy.request({ url: src, failOnStatusCode: false }).then(res => { - expect(res.status, `Image returned ${res.status}: ${src}`).to.be.lt( - 400, - ); - }); - } - }); - }); - - // 5. WeeklyWorkout – ProgressBar fill & thumb exist; interacting with the bar - // changes the active image and the summary content - it('WeeklyWorkout: ProgressBar fill and thumb exist; dragging bar changes active image and summary', () => { - cy.get('[data-cy="weekly-workout"]').scrollIntoView(); - - // ProgressBar fill and thumb are rendered - cy.get('[data-cy="weekly-workout"]').within(() => { - cy.get('[data-cy="progress-bar-fill"]') - .should('exist') - .and('have.attr', 'style'); - cy.get('[data-cy="progress-bar-thumb"]').should('exist'); - }); - - // Initial state: image 0 is active - cy.get('[data-cy="weekly-workout-image"][data-active="true"]').should( - 'have.attr', - 'data-id', - '0', - ); - - // Capture initial summary risk value - cy.get('[data-cy="weekly-workout-summary"]') - .invoke('attr', 'data-risk') - .then(initialRisk => { - // Click the right end of the ProgressBar → jumps to last stop (index 4, 300 min) - cy.get('[data-cy="weekly-workout"]').within(() => { - cy.get('[data-cy="progress-bar-container"]').click('right'); - }); - - cy.wait(300); - - // Image at index 4 should now be active - cy.get('[data-cy="weekly-workout-image"][data-active="true"]').should( - 'have.attr', - 'data-id', - '4', - ); - - // Summary content (risk value) should have changed - cy.get('[data-cy="weekly-workout-summary"]') - .invoke('attr', 'data-risk') - .should('not.equal', initialRisk); - }); - }); - - // 6. BrainAgeActivity – clicking the second age button makes it active; - // passive (sedentary) and active brain-age values update accordingly - it('BrainAgeActivity: clicking second age button activates it and updates passive/active content', () => { - cy.get('[data-cy="brain-age-activity"]').scrollIntoView(); - cy.wait(300); - - // First, click the third button (baseline=45) to move away from the default (baseline=32) - cy.get('[data-cy="age-button"][data-baseline="45"]') - .scrollIntoView() - .click(); - cy.get('[data-cy="age-button"][data-baseline="45"]').should( - 'have.attr', - 'data-active', - 'true', - ); - - // Capture current result values (baseline=45 → sedentary=53, active=40) - cy.get('[data-cy="brain-age-result"]') - .invoke('attr', 'data-sedentary') - .then(sedentaryBefore => { - // Click the second button (baseline=32) - cy.get('[data-cy="age-button"][data-baseline="32"]') - .scrollIntoView() - .click(); - - // Second button (baseline=32) becomes active, third (baseline=45) becomes inactive - cy.get('[data-cy="age-button"][data-baseline="32"]').should( - 'have.attr', - 'data-active', - 'true', - ); - cy.get('[data-cy="age-button"][data-baseline="45"]').should( - 'have.attr', - 'data-active', - 'false', - ); - - // Result data attributes reflect baseline=32 (sedentary=37, active=29) - cy.get('[data-cy="brain-age-result"]') - .should('have.attr', 'data-sedentary', '37') - .and('have.attr', 'data-active-age', '29'); - - // Values are different from baseline=45 (which had sedentary=53) - cy.get('[data-cy="brain-age-result"]') - .invoke('attr', 'data-sedentary') - .should('not.equal', sedentaryBefore); - }); - }); - - // 7. StrengthAndTimeCompression – clicking the progress bar to +1 stop - // updates totalMins and minutesPerSession (quantity) - it('StrengthAndTimeCompression: advancing progress bar changes totalMins and per-session quantity', () => { - cy.get('[data-cy="strength-section"]').scrollIntoView(); - - // Initial state: Novice (index 0) → totalMinutesPerWeek=240, minutesPerSession=65 - cy.get('[data-cy="strength-total-mins"]').should('contain', '240'); - cy.get('[data-cy="strength-per-session-qty"]').should( - 'have.attr', - 'data-value', - '65', - ); - - // Click the right end of the ProgressBar → Elite (index 4) - // totalMinutesPerWeek=90, minutesPerSession=30 - cy.get('[data-cy="strength-section"]').within(() => { - cy.get('[data-cy="progress-bar-container"]').click('right'); - }); - - cy.wait(300); - - // totalMins should now show 90 - cy.get('[data-cy="strength-total-mins"]').should('contain', '90'); - - // Per-session quantity data-value should now be 30 - cy.get('[data-cy="strength-per-session-qty"]').should( - 'have.attr', - 'data-value', - '30', - ); - }); - }); -}); - -export {}; diff --git a/cypress/e2e/utils.ts b/cypress/e2e/utils.ts deleted file mode 100644 index 3510879f..00000000 --- a/cypress/e2e/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const checkPageLoad = ( - route: string, - viewport: { width: number; height: number; name: string }, -) => { - cy.viewport(viewport.width, viewport.height); - - cy.on('fail', error => { - cy.log(`Failed to load ${route}: ${error.message}`); - throw error; - }); - - cy.visit(route, { - timeout: 10000, - retryOnNetworkFailure: true, - }); - - cy.url().should('include', route); - - cy.get('section', { timeout: 10000 }).should('exist').should('be.visible'); - - cy.window().then(win => { - expect(win.document.readyState).to.eq('complete'); - }); -}; diff --git a/cypress/support/commands.d.ts b/cypress/support/commands.d.ts deleted file mode 100644 index 2fdf4fd2..00000000 --- a/cypress/support/commands.d.ts +++ /dev/null @@ -1,67 +0,0 @@ -/// -import { mount } from 'cypress/react'; - -declare global { - namespace Cypress { - interface Chainable { - /** - * Custom command to check all external links on the page. - * @param excludedDomains Array of domain strings to skip - * @param selector CSS selector for links (defaults to 'a') - */ - checkExternalLinks( - excludedDomains?: string[], - selector?: string, - ): Chainable; - - checkH1(): Chainable; - - scrollToSection(sectionText: string): Chainable; - - validateAllImages(): Chainable; - - uxcgTestSearchBehavior( - validWord: string, - invalidWord: string, - ): Chainable; - - checkSocialMediaLink(title: string, domain: string): Chainable; - - showCopiedTooltip(): Chainable; - - clickArrowWhenReady( - direction: 'next' | 'prev', - expectedUrlPart: string, - ): Chainable; - - showMoreAndLess(): Chainable; - - uxcoreSearchBehavior: ( - validWord: string, - invalidWord: string, - ) => Chainable; - - playAudio(): Chainable; - - checkPyramidChange( - bluePyramidId: string, - orangePyramidId: string, - purplePyramidId: string, - ): Chainable; - - checkSwiperSlide(prevUrl, nextUrl): Chainable; - - uxcpSearchBehavior( - validWord: string, - invalidWord: string, - ): Chainable; - - uxcpAddBiases: () => Chainable; - checkAllLinks: (routes) => Chainable; - checkPageLinks: () => Chainable; - checkJapaneseText: () => Chainable; - openLoginModalByButtonClick: (buttonLabel: string) => Chainable; - loginBySession: () => Chainable; - } - } -} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index 918cf476..00000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,326 +0,0 @@ -// 1. Check if a h1 element is visible and not empty -Cypress.Commands.add('checkH1', () => { - cy.get('h1').should('be.visible').and('not.be.empty'); -}); - -// 2. Check all external links on the page -Cypress.Commands.add('checkExternalLinks', (excludedDomains = []) => { - cy.get('a').each($link => { - const href = $link.prop('href'); - - const isExternal = - href && - href.startsWith('http') && - !href.includes('localhost') && - !excludedDomains.some(domain => href.includes(domain)); - - if (isExternal) { - // URL format validation - expect(href).to.match( - /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/[\w\- ./?%&=]*)?$/, - ); - - // Request to validate link is reachable - cy.request({ - url: href, - failOnStatusCode: false, - }).then(response => { - expect(response.status).to.be.oneOf([200, 301, 302, 403]); - }); - } - }); -}); - -// 3. Scroll to a specific section by clicking a button and checking scroll position -Cypress.Commands.add('scrollToSection', sectionText => { - cy.get('button').contains('Project Management').click(); - cy.window().then(win => { - expect(win.scrollY).to.be.greaterThan(100); - }); -}); - -// 4. Validate Images -Cypress.Commands.add('validateAllImages', () => { - cy.get('img').each($img => { - const imgSrc = $img.attr('src'); - - if (imgSrc) { - cy.request({ - url: imgSrc, - failOnStatusCode: false, - }).then(response => { - expect(response.status).to.be.oneOf([200, 301, 302]); - }); - } - }); -}); - -// 5. UXCG Search Field -Cypress.Commands.add( - 'uxcgTestSearchBehavior', - (validWord: string, invalidWord: string) => { - cy.get('[data-cy="Search Input"]').type(validWord); - cy.wait(500); - cy.get('[data-cy="open-question"]').should('be.visible'); - cy.get('[data-cy="No Results Found"]').should('not.exist'); - - cy.get('[data-cy="Search Input"]').clear().type(invalidWord); - cy.wait(500); - cy.get('[data-cy="open-question"]').should('not.exist'); - cy.get('[data-cy="No Results Found"]').should('be.visible'); - }, -); - -// 6. UXCP Search Field -Cypress.Commands.add( - 'uxcpSearchBehavior', - (validWord: string, invalidWord: string) => { - cy.get('[data-cy="input-field"]').eq(1).type(validWord); - - cy.wait(500); - - cy.get('[data-cy="uxcp-bias-action-cell"]').should('be.visible'); - cy.get('[data-cy="input-field"]').eq(1).type(invalidWord); - - cy.wait(500); - cy.get('[data-cy="uxcp-bias-action-cell"]').should('not.exist'); - }, -); - -// 7.Check Social Media Links -Cypress.Commands.add( - 'checkSocialMediaLink', - (title: string, domain: string) => { - cy.get(`a[title="${title}"]`) - .should('have.attr', 'href') - .and('include', domain) - .and('match', /^https?:\/\//) - .and('not.include', 'undefined'); - - cy.get(`a[title="${title}"]`).should('have.attr', 'target', '_blank'); - }, -); - -// 8. Shows tooltip Copy and Copied -Cypress.Commands.add('showCopiedTooltip', () => { - cy.get('[data-cy="copy-container"]').click(); - cy.get('[data-cy="copy-tooltip"]').should('contain', 'Copied!'); - cy.wait(2500); - - cy.get('[data-cy="copy-tooltip"]').should('not.have.class', 'visible'); -}); - -// 9. Clicks the arrow and checks if the URL contains the expected part -Cypress.Commands.add( - 'clickArrowWhenReady', - (direction: 'next' | 'prev', expectedUrlPart: string) => { - const selector = `[data-cy="arrow-${direction}"]`; - - cy.get(selector) - .invoke('attr', 'class') - .should('not.include', 'Disabled') - .then(() => { - cy.get(selector).click(); - }); - - if (expectedUrlPart) { - cy.url({ timeout: 10000 }).should('include', expectedUrlPart); - } - }, -); - -// 10. UX Core Search Behavior -Cypress.Commands.add( - 'uxcoreSearchBehavior', - (validWord: string, invalidWord) => { - cy.get('[data-cy="uxcore-search-input"]') - .clear() - .type(validWord, { delay: 100 }); - - cy.get('[data-cy="search-result-item"][data-state="hovered"]', { - timeout: 5000, - }).should('exist'); - }, -); - -// 11. Show more and less button (UX Core) -Cypress.Commands.add('showMoreAndLess', () => { - cy.get('[data-cy="show-more-button"]').should('be.visible').click(); - - cy.wait(500); - - cy.get('[data-cy="show-less-button"]').should('be.visible').click(); -}); - -// 12. Check Japanese text exists and is not empty -Cypress.Commands.add('checkJapaneseText', () => { - cy.get('[data-cy="japanese-text"]') - .should('exist') - .invoke('text') - .should('not.be.empty'); -}); - -// 13. Play Audio -Cypress.Commands.add('playAudio', () => { - // Initially paused: play icon visible, pause icon hidden - cy.get('[data-cy="pyramid-play-icon"]').should('be.visible'); - cy.get('[data-cy="pyramid-pause-icon"]').should('not.be.visible'); - - // Click to play - cy.get('[data-cy="audio-player"]').click({ force: true }); - - // Now playing: pause icon visible, play icon hidden - cy.get('[data-cy="pyramid-pause-icon"]').should('be.visible'); - cy.get('[data-cy="pyramid-play-icon"]').should('not.be.visible'); - - // Click to pause - cy.get('[data-cy="audio-player"]').click({ force: true }); - - // Paused again: play icon visible, pause icon hidden - cy.get('[data-cy="pyramid-play-icon"]').should('be.visible'); - cy.get('[data-cy="pyramid-pause-icon"]').should('not.be.visible'); - - // Click to play again - cy.get('[data-cy="audio-player"]').click({ force: true }); - - // Playing again: pause icon visible, play icon hidden - cy.get('[data-cy="pyramid-pause-icon"]').should('be.visible'); - cy.get('[data-cy="pyramid-play-icon"]').should('not.be.visible'); -}); - -// 14. Check all links on the current page (internal + external + no empty hrefs) -const SKIP_LINK_DOMAINS = [ - 'linkedin.com', - 'twitter.com', - 'instagram.com', - 'facebook.com', -]; - -Cypress.Commands.add('checkPageLinks', () => { - // No empty or missing hrefs - cy.get('a').each($a => { - const href = $a.attr('href'); - expect(href, `Anchor "${$a.text().trim()}" has no href`).to.not.be - .undefined; - expect( - href.trim(), - `Anchor "${$a.text().trim()}" has empty href`, - ).to.not.equal(''); - expect( - href.trim(), - `Anchor "${$a.text().trim()}" has bare # href`, - ).to.not.equal('#'); - expect( - href, - `Anchor "${$a.text().trim()}" has undefined in href`, - ).to.not.include('undefined'); - }); - - // Internal links (skip /uxcore which requires auth) - cy.get('a[href^="/"]').each($a => { - const href = $a.attr('href'); - if (!href || href.includes('/uxcore')) return; - cy.request({ url: href, failOnStatusCode: false }).then(res => { - expect(res.status, `Internal link broken: ${href}`).to.be.lt(400); - }); - }); - - // External links (skip social media that blocks bots) - cy.get('a[href^="http"]').each($a => { - const href = $a.attr('href'); - if (!href || SKIP_LINK_DOMAINS.some(domain => href.includes(domain))) - return; - cy.request({ - url: href, - failOnStatusCode: false, - timeout: 10000, - method: 'GET', - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; Cypress link checker)', - }, - }).then(res => { - expect(res.status, `External link broken: ${href}`).to.be.lt(400); - }); - }); -}); - -// 15. Check Pyramid Change -Cypress.Commands.add( - 'checkPyramidChange', - (bluePyramidId: string, orangePyramidId: string, purplePyramidId: string) => { - cy.get(`[data-id="${bluePyramidId}"]`).click(); - cy.get('[data-cy="orange-pyramid"]', { timeout: 4000 }).should( - 'be.visible', - ); - - cy.get(`[data-id="${orangePyramidId}"]`).click(); - cy.get('[data-cy="purple-pyramid"]', { timeout: 4000 }).should( - 'be.visible', - ); - - cy.get(`[data-id="${purplePyramidId}"]`).click(); - cy.get('[data-cy="blue-pyramid"]', { timeout: 4000 }).should('be.visible'); - }, -); - -// 16. Checks swiper slide -Cypress.Commands.add('checkSwiperSlide', (prevUrl: string, nextUrl: string) => { - cy.get('[data-cy="slide-move-right"]').first().click({ force: true }); - cy.wait(1000); - cy.url().should('include', nextUrl); - - cy.get('[data-cy="slide-move-left"]').eq(1).click({ force: true }); - cy.wait(1000); - cy.url().should('include', prevUrl); -}); - -// 17. UXCP Adding BIases -Cypress.Commands.add('uxcpAddBiases', () => { - cy.get('[data-cy="add-bias"]').first().click(); - cy.get('[data-cy="added-bias-item"]').first().should('be.visible'); - cy.wait(1000); - - cy.get('[data-cy="add-bias"]').eq(1).click(); - cy.get('[data-cy="added-bias-item"]').eq(1).should('be.visible'); - cy.wait(1000); - - cy.get('[data-cy="add-bias"]').eq(2).click(); - cy.get('[data-cy="added-bias-item"]').eq(2).should('be.visible'); - - cy.get('[data-cy="remove-bias"]').eq(2).click(); - cy.get('[data-cy="added-bias-item"]').eq(2).should('not.be.visible'); -}); - -// 18. Check all links on the page -Cypress.Commands.add('checkAllLinks', (routes: []) => { - routes.forEach(route => { - cy.visit(route); - - cy.get('a').each($a => { - const message = $a.text(); - const href = $a.prop('href'); - - expect($a, message) - .to.have.attr('href') - .and.not.match(/undefined|null|^$/); - - if ( - href && - href.startsWith('http') && - !href.includes('http//localhost:3005') && - !href.includes('linkedin.com') && - !href.includes('facebook.com') - ) { - cy.request({ - url: href, - failOnStatusCode: false, - }).then(response => { - cy.log( - `Checking external link: ${href} - Status: ${response.status}`, - ); - expect(response.status).to.be.oneOf([200, 301, 302, 403]); - }); - } - }); - }); -}); diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html deleted file mode 100644 index 3e16e9b0..00000000 --- a/cypress/support/component-index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Components App - -
- - -
- - \ No newline at end of file diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index b2a28f83..00000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Import custom commands -import './commands'; - -// Suppress Next.js route cancellation errors (thrown during rapid navigation) -Cypress.on('uncaught:exception', err => { - if (err.message.includes('Cancel rendering route')) { - return false; - } -}); diff --git a/eslint.config.mjs b/eslint.config.mjs index dd95727b..e4c85a6e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,7 +13,7 @@ const compat = new FlatCompat({ // eslint-disable-next-line import/no-anonymous-default-export export default [ { - ignores: ['.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + ignores: ['.next/**', 'out/**', 'build/**', 'next-env.d.ts', '.claude/keepsimple-qa/**'], }, ...compat.extends('next/core-web-vitals', 'next/typescript'), diff --git a/known-issues.md b/known-issues.md new file mode 100644 index 00000000..368a2da2 --- /dev/null +++ b/known-issues.md @@ -0,0 +1,53 @@ +# Known Issues + +This file lists known issues, intentional behaviors, and quirks that the QA agent should NOT report as findings. The agent reads this before every pass. + +## How to add an entry + +Each entry needs: + +- A short title. +- The route(s) it affects (or "global"). +- A one-sentence reason it's not a finding (intentional / wontfix / third-party / pending fix). +- Date added. + +## Format + +### [Short title] + +- **Routes:** `/path` or `global` +- **Reason:** intentional | wontfix | third-party | pending-fix +- **Note:** One sentence. +- **Added:** YYYY-MM-DD + +--- + +## Entries + +### Cookie consent banner on first visit + +- **Routes:** global +- **Reason:** intentional +- **Note:** A cookie banner appears on first visit and must be dismissed before interactions work. Agent should accept/dismiss it at the start of every pass. +- **Added:** 2026-04-28 + +### "New Update!" promo modal on first visit + +- **Routes:** global +- **Reason:** intentional +- **Note:** A promotional modal appears on first visit and blocks clicks. Agent should dismiss it at the start of every pass. +- **Added:** 2026-04-28 + +### English-only header tabs: "Awareness Test" and "Bob - AI Assistant" + +- **Routes:** global (header on all locale-prefixed pages) +- **Reason:** intentional +- **Note:** Per team decision, these two header tabs stay English in all locales (en/ru/hy). Same as "UX CORE / GUIDE / PERSONA" tool brand names. +- **Added:** 2026-04-28 + +### English-only motto: "Be Kind. Do Good." + +- **Routes:** global (under UX CORE hexagon) +- **Reason:** intentional +- **Note:** Brand motto stays English in all locales. Same convention as tool brand names. +- **Added:** 2026-04-28 diff --git a/linkinator.config.json b/linkinator.config.json new file mode 100644 index 00000000..8611b22c --- /dev/null +++ b/linkinator.config.json @@ -0,0 +1,18 @@ +{ + "recurse": true, + "concurrency": 10, + "timeout": 15000, + "retry": true, + "retryErrors": true, + "retryErrorsCount": 3, + "skip": [ + "linkedin.com", + "twitter.com", + "x.com", + "facebook.com", + "instagram.com", + "discord.com", + "discord.gg" + ], + "verbosity": "INFO" +} diff --git a/package.json b/package.json index 1b5787e3..dcf50f54 100644 --- a/package.json +++ b/package.json @@ -11,19 +11,16 @@ "start": "cross-env NODE_ENV=production APP_ENV=production yarn build && next start -p 4033", "start:staging": "cross-env NODE_ENV=production APP_ENV=staging next start -p 3005", "build:staging": "cross-env NODE_ENV=production APP_ENV=staging next build", - "test:e2e:staging": "start-server-and-test start:staging http://localhost:3005 cypress run", - "cypress:open": "cypress open", - "cypress:run": "cypress run", - "test:chrome": "cypress run --browser chrome", - "test:firefox": "cypress run --browser firefox", - "test:edge": "cypress run --browser edge", - "test:all": "npm run test:chrome && npm run test:firefox && npm run test:edge", "test:e2e": "playwright test --project=chromium", "test:e2e:ui": "playwright test --project=chromium --ui", "test:e2e:p0": "playwright test tests/p0 --project=chromium", "test:e2e:p1": "playwright test tests/p1 --project=chromium", "test:e2e:p2": "playwright test tests/p2 --project=chromium", "test:e2e:report": "playwright show-report", + "render-report": "node .claude/keepsimple-qa/render-report.js", + "check-links": "linkinator https://keepsimple.io --config linkinator.config.json --format json > reports/links-$(date +%Y-%m-%d).json && echo 'Done. Report saved to reports/links-YYYY-MM-DD.json'", + "check-links:summary": "linkinator https://keepsimple.io --config linkinator.config.json", + "render-links": "node .claude/keepsimple-qa/render-links-report.js", "prepare": "husky install" }, "dependencies": { @@ -32,9 +29,6 @@ "classnames": "2.3.1", "cookie": "0.6.0", "cross-env": "7.0.3", - "cypress": "^14.5.2", - "cypress-social-logins": "^1.14.2", - "cypress-v10-preserve-cookie": "^1.2.1", "date-fns": "2.30.0", "dotenv": "^16.4.5", "eslint-plugin-simple-import-sort": "^12.1.1", @@ -75,8 +69,6 @@ "devDependencies": { "@axe-core/playwright": "^4.11.2", "@babel/core": "7.19.3", - "@cypress/react": "^9.0.1", - "@cypress/vite-dev-server": "^6.0.3", "@playwright/test": "^1.59.1", "@types/amplitude-js": "8.16.2", "@types/classnames": "2.2.11", @@ -88,13 +80,19 @@ "@types/react-slick": "0.23.10", "@types/swiper": "6.0.0", "@types/uuid": "8.3.1", + "axe-core": "^4.11.4", "babel-loader": "8.2.5", "eslint": "^9.32.0", "eslint-config-next": "^15.4.4", "husky": "^9.1.7", + "linkinator": "^7.6.1", + "marked": "^18.0.2", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "sass-loader": "13.1.0", - "start-server-and-test": "^2.0.12", "ts-node": "^10.9.2", - "typescript": "5.2.2" + "typescript": "5.2.2", + "web-vitals": "^5.2.0", + "yaml": "^2.8.4" } } diff --git a/playwright.config.ts b/playwright.config.ts index 7c4d2869..c62e0989 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,31 +1,43 @@ -/// import { defineConfig, devices } from '@playwright/test'; +// Local: `yarn dev` on port 3005. CI: hit a deployed URL via PLAYWRIGHT_BASE_URL. const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3005'; -const isCI = !!process.env.CI; + +// CI sets PLAYWRIGHT_NO_SERVER=1 to skip the local webServer and target a deployed URL. +const skipWebServer = process.env.PLAYWRIGHT_NO_SERVER === '1'; + +// Staging sits behind HTTP Basic Auth; production and local dev don't. +const httpCredentials = + process.env.PLAYWRIGHT_HTTP_USERNAME && process.env.PLAYWRIGHT_HTTP_PASSWORD + ? { + username: process.env.PLAYWRIGHT_HTTP_USERNAME, + password: process.env.PLAYWRIGHT_HTTP_PASSWORD, + } + : undefined; export default defineConfig({ testDir: './tests', - testIgnore: ['**/fixtures/**', '**/helpers/**'], + globalSetup: './tests/global-setup.ts', fullyParallel: true, - forbidOnly: isCI, - retries: isCI ? 2 : 1, - // Cap local workers. `next dev` compiles routes on-demand; unbounded - // parallelism starves the server and the first-hit latency blows past - // navigationTimeout on pages the compiler hasn't warmed yet. 2 is - // deliberately conservative — the suite still runs under 3 min. - workers: isCI ? 1 : 2, - reporter: [['list'], ['html', { open: 'never' }]], + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + // 1 worker locally: `next dev` compile-on-first-hit contention causes + // intermittent net::ERR_ABORTED flakes when multiple workers hammer the + // same uncompiled dynamic route at once. CI hits a prebuilt deployed URL + // so parallelism is fine there; but scheduled runs use 1 worker anyway + // to keep behavior consistent. + workers: 1, + reporter: [['html', { open: 'never' }], ['list']], + use: { baseURL, - locale: 'en-US', - trace: 'on-first-retry', + httpCredentials, testIdAttribute: 'data-testid', - actionTimeout: 15_000, - // Raised from 30s — accommodates `next dev`'s compile-on-first-hit cost - // when multiple workers each land on cold routes simultaneously. - navigationTimeout: 60_000, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', }, + projects: [ { name: 'chromium', @@ -40,23 +52,17 @@ export default defineConfig({ use: { ...devices['Desktop Safari'] }, }, ], - // Server selection: - // - PLAYWRIGHT_NO_SERVER=1 → don't manage one (e.g. workflow already - // started it, or running against a deployed URL). - // - APP_ENV=staging|prod → pre-built production server (`next start`). - // The CI workflow runs `next build` first, then this block boots - // `next start` against the chosen env file. See - // .github/workflows/playwright-scheduled.yml. - // - else → local `yarn dev`. - webServer: process.env.PLAYWRIGHT_NO_SERVER + + webServer: skipWebServer ? undefined : { - command: - process.env.APP_ENV === 'staging' || process.env.APP_ENV === 'prod' - ? `cross-env NODE_ENV=production APP_ENV=${process.env.APP_ENV} next start -p 3005` - : 'yarn dev', - url: baseURL, - reuseExistingServer: !isCI, + command: 'yarn dev', + // Health-check against a known-200 route. Root (/) currently returns + // 404 in dev (see QA_PLAN Phase 1 findings), so we can't use baseURL. + // English is served at the root (no /en prefix) — use the canonical + // path. + url: `${baseURL}/uxcore`, + reuseExistingServer: true, timeout: 180_000, stdout: 'ignore', stderr: 'pipe', diff --git a/qa-config.yml b/qa-config.yml new file mode 100644 index 00000000..131b56c6 --- /dev/null +++ b/qa-config.yml @@ -0,0 +1,116 @@ +# QA agent configuration — read by .claude/skills/keepsimple-qa/SKILL.md +# The agent re-reads this every run; edit freely. + +environment: + production: https://keepsimple.io + staging: # leave empty for now, fill in when we have one + +# Locales to test. The first locale is the default. +# hy falls back to en at runtime, so test it anyway. +locales: + - en + - ru + - hy + +# How locales appear in URLs: +# - default-unprefixed: en routes are /uxcore, ru routes are /ru/uxcore +# - all-prefixed: every route has a locale segment +# - no-prefix: single-locale, no locale segments +locale_url_strategy: default-unprefixed + +# Browser viewports. +viewports: + desktop: { width: 1920, height: 1080 } + mobile: { width: 390, height: 844 } + +# Timezone for report timestamps. +report_timezone: Asia/Yerevan + +# Design-system reference. Skill reads this and flags violations as +# `design-system` findings. +design_system: uxcore-visual-design-system.md + +# Sections — groups of routes that conceptually belong together. +# The smoke profile runs against the section marked `default: true`. +# Each section may declare: +# - primary_interaction: the main thing to exercise on the hub route +# during the fixed pass. +# - auth_required: list of routes that need authentication; the +# agent records them in coverage as +# "skipped — auth required (configured)" and +# does not attempt to reach them. + +sections: + + site: + default: true + routes: + - / + - /tools + - /contributors + - /company-management + - /auth + primary_interaction: "Click the primary CTA in the hero on / and confirm navigation works." + + uxcore: + routes: + - /uxcore + - /uxcore/96-conjunction-fallacy + - /uxcore/75-barnum-effect + primary_interaction: "Open the bias detail modal on a card and verify it opens without console errors and shows the bias content." + + uxcg: + routes: + - /uxcg + - /uxcg/what-causes-users-to-make-mistakes-when-using-our-product + - /uxcg/what-to-consider-when-adding-new-functionality-into-product + - /uxcg/how-to-involve-users-into-product-testing + - /uxcg/involving-users-in-product-development + - /uxcg/what-if-our-team-members-dont-share-their-opinion + primary_interaction: "Use the stage selector on /uxcg and verify the question list updates to match the selected stage." + + uxcp: + routes: + - /uxcp + primary_interaction: "Apply the priority filter and verify the visible cards change accordingly." + + uxcat: + routes: + - /uxcat + - /uxcat/ongoing + - /uxcat/start-test + - /uxcat/test-result + auth_required: + - /uxcat/ongoing + - /uxcat/start-test + - /uxcat/test-result + primary_interaction: "Click the primary CTA on the hub /uxcat and verify it routes correctly. Do NOT submit the test (creates real data)." + + uxcore-api: + routes: + - /uxcore-api + # primary_interaction omitted — agent clicks the most prominent element + + longevity-protocol: + # No hub route exists; first route below is treated as the hub. + routes: + - /tools/longevity-protocol/about-project + - /tools/longevity-protocol/environment + - /tools/longevity-protocol/habits/diet + - /tools/longevity-protocol/habits/lifestyle + - /tools/longevity-protocol/habits/sleep + - /tools/longevity-protocol/habits/study + - /tools/longevity-protocol/habits/supplements + - /tools/longevity-protocol/habits/workout + - /tools/longevity-protocol/results + primary_interaction: "Click a habit link in the side nav from /tools/longevity-protocol/about-project and verify the destination habit page loads without console errors." + + articles: + routes: + - /articles + - /articles/create-behavioral-user-personas + - /articles/table-of-contents + - /articles/what-is-a-project + - /articles/awareness-test + - /articles/summarize-like-your-job-depends-on-it + primary_interaction: "From /articles, click the first article in the listing and verify it navigates to a working article page that renders without errors." diff --git a/qa-runs/README.md b/qa-runs/README.md new file mode 100644 index 00000000..9c611ca4 --- /dev/null +++ b/qa-runs/README.md @@ -0,0 +1,108 @@ +# QA Agent + +Manual QA pass for keepsimple.io, driven by Claude via the Playwright +MCP server. Visits production routes, watches console + network, +interacts with key features, produces a findings report. + +This is the **agent QA** system. The Playwright spec suite (in +`tests/`, see `QA_PLAN.md`) is a separate effort. + +## How to run + +In a fresh Claude Code session at the repo root, type a slash command: + +| Command | What it runs | Time | +| --------------------- | --------------------------------------------------------------------------------------- | ------------- | +| `/qa-init` | Interactive wizard to (re-)write `qa-config.yml`. | ~5 min | +| `/qa-smoke` | Default section × en × desktop. Fast post-deploy check. | ~15 min | +| `/qa-locale-smoke` | Default section × all locales × desktop. Locale-routing check. | ~25 min | +| `/qa-canonical` | All sections × all locales × desktop. Source of truth for desktop findings. | ~90-120 min | +| `/qa-mobile-followup` | All sections × all locales × mobile. Pairs with canonical for full matrix. | ~90 min | +| `/qa-full-matrix` | Everything in one pass: all sections × all locales × desktop + mobile. | 3-4 hours | +| `/qa-deploy-check` | Surgical post-deploy regression. Only audits routes that changed OR have open findings. | ~30 min | +| `/qa-retest` | Verify specific finding IDs after a deploy or local fix. | ~5 min per ID | + +Always start in a **fresh** Claude Code session. Same chat ≠ same +session. Open chat memory pollutes results. + +After the run finishes, the agent writes the report to `reports/` and +returns a short summary in chat. The agent renders the HTML itself +(`node .claude/keepsimple-qa/render-report.js …`) at the end of every pass — or +you can re-render manually with `yarn render-report reports/.md`. + +## What the system is made of + +| File | Role | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `.claude/keepsimple-qa/SKILL.md` | The method playbook. Pre-flight, fixed pass, exploratory pass, severity rubric, finding schema. Loaded by every slash command. | +| `.claude/keepsimple-qa/PROFILES.md` | Profile catalogue — what each profile's scope actually covers. | +| `.claude/keepsimple-qa/helper.mjs` | Helper CLI: fingerprints, axe, vitals, screenshots, pixelmatch visual-diff. | +| `.claude/keepsimple-qa/render-report.js` | Markdown → HTML renderer. | +| `.claude/commands/qa-*.md` | Slash commands, one per profile. Each loads the skill and pins scope. | +| `qa-config.yml` | Sections, routes, locales, viewports, design-system reference, per-section `auth_required:` and `primary_interaction:` declarations. | +| `known-issues.md` | Suppressions. Anything listed here is intentional and not a finding. | +| `qa-runs/findings-register.md` | Living index of open findings. Updated manually after each run. | +| `qa-runs/state/` | Helper-managed: route fingerprints from `batch-fingerprint`. Gitignored. | +| `qa-runs/baselines/` and `screenshots/` | Helper-managed: visual-regression baseline + current PNGs. Gitignored. | +| `reports/` | Per-run reports (`.md` source + `.html` rendered). Source of truth for finding details. | + +The skill defines **how**. The profile (and slash command) defines +**what scope**. The register tracks **what's currently broken**. + +## After a run + +The agent does NOT auto-update the findings register. You do. + +For each new finding the report surfaces: + +1. Decide if it's real (cross-check against `known-issues.md` if + you're unsure). +2. Add a row to `qa-runs/findings-register.md` under "Open + findings" with a new `F#` ID. +3. Reference the report it came from in `last-confirmed`. + +For findings the report shows as persistent: update `last-confirmed` +to today's date. Status stays the same. + +For findings the report shows as refuted (after a fix and retest): +flip status `fixed-deployed` → `verified-fixed`. After one more +clean run with no regression, move the row to the Archive section. + +## Fix flow + +When a finding gets fixed: +open → fixed-locally → fixed-deployed → verified-fixed → archive +↑ | +| ↓ +(you write the fix) (one more clean run later) + +Update register status manually as it moves through the stages. +Run `/qa-retest` after a deploy to verify `fixed-deployed` → +`verified-fixed`. Or run `/qa-deploy-check` for a broader surgical +sweep that re-audits anything that changed plus all open findings. + +## Constraints worth knowing + +- The agent never logs in or submits forms that create real data. +- Routes listed under a section's `auth_required:` field in + `qa-config.yml` are intentionally skipped. The agent records them + as `skipped — auth required (configured)` in the coverage table. +- The agent will not refile anything in `known-issues.md`. If + something there should actually be fixed, remove it from + known-issues first. +- Reports stick around indefinitely. They're the diff chain — each + run references prior runs by name. Don't delete old ones. + +## When something's off + +- Slash command shows "no matching commands" → restart Claude Code + panel. New files don't always get picked up live. +- Agent runs but produces no report → check Playwright MCP is + connected. Should show in available tools. +- Agent reports findings already in `known-issues.md` → known-issues + isn't being read. Check the file path is correct from repo root. +- Report references a build ID that doesn't match current production + → you're looking at a pre-deploy report. Check `reports/` for the + most recent one. +- Helper command fails with `Cannot find module 'pngjs'` (or similar) + → run `yarn install`; the kit's devDeps may not be installed yet. diff --git a/report.md b/report.md index 9775b5e6..32e588c2 100644 --- a/report.md +++ b/report.md @@ -1,3 +1,8 @@ +> **TODO (2026-05-01):** Cypress was removed and replaced by Playwright + the QA-agent setup +> (`tests/`, `.claude/keepsimple-qa/`). This report still references Cypress as the test +> framework (see §1 stack table and §10). Review and refresh against the current state of +> the repo before relying on it. + ● KeepSimpleOSS Codebase Audit Report 1. Stack & Tooling diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 00000000..e869cb1e --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,6 @@ +// Playwright global setup. The config references this file; intentionally a +// no-op until there is shared setup work to do (auth state, seeded fixtures, +// etc.). +export default async function globalSetup(): Promise { + return; +} diff --git a/tests/p1/mobile-navigation.spec.ts b/tests/p1/mobile-navigation.spec.ts index 3fea73e7..7bd1254e 100644 --- a/tests/p1/mobile-navigation.spec.ts +++ b/tests/p1/mobile-navigation.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '../fixtures/base'; -// 390x844 = existing Cypress convention (see QA_RECON.md §5). +// 390x844 = standard mobile viewport (see QA_RECON.md §5). test.use({ viewport: { width: 390, height: 844 } }); // --------------------------------------------------------------------------- diff --git a/tsconfig.json b/tsconfig.json index 623b1068..c67bbbf8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "types": ["cypress", "node", "./cypress/support/commands"], + "types": ["node"], "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, @@ -34,13 +34,6 @@ }, "incremental": true }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - "cypress", - "cypress/support/commands.d.ts", - "cypress/**/*.ts" - ], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules", "tests", "playwright.config.ts"] } diff --git a/uxcore-visual-design-system.md b/uxcore-visual-design-system.md new file mode 100644 index 00000000..c1383674 --- /dev/null +++ b/uxcore-visual-design-system.md @@ -0,0 +1,725 @@ +--- +name: UXCore Design System +description: Build UI components and pages that match the UXCore visual identity — covers typography, colors, spacing, components, motion, and do/don't rules for UXCore, UXCG, UXCP, and UXCat. +--- + +# Workflow + +When triggered, follow these steps: + +1. **Read this file fully** before generating any code. Internalize the design tokens, component specs, and do/don't rules. +2. **Identify which visual scope applies** — is this for the shared platform style (UXCore/UXCG/UXCP) or for UXCat? This determines accent colors, heading weights, and animation behavior (see Section 10b). +3. **Build the component or page** using the exact values from this spec — font sizes, colors, spacing, border-radius, shadows, and easing curves. Do not approximate or substitute. +4. **Check against Section 11 (Do / Don't Rules)** before finalizing. These rules override any instinct to add gradients, pill buttons, glassmorphism, or decorative elements. +5. **Reference `AGENTS.md`** at the repo root for code-structure rules (component pattern, file layout, prop typing, etc.) whenever you write or modify actual code files. + +# UXCore Visual Design System + +> A standalone design specification for building interfaces that match the UXCore visual identity (keepsimple.io). No codebase access needed — every value is resolved and every component described well enough to implement from scratch in any tech stack. +> +> **Two visual scopes:** +> +> - **Sections 1–10** describe the **shared platform style** used across UXCore (bias explorer), UXCG (UX Case Generator), and UXCP (UX Companion Personas). These three tools share the same typography, colors, layout, and component styles. +> - **Section 10b** describes **UXCat-specific styling** — the quiz/gamification tool that has its own distinct visual personality built on top of the shared foundation. +> +> Section 11 (Do / Don't Rules) and Tone left as `____` for manual completion. + +--- + +## Design Philosophy + +The platform has **two visual modes** that share a common foundation but serve different moods. + +**The core platform (UXCore, UXCG, UXCP)** is quiet, structured, and informational. It presents dense educational content — cognitive biases, UX questions, persona-driven analysis — without competing with that content for attention. The palette is desaturated and cool, anchored by a muted steel-blue and generous white space. Typography is deliberately lightweight for headings (thin Red Hat Display) and utilitarian for body text (Lato), letting the content hierarchy do the work rather than visual flair. Interactions are subtle: cards lift a few pixels on hover, borders shift color to confirm focus, and the navigation pill slides between tabs with a smooth, physical-feeling ease. The overall impression should be a well-organized reference tool — closer to a thoughtfully designed textbook than a marketing landing page. + +**UXCat (the quiz tool)** inherits the same layout grid, font stack, and color tokens, but layers on a warmer, more energetic personality. Orange replaces steel-blue as the accent for headings and CTAs. Content appears through staggered fade-in sequences. A progress-and-reward system adds gamification elements — achievement badges with gold glow effects, animated leaderboards, and a branded brain-spinner loading animation. UXCat feels like taking a well-designed exam in a serious but encouraging environment. + +--- + +## 1. Typography + +### Fonts + +- **Primary font (body text):** Lato (fallback: Arial, serif). A clean, humanist sans-serif used for all body copy, buttons, labels, and UI chrome across every tool. +- **Heading font (page and section titles):** Red Hat Display (fallback: sans-serif). Used at light weight for a refined, modern feel on page titles — shared by UXCore, UXCG, UXCP, and UXCat. +- **Monospace font (bias number labels, meta tags):** IBM Plex Mono (fallback: sans-serif). Used for small uppercase category labels on bias detail views — gives a technical, cataloging feel. +- **Font loading:** Self-hosted via `@font-face` with `font-display: swap` for Lato and `font-display: optional` for secondary fonts. Not loaded from Google Fonts CDN. +- **Special-purpose fonts:** + - Noto Sans Armenian (sans-serif fallback) — full locale override for Armenian language, applied globally when locale is `hy` + - Source Serif 4 (Regular/Bold/SemiBold) — article and long-form content layouts + - Cormorant Garamond (Regular/Medium) — article layouts, Russian locale variant + - Aboreto — article titles (decorative) + - See [Section 10b (UXCat)](#10b-uxcat-specific-styles) for additional fonts used only in the quiz/gamification context. + +### Type Scale + +| Role | Size | Weight | Line-height | Notes | +| ----------------------- | ------- | ----------- | ----------- | ------------------------------------------------------------------------------------------------------- | +| Page title (h1) | 36px | 300 (light) | default | Intentionally thin — lets the content breathe. Drops to 24px / weight 600 on mobile (≤800px). | +| Section heading (h2) | 24px | 600–800 | ~28.8px | Used for quiz questions and result titles. Weight 800 for maximum-emphasis question text. | +| Subsection heading (h3) | 18–20px | 400–700 | ~25.6px | 20px for mottos and API section headers; 18px for quiz sub-questions (weight 700). | +| Body text | 16px | 400 | 150% (24px) | Standard body copy. Some contexts use 140% line-height. Drops to 14px at ≤1440px on data-dense screens. | +| Small / caption text | 12px | 400–600 | default | Used for axis labels, search result hints, and small meta labels. Weight 600 for emphasized labels. | +| Label / tag text | 14px | 400 | ~17px | Buttons, filter controls, nav items, tooltip content, sidebar descriptors. | +| Navigation links | 14px | 400 | default | Always uppercase. | + +### Text Rules + +- **Letter spacing on headings:** -0.01em on bias detail modal titles (a very subtle tightening). +- **Uppercase (`text-transform: uppercase`)** is used on: navigation items, bias number labels in detail modals, section headers, view switcher controls, panel headers, and completion bar labels. It signals "system chrome" vs. content. +- **Max line width for readability:** Content containers are capped at 900px. This keeps body text to a comfortable ~75-character line length at 16px. + +--- + +## 2. Colors + +### Brand / Primary + +- **Primary brand color:** `#28587b` — a muted steel-blue. The dominant accent across UXCore, UXCG, and UXCP. Used for primary buttons, link text, page headings, active filter states, and interactive affordances. +- **Primary hover state:** `#28587b` (no lightening — primary buttons don't shift color on hover; the interaction feedback comes from border changes on outline variants). +- **Primary active/pressed:** `#28587b` (used as background on active filter buttons with white text). + +### Secondary Accent Colors + +- **Secondary blue:** `#5b88bd` — a lighter, softer blue. Used for active-state borders (selected answers, active product tabs), interactive link text in modals, active toggle controls, checkboxes, and radio-style selections. This is the "something is selected/active" color — shared across all tools. +- **Orange accent:** `#de915b` — warm terracotta orange. Used for call-to-action buttons (start actions, submit). In UXCat, this is the dominant accent for titles and primary CTAs (see Section 10b). +- **Orange hover:** `#cd7232` — deeper, toasted orange. Orange buttons darken on hover rather than lighten. +- **Purple accent:** `#a36aa4` — muted plum. Used sparingly for secondary feature tooltips, tab decorations, date stamps, and highlight actions. + +### Backgrounds + +- **Page background (global):** `linear-gradient(180deg, #f9fafb 0%, #e8e8e8 100%)` with fallback `#f9fafb` — a very subtle warm-to-cool gray gradient from top to bottom. The page never feels stark white. +- **Page background (tool pages):** `#fafafa` — a flat, near-white gray used as the canvas for all main tool interfaces across UXCore, UXCG, UXCP, and UXCat. +- **Card / content block background:** `#fff` — pure white. Cards sit on the `#fafafa` canvas, creating a very gentle lift even before shadows. +- **Card hover background:** No background change on hover — hover feedback is handled through shadow deepening and subtle vertical movement. +- **Modal / overlay backdrop:** `rgba(0, 0, 0, 0.35)` — a light dim, not a heavy blackout. Content behind the modal remains partially visible. +- **Spinner / loading overlay:** `rgba(0, 0, 0, 0.6)` — darker than modal backdrops to fully focus attention on the loading indicator. +- **Header background:** `#fff` (white, with a subtle bottom shadow to separate from content). +- **Hover highlight (UI chrome):** `#f1f1f1` — used for nav item hover states, dropdown item highlights, and user menu hover. A barely-there gray tint. + +### Text + +- **Primary text:** `#252626` — near-black with just enough warmth to avoid harshness. Some components also use pure `#000`. +- **Secondary / muted text:** `#9e9e9e` — medium gray for subtitles, mottos, short descriptions, and secondary labels. Also `rgba(0, 0, 0, 0.65)` (a 65% black) for slightly more prominent secondary text on sidebar labels. +- **Disabled text:** `#c4c4c4` — light gray signaling non-interactive or inactive elements. +- **Link color (default):** `#28587b` (primary blue, same as brand); some secondary links use `#5b88bd` (lighter blue). +- **Link color (hover):** An underline appears on hover (no color change). Underline-on-hover is the standard link interaction pattern. +- **Link color (visited):** Not styled — visited links look identical to unvisited links. + +### Borders & Dividers + +- **Default border:** `1px solid #d9d9d9` — a light, neutral gray. Used on buttons, separators, section dividers, and card outlines. +- **Divider / separator:** `#d9d9d9` is the primary separator color; `#c4c4c4` (slightly darker gray) appears on secondary dividers like header section separators. +- **Input border:** `1px solid #cbcbcb` — marginally darker than the general border color, giving form fields a bit more definition. +- **Input border on focus:** `#6d6d6d` — a medium-dark gray. Notably, focus does _not_ use the brand blue — it uses a neutral dark gray, keeping the field feeling calm rather than highlighted. + +### Status / Feedback + +- **Error / danger:** `red` (plain CSS red, used for form validation messages). +- **Success:** `#4caf50` (Material-style green for checkmarks); `#2db675` (progress bar fill); `#77a34b` / `#42c256` (glowing tooltip accents). +- **Warning:** Not used in the current design system. +- **Info:** Not used in the current design system. + +### UXCG Stage Colors + +Each UX Case Generator stage has a distinct color identity, used for icon strokes and category swatches: + +| Stage | Default stroke | Selected stroke | Swatch background | +| ------------- | ------------------------ | ----------------------- | ---------------------- | +| Team Assembly | `#6087ab` (steel blue) | `#6087ab` | `#bbe4f2` (light sky) | +| Development | `#186930` (forest green) | `#669e77` (sage) | `#c5eadd` (mint) | +| Marketing/BD | `#9e4579` (berry) | `#e5ccee` (lavender) | `#dbcad1` (dusty rose) | +| Released | `#c3a70c` (dark gold) | `#fff2ae` (pale yellow) | — | +| Monitoring | `#ff7300` (vivid orange) | `#ffd9b9` (peach) | — | + +### Bias Category Colors + +The four cognitive bias categories each have a background tint and border/accent color. Used across UXCore (bias explorer), UXCG (case connections), and UXCP (persona analysis): + +| Category | Background | Border/accent | Label text | +| ------------------------ | ----------------------- | ------------------------ | ------------------------ | +| Too much information | `#c7ebf8` (ice blue) | `#4c8cc1` (ocean blue) | `#4c8cc1` | +| Not enough meaning | `#c5eadd` (soft mint) | `#75b3b3` (teal) | `#75b3b3` | +| Need to act fast | `#e9dfe3` (blush gray) | `#80739b` (dusty purple) | `#573e48` (wine) | +| What should we remember? | `#e9e6ff` (pale violet) | `#8073ff` (indigo) | `#5c578d` (slate violet) | + +Note: These are intentionally pastel and desaturated — they color-code without screaming. + +### UXCP Priority Colors + +Priority levels (high/medium/low) are distinguished by icon shape, not by color. The active filter button uses the primary brand color (`#28587b` background, white text) regardless of which priority level is selected. + +--- + +## 3. Spacing + +### Base Unit + +No formal spacing token system. Values cluster around **multiples of 4px and 8px**, which is the effective base grid. This applies uniformly across UXCore, UXCG, UXCP, and UXCat. + +### Common Values + +| Token | Value | Typical use | +| ----- | ----- | ------------------------------------------------------------------- | +| XS | 4px | Tight gaps between inline elements, small margins | +| SM | 8px | Standard gaps in flex containers, icon spacing, small padding | +| MD | 16px | Mobile page padding, vertical section spacing, common inner padding | +| LG | 24px | Section vertical gaps, content area spacing | +| XL | 32px | Major section breaks, button group top margins | +| 2XL | 48px | Large section bottom margins, content-to-loader spacing | + +Intermediate values also appear: 10px (input inner padding), 14px (filter bar padding), 20px (modal body padding), 28px (modal side padding). + +### Page Layout + +- **Max content width:** 900px for all standard content pages across every tool (question lists, persona builders, quiz views, API docs, user profiles). The bias explorer (folder/tree view) in UXCore uses a wider 1360px max-width. +- **Page horizontal padding (desktop):** Content is centered via auto margins with `max-width: 100%`, so there is no fixed side padding — the 900px cap and centering handle it. +- **Page horizontal padding (mobile):** 16px on each side (≤800px). Some screens use 8px side margins or 20px padding at narrow widths. +- **Content top margin from header:** 94px on desktop (accounts for the 46px fixed header plus breathing room). Reduces to 28–35px on mobile where the header is no longer fixed. + +--- + +## 4. Breakpoints + +| Name | Value | Role | +| ------- | ----------------- | ---------------------------------------------------------------------- | +| Mobile | ≤800px | Primary mobile layout switch | +| Tablet | ≤1010px | Intermediate adjustments; desktop navigation collapses | +| Desktop | ≤1440px | Font-size scaling (16px → 14px on dense screens) | +| Wide | ≥2500px / ≥3500px | Large-display scaling (headings and body text grow via viewport units) | + +### What Changes at Each Breakpoint + +- **At mobile (≤800px):** Desktop horizontal navigation disappears, replaced by a tool-name dropdown (PageSwitcher). Content gets 16px side padding. Page titles shrink from 36px light to 24px semibold — the weight increase compensates for the smaller size to maintain hierarchy. Subtitles drop from 18px to 16px. Content stacks vertically. Search inputs lose their border-radius (become flat-edged to span full width). +- **At tablet (≤1010px):** Top content margin shrinks from 94px to ~28–35px. Multi-column layouts (like the bias folder view) collapse to single column. Navigation header simplifies. +- **At desktop (≤1440px):** A density pass — body text, labels, and controls scale from 16px to 14px in data-heavy components (inputs, filter bars, test results, API docs). This fits more content without scrolling on standard laptops. +- **At wide (≥2500px / ≥3500px):** The bias explorer and its labels scale up using viewport-relative units (e.g., headings grow to ~4vh, descriptions to ~1.5vh) so the interface doesn't feel tiny on ultrawide or 4K displays. + +--- + +## 5. Component Styles + +### Cards + +Cards are the primary content containers across all tools — used for bias detail boxes, UXCG case question wrappers, UXCP persona result sections, and general info panels. + +**At rest:** + +- Background: `#fff` (white, sitting on the `#fafafa` page canvas). +- Border radius: 8px for standalone content cards; 4px for inline interactive cards and smaller elements. +- Border: most cards rely on shadow alone (no visible border); some section cards use `1px solid #d9d9d9`. +- Shadow (resting): `0px 2px 8px rgba(0, 0, 0, 0.05)` — barely visible, just enough to lift the card off the background. +- Shadow (elevated/prominent): A multi-layered progressive shadow for high-importance cards: `0px 8px 17px rgba(0, 0, 0, 0.05), 0px 30px 30px rgba(0, 0, 0, 0.04), 0px 68px 41px rgba(0, 0, 0, 0.03), 0px 122px 49px rgba(0, 0, 0, 0.01), 0px 190px 53px rgba(0, 0, 0, 0)`. This creates a realistic, diffused paper-floating effect. +- Padding: 15px vertical, 20px horizontal (standard); 10px top/bottom, 16px left / 32px right (answer cards with action space). + +**On hover (interactive cards):** + +- Cards lift slightly — 8px upward shift with a smooth spring-like ease (`transform: translateY(-8px)`, `transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1)`). The cubic-bezier gives a slight overshoot that feels physical. +- No background color change on hover — the movement and shadow shift are the only feedback. + +**Selected state:** + +- Border becomes `2px solid #5b88bd` (secondary blue). +- A very faint blue wash appears: `rgba(147, 183, 255, 0.2)` background. + +**Grid spacing:** + +- Gaps between cards: 13px (category tag grids in UXCG); 3px (dense bias lists in UXCore explorer view). + +### Buttons + +Buttons come in several variants, all compact and understated — they don't shout. Shared across every tool. + +**Default (outline) button:** + +- Background: `#fff`. Text: `#000`. Border: `1px solid #d9d9d9`. Border-radius: 2px (very slightly rounded — nearly square). Padding: 6px 16px. Height: 32px. Font-size: 14px (inherited from label text scale). +- On hover: border shifts to `#28587b` and text shifts to `#28587b` — the button "activates" with the brand color. Transition: 200ms all properties. +- When disabled: `cursor: not-allowed`, no hover effect. + +**Primary (filled) button:** + +- Background: `#28587b` (brand blue). Text: `#fff`. Border: `1px solid #d9d9d9`. Border-radius: 2px. Same sizing as default. +- On hover: no visible change (already in its active color). The primary button is always "on." + +**Secondary (outline, softer) button:** + +- Background: `#fff`. Text: `rgba(0, 0, 0, 0.65)` (muted). Border: `1px solid #d9d9d9`. Border-radius: 4px (slightly more rounded than default). Padding: 8px 18px. + +**Orange (CTA) button:** + +- Background: `#de915b` (terracotta). Text: `#fff`. On hover: darkens to `#cd7232`. A warm, attention-drawing variant for start/submit actions. + +**Orange outline button:** + +- Background: `#fff`. Text/border: `#de915b`. On hover: fills to `#cd7232` background with white text — a smooth inversion. + +**Blue outline button:** + +- Background: `#fff`. Text/border: `#28587b`. On hover: fills to `#28587b` background with white text. + +**Big button variant:** + +- Height: 54px (45px on mobile). Used for major actions. + +**Disabled state:** + +- Background: `#d9d9d9`. Text: `#c4c4c4`. `cursor: not-allowed`. No hover effect. + +**Loading state:** + +- An inline spinner appears inside the button (CSS rotation: 360deg, 500ms, infinite, linear). + +**Transition:** All button properties animate at 200ms ease. + +### Input Fields + +Clean, minimal form inputs used across all tools. + +**At rest:** + +- Height: 32px minimum. Background: `#fff`. Border: `1px solid #cbcbcb`. Border-radius: 4px. Padding: 6px 10px. Font-size: 16px. +- Placeholder text: `#ababab` (light gray, clearly secondary). + +**On focus:** + +- Border darkens to `#6d6d6d` (a calm, neutral dark gray — not the brand blue). Outline removed. Any associated icon also shifts to `#6d6d6d`. +- This neutral focus state is deliberate: it confirms the field is active without creating a "selected" feeling that might conflict with validation states. + +**With icon:** + +- Right padding increases to 34px to make room for an inline icon (search, clear, etc.). + +**Error state:** + +- Error message appears below the field in `red`, 12px font, with a 200ms slide-in transition. + +**Responsive:** + +- Font-size drops to 14px at ≤1440px on dense screens. + +### Tags / Badges / Chips + +Small, pill-like labels used across tools — categorizing biases in UXCore, marking UXCG stages, and labeling UXCP relevance. + +- Border-radius: 2px (very subtly rounded — almost rectangular, not pill-shaped). +- Padding: 3px 17px (generous horizontal padding for readability). +- Font-size: 12px (default) or 14px (large variant, 32px height). +- Text color: `#fff` (always white — the background color carries the category meaning). +- Background: set per-category from the Bias Category Colors or UXCG Stage Colors tables (section 2). No default background — always determined by semantic context. +- Border: none. + +### Toggles / Switches + +A two-button segmented control (not a sliding iOS-style toggle). Used for binary choices like Yes/No in the UXCP persona builder. + +- Two buttons sit side by side, sharing a border. Left button has `border-radius: 4px 0 0 4px`, right has `0 4px 4px 0`. +- Each button: width 60px, font-size 14px. +- **Inactive:** white background, `#000` text, `1px solid #c4c4c4` border. +- **Active:** white background, `#5b88bd` text (secondary blue), border color shifts to `#5b88bd`. +- **Hover (inactive):** a whisper of blue tint appears: `rgba(53, 158, 255, 0.12)` background. + +### Navigation + +A minimal, horizontally tabbed header bar spanning the full viewport width. Shared across all four tools — the nav items are UXCore, UXCG, UXCP, and UXCat. + +**Desktop (>1010px):** + +- Height: 46px. Background: `#fff`. Box-shadow: `0px 2px 8px rgba(0, 0, 0, 0.05)` (the subtle "SM" shadow). +- Tabs are uppercase text, 14px, color `rgba(0, 0, 0, 0.85)`. +- **Active tab indicator:** A dark pill (`rgba(0, 0, 0, 0.85)` background, 4px border-radius, 33px height) that slides horizontally behind the active tab. Text turns to `#fafafa` (near-white) when active. The pill slides with `transition: transform 450ms cubic-bezier(0.22, 0.95, 0.35, 1)` — a fast-start, soft-landing ease that feels physical and satisfying. +- **Hover (inactive tabs):** `#f1f1f1` background tint with 4px border-radius. Subtle enough to not distract from the active indicator. + +**Mobile (≤1010px):** + +- The horizontal tab bar disappears entirely. It's replaced by a PageSwitcher — a single dropdown-style button showing the current tool name, 40px tall, `1px solid #c4c4c4` border, 4px border-radius, with 16px page padding. + +### Modals / Dialogs + +Clean, centered overlays used across all tools — for bias detail views (UXCore), case detail views (UXCG), persona confirmations (UXCP), and various settings. + +- **Backdrop:** `rgba(0, 0, 0, 0.35)` — a light dim that keeps the page context partially visible. +- **Modal panel:** `#fff` background. Border-radius: 4px. Border: `1px solid #cbcbcb`. Box-shadow: `0px 4px 16px rgba(0, 0, 0, 0.15)`. +- **Title:** 16px, color `#28587b` (brand blue). Alternate title styles: `#000` (strong), `#9e9e9e` (gray/subtle). +- **Body padding:** 16px top/bottom, 28px sides (generous horizontal space for reading). +- **Header area:** 13px top padding, 8px bottom padding, with a bottom border when present. +- **Close button:** Positioned 15px from the right edge. +- **Entry animation:** Modals appear immediately (no slide-in or scale animation). Some secondary modals use a slow 1s transition. +- **Z-index stacking:** Backdrop at z-45, modal content at z-80 (well above any dropdown or tooltip). + +### Tooltips + +Small, informational popovers that appear on hover for help icons and contextual hints. Shared across all tools. + +**Light variant (default):** + +- Background: `#fff`. Text: `#000`. Border: `1px solid #cbcbcb`. Border-radius: 4px. Box-shadow: `0px 4px 16px rgba(0, 0, 0, 0.15)`. Padding: 10px. Font-size: 14px. Max-width: 380px (preferred width 300px). +- Has a small CSS arrow (8px triangle) pointing toward the trigger element. + +**Dark variant:** + +- Background: `#000`. Text: `#fff`. Border-radius: 4px. Padding: 2px 6px. Font-size: 12px. Compact and label-like — used for quick, single-line hints. + +--- + +## 6. Icons & Images + +### Icon System + +- **No shared Icon wrapper component.** Icons are either inline SVG (React components) or static `.svg` files referenced by URL. This pattern is the same across all tools. +- **Common sizes:** 14–25px for UI icons. Typical: 20×20 (search), 25×25 (header nav), 16–18px (buttons and small actions), 13×17 (info/question marks). +- **Icon color:** Usually inherits text color via `fill: currentColor`, or is set explicitly (e.g., `#28587b` for brand-colored icons, `#5b88bd` for interactive states, `#fafafa` for icons on dark backgrounds). +- **Style:** Flat, outlined SVGs — not filled/solid. UXCG stage icons use stroke-based drawing on circles and paths. + +### Images + +- **Optimization:** Next.js `Image` component is the standard; however, many images set `unoptimized: true` for static SVGs and small assets. Remote image domains are whitelisted for user avatars (Google, Discord). +- **Illustration style:** SVG-based, flat/outlined. No photographic hero images. Background textures for UXCG category tags use small PNG cards (170×250px) with a neutral gray placeholder for unselected state. +- **Avatar shape:** Circular (`border-radius: 50%`). + +### Language Flags + +- Three flags for the language switcher: English (UK flag), Armenian, Russian — all SVGs. +- Main switcher size: 22×15px. Dropdown size: 16×13px. + +--- + +## 7. Motion & Transitions + +### Timing + +| Category | Duration | Use | +| ------------------------------ | ---------- | --------------------------------------------------------------------------- | +| Micro (hover, focus) | 150–250ms | Button color/border shifts, label opacity fades, input focus border changes | +| Component (expand, tab switch) | 300–320ms | Tooltip show/hide, search expand, card hover lift, dropdown open | +| Navigation (pill slide) | 450ms | Active tab indicator sliding between nav items | +| Page / section | 500–1000ms | Staggered content fade-ins (UXCat), slide-in panels, layout transitions | + +### Easing + +- **Default easing:** `ease-in-out` — used for most fade and slide animations across all tools. +- **Card hover lift:** `cubic-bezier(0.22, 1, 0.36, 1)` — a fast-start, overshoot-then-settle curve that feels springy and physical. +- **Nav pill slide:** `cubic-bezier(0.22, 0.95, 0.35, 1)` — similar springy feel but slightly more damped for a heavier, satisfying slide. +- **Loading spinner:** `cubic-bezier(0.5, 0, 0.5, 1)` — symmetrical ease for continuous rotation. +- **Dropdown open:** `cubic-bezier(0.4, 0, 0.2, 1)` — Material-style standard easing. + +### Hover Effects + +- **Cards:** Lift upward by 8px with a springy ease (320ms). Shadow doesn't change explicitly — the movement itself creates the perception of increased depth. +- **Buttons:** Border and text color shift to brand blue (200ms). Orange buttons darken. Blue/orange outline buttons invert to filled on hover. +- **Links:** An underline appears (no color change, no fade — it's a binary appear/disappear). +- **Nav items:** A `#f1f1f1` background tint fades in with 4px border-radius. Associated icons go from 85% opacity to 100%. +- **Dropdown items / user menu:** `#f1f1f1` background highlight; user name text shifts to `#5b88bd`. + +### Loading States + +- **Primary loader (brain spinner):** A branded animation shared across the entire platform — three layered brain SVG images (brain outline, rotating circle, rotating gears) stacked at 50×50px, counter-rotating at 3s linear infinite. Displayed over a `rgba(0, 0, 0, 0.6)` overlay. This is the signature loading state. +- **Secondary loader (ring spinner):** A 32×32px CSS border-based spinner: `border: 4px solid #5b88bd`, animating at 1.2s with cubic-bezier easing. +- **Skeleton screens:** Used while content loads — flat `#ebebeb` rectangles with 4px border-radius that hold the space where content will appear. +- **Button loading:** An inline spinner rotates inside the button (500ms, linear, infinite). + +### Scroll Behavior + +- **Smooth scroll:** Used sparingly (only in specific selection views), not globally. +- **No scroll-triggered animations** — content is static once rendered. +- **Sticky elements:** Mobile view headers and folder-view sidebars use `position: sticky`. +- **Custom scrollbar:** Thin (6–8px), border-radius 5px, with a brand-tinted thumb (`rgba(40, 88, 123, 0.5)` — the primary blue at 50% opacity) on a transparent track. + +--- + +## 8. Shadows & Depth + +Shadows are always pure black at low opacity — never tinted. The system uses three tiers, shared across all tools: + +| Level | Value | Feel | Used for | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- | +| SM | `0px 2px 8px rgba(0, 0, 0, 0.05)` | Barely visible, just separates from background | Header bar, resting content cards, section headers | +| MD | `0px 4px 16px rgba(0, 0, 0, 0.15)` | Clearly floating, still subtle | Tooltips, dropdowns, user menus, modal panels, search popups | +| LG | `0px 8px 17px rgba(0, 0, 0, 0.05), 0px 30px 30px rgba(0, 0, 0, 0.04), 0px 68px 41px rgba(0, 0, 0, 0.03), 0px 122px 49px rgba(0, 0, 0, 0.01), 0px 190px 53px rgba(0, 0, 0, 0)` | Diffused, paper-floating-on-desk feel | Elevated content panels, answer cards, NPS survey, start-test cards | + +- **Shadow color:** Always pure black with opacity ranging from 0.01 to 0.15. No colored or tinted shadows. +- **Specialty:** Upward-facing shadow (`0px -4px 16px rgba(0, 0, 0, 0.15)`) for bottom-anchored action bars. See Section 10b for UXCat-specific achievement glow effects. + +--- + +## 9. Border Radius Scale + +| Token | Value | Used for | +| ----- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| None | 0px | Mobile search inputs (full-width, edge-to-edge) | +| SM | 2px | Buttons (default and primary), tags/badges, small inline elements. Nearly square — a signature detail. | +| MD | 4px | The dominant radius. Modals, tooltips, inputs, textareas, search fields, toggles, nav indicator pill, hover states, answer cards, filter buttons. | +| LG | 8px | Standalone content cards, result sections, statistics boxes. | +| XL | 15px | Achievement badge containers (UXCat-specific decorative element). | +| Full | 50% | Avatars, score indicators, loading spinners, circular decorative elements. | + +The design leans heavily on **4px** as the standard radius. The 2px radius on buttons is an intentional choice — it gives buttons a more precise, tool-like feel compared to the slightly softer 4px on containers. + +--- + +## 10. Z-Index Layers + +A simplified, recommended stacking order based on the patterns in the design: + +| Layer | Z-index | Elements | +| ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------ | +| Base content | 0 | Default stacking — page content, card interiors | +| Local lifts | 1–5 | Header chrome, input fields above their error messages, tooltip arrows, snackbar notifications | +| Dropdowns / tooltips | 10 | Tooltip popups, user dropdowns, modal overlays (bias detail, UXCG detail) | +| Fixed navigation | 44–50 | Dropdown menus, leaderboard overlays, language switcher, mobile nav arrows | +| Modal backdrop | 45 | Semi-transparent overlay behind modals | +| Modal content | 80 | Modal panels (above their own backdrop) | +| Special overlays | 100+ | Mobile user dropdowns, fullscreen achievement announcements | +| Full-screen spinner | 1000 | The brain-spinner loading overlay (must be above everything) | +| Achievement celebrations | 99999+ | Achievement badge pop-ups and gamification overlays (UXCat — intentionally sky-high to guarantee visibility) | + +Note: for new implementations, a tighter scale (e.g., 0/10/20/30/40/50/100/1000) following the same layer order would be more maintainable. + +--- + +## 10b. UXCat-Specific Styles + +UXCat is the quiz and gamification tool. It inherits the entire shared foundation above (fonts, colors, spacing, breakpoints, component styles, shadows, border-radius) but adds its own visual personality on top of it. + +### How UXCat Differs from the Platform Style + +| Aspect | Platform (UXCore/UXCG/UXCP) | UXCat | +| ---------------- | --------------------------- | ------------------------------------------------------------ | +| Dominant accent | Steel-blue `#28587b` | Orange `#de915b` for titles and CTAs | +| Heading weight | 300 (light) | Bold (for quiz titles and start-test headings) | +| Content entrance | Immediate (no animation) | Staggered fade-in sequence | +| Interactivity | Browse and explore | Select, answer, submit — quiz-flow interaction | +| Reward layer | None | Achievement badges, leaderboard, progress bars, certificates | +| Emotional tone | Calm, reference-like | Encouraging, slightly competitive | + +### Additional Fonts (UXCat only) + +- **Oswald** (bold, serif fallback) — used for achievement badge display names. A condensed, impactful typeface that signals "reward" and "accomplishment." +- **Dela Gothic One** (weight 400) — used for gamification ranking displays (leaderboard ranks, score labels). Thick and monumental — makes rank numbers feel important. +- **IBM Plex Sans** (Regular/SemiBold) and **Manrope ExtraLight** — used for certificate generation. These give certificates a formal, clean appearance distinct from the UI chrome. + +### Start Test Screen + +A full-viewport introduction screen shown before a quiz begins. Sets the mood for the UXCat experience. + +- **Layout:** Full-screen (100vw × 100vh) with a background illustration image covering the viewport. Content is centered in a white modal-like card floating over the image. +- **Title:** 36px, **bold** weight, color `#de915b` (orange accent), centered. This is the key visual difference — UXCat headings use orange and bold weight, not the thin steel-blue of other tools. +- **Description:** 16px, line-height 22px, centered, 25px side padding. +- **Duration badge:** Positioned top-center, 20px, `#c4c4c4` gray text. On mobile (≤901px), this repositions to a small pill in the top-right corner with `1px solid #c4c4c4` border, 4px border-radius, white background. +- **Card shadow:** Uses the LG multi-layered shadow for a prominent floating effect. +- **CTA button:** Orange button variant (`#de915b` → `#cd7232` on hover), centered below the description. + +### Quiz Flow (Ongoing Screen) + +The active quiz-taking interface. Content is broken into a question zone at the top and selectable answer cards below. + +**Question area:** + +- White card with SM shadow (`0px 2px 8px rgba(0, 0, 0, 0.05)`), minimum height 223px. +- Question text: 24px, weight 800 (the heaviest weight in the system), centered, `#000`. On mobile (≤800px), drops to 16px. +- Sub-question text: 18px, weight 700, centered. +- Bias title and description: 16px, line-height 25.6px, left-aligned, with bold weight for the title. +- Top padding: 80px on desktop (creates breathing room below the header). Removed on mobile. +- User selection is disabled (`user-select: none`) — the interface is interaction-only. + +**Answer cards:** + +- White cards with the LG multi-layered shadow, `2px solid #fafafa` border (nearly invisible at rest), 4px border-radius, minimum height 72px. +- Each card has a **prefix label** (e.g., "A", "B") in a dark pill: `#000000a6` background, `#fff` text, 2px border-radius, 0 8px padding. +- Answer text: 16px, `rgba(0, 0, 0, 0.65)` (slightly muted), centered. +- **Selected state:** Border becomes `2px solid #5b88bd`, background gets a faint blue wash `rgba(147, 183, 255, 0.2)`. A quick scale pulse plays (scale 1 → 0.99 → 1, 200ms) to give tactile feedback. +- **Skeleton state (loading):** Background `#ebebeb`, no text, same dimensions. On mobile, border changes to `1px solid #c4c4c4`. + +**Staggered entrance animation:** + +- When a new question loads, elements fade in sequentially: + - Question text: 500ms + - Bias title: 600ms + - Description: 700ms + - Sub-question: 800ms + - Answer cards: 900ms +- Each uses `opacity: 0 → 1`, `ease-in-out`. On exit, the same elements fade out in the same staggered order. +- Answers can also slide down from above (`translateY(-100%) → 0`, 400ms, `ease-in-out`) when new options appear. + +**Action buttons:** + +- Centered below answers, 32px top margin, 60px bottom padding, 17px gap between buttons. +- Fixed width: 205px per button (235px for Russian locale). + +### Calculating Results Screen + +A transitional screen shown while quiz results are being processed. + +- **Layout:** Full height (`100vh - 45px`), centered content, 125px top margin. +- **Ring spinner:** 32×32px, `border: 4px solid #5b88bd`, three staggered rings with `-0.45s`, `-0.3s`, `-0.15s` animation delays. Duration: 1.2s, `cubic-bezier(0.5, 0, 0.5, 1)`. +- **Title:** 24px, weight 600, line-height 28.8px. +- **Progress card:** White card with the LG multi-layered shadow, max-width 497px, 64px vertical padding. +- **Progress bars:** 2px height, `#e0e0e0` background track, `#2db675` (green) fill that animates from 0% to 100% width over 1 second. +- **Checkmark:** `#4caf50` green, 18px. + +### Test Results Screen + +Displays quiz results with score, reading recommendations, and next-test information. + +- **Header:** 396px tall white card with SM shadow, containing score visualization. +- **Body:** 900px max-width, 24px top margin, standard platform layout. +- **Result cards:** 8px border-radius, `1px solid #d9d9d9` border, MD shadow (`0 4px 16px rgba(0, 0, 0, 0.15)`), white background. +- **Reading list links:** 16px, `#000` text, 2px border-radius, ellipsis overflow at 380px max-width. Links stack with 8px gap. +- **Next test date:** `#a36aa4` (purple accent) text. +- **Action buttons:** 218px fixed width. Centered in the footer with 61px top margin. + +### Achievement Badges + +Pop-up reward notifications that appear when users unlock achievements during quizzes. These are the most visually distinctive elements in the entire platform — intentionally flashy compared to the understated core aesthetic. + +- **Container:** 420×140px, border-radius 15px, transparent background, z-index 99999 (above everything). +- **Inner wrapper:** 320×100px, border-radius 10px, gold background `rgb(177, 136, 56)` with asymmetric gold borders (top: `5px solid rgb(216, 188, 131)`, right: `5px solid rgb(168, 130, 53)`, bottom: `5px solid rgb(177, 134, 53)`). A metallic gradient fills the text area: `linear-gradient(-23deg, #9e7931, #cb9d4a 39%, #d1ad66 51%)`. +- **Badge name:** Oswald font, 24px (20px on mobile), `#fff` white text, uppercase. +- **Title label:** 15px (12px on mobile), `#fff` text, `rgb(151, 100, 0)` background, `2px solid rgb(166, 128, 53)` border, uppercase. +- **Glow effect:** Pulsing gold glow shadow: `0px 0px 4px rgb(252, 249, 218), 0px 0px 8px rgb(252, 249, 218), 0px 0px 16px rgb(252, 249, 218)`. On hover, glow intensifies (spreads to 8/12/20px). 4px border-radius on the glow elements. +- **Star decorations:** Rotating star images (5s ease-in infinite) with fade-in-and-out particles (2s linear infinite). +- **Entrance animations:** Slides in from the side (`translateX(100%) → 0`, 1s ease-in-out). Exits with `translateX(0) → translateX(100%)` + opacity fade, 500ms. +- **Mobile:** Container shrinks (reduced padding, smaller text — 12px title, 20px badge name, 75px wrapper height). + +### UXCat Page Layout + +The UXCat hub page (test selection, leaderboard) uses the same layout pattern as other tools: + +- Page title: 36px, weight 400, `#28587b` (brand blue — the hub page uses the platform color, not orange). +- Subtitle: 18px, `#9e9e9e`, 9px top padding. +- Description: 16px, line-height 19.6px. +- Content: 900px max-width, 86px top margin. +- **Accordion sections:** 32px bottom margin, 150% line-height. +- **Leaderboard:** 32px top margin, 62px bottom margin. +- On mobile (≤801px): title goes to 24px bold, subtitle to 16px, description hidden, accordion margin to 16px. + +### Achievement Banner (In-Quiz) + +A full-width gradient banner that appears during a quiz when an achievement is unlocked: + +- Background: `linear-gradient(261.09deg, rgba(195, 55, 100, 0.89) 0.45%, rgba(29, 38, 113, 0.89) 90.78%)` — a dramatic pink-to-navy gradient, the only gradient element in the entire UI. +- Padding: 14px vertical, 20px horizontal text. +- Text: 14px, white. +- Twinkling star emojis at corners, fading in and out (2–3s infinite). +- Z-index: 999999 (the absolute highest layer — must appear above quiz content and any existing badges). + +--- + +## 11. Do / Don't Rules + +# Section 11 — Do / Don't Rules (DRAFT) + +> Go through these. Keep the ones that match your vision, +> delete the ones that don't apply, reword anything that +> needs adjusting, and add your own. These are the rules +> that stop Claude from making "technically correct but +> visually wrong" output. + +## Always Do + +### Layout & Structure + +- Keep layouts flat and simple — content speaks, decoration doesn't +- Use generous whitespace between sections — let things breathe +- Maintain strict visual hierarchy: one clear focal point per section +- Align elements to a consistent grid — nothing should feel randomly placed +- Keep content containers at a predictable max-width — never let text stretch edge-to-edge + +### Typography + +- Use font weights exactly as specified — don't improvise with weights we don't use +- Keep headings lightweight (thin/light weight) — heavy headings feel wrong for UXCore/UXCG/UXCP (UXCat uses bold for quiz titles — that's the exception) +- Use uppercase only where the system specifies it (nav, labels, tags) — nowhere else +- Maintain consistent text sizes — don't introduce sizes between our scale steps + +### Color + +- Use color sparingly and with purpose — every colored element should mean something +- Keep the palette desaturated and cool — the platform is calm, not vibrant +- Use the steel-blue (#28587b) as the single dominant accent for UXCore/UXCG/UXCP — don't spread multiple accent colors +- For UXCat quiz screens, use orange (#de915b) as the dominant accent — but only on titles and CTAs, not everywhere +- Let white/near-white backgrounds do most of the work + +### Components + +- Cards should feel like paper — white, slightly lifted, clean edges +- Buttons should be small and compact — they don't shout +- Inputs should feel invisible until focused — minimal borders, no shadows at rest +- Tags/badges should be subtle color-coded labels, not attention-grabbing pills + +### Interactions + +- Keep hover effects subtle — a slight lift, a border color shift, nothing dramatic +- Use the exact easing curves specified — the springy cubic-bezier is part of the feel +- Transitions should be fast (150-300ms) — the UI should feel responsive, not animated + +## Never Do + +### The "AI Look" Killers (most important) + +- Never use gradient backgrounds on cards or sections — the platform uses flat white/gray only (the UXCat achievement banner is the sole exception) +- Never use colored shadows or glows (except the specific gold achievement glow in UXCat) +- Never use rounded pill buttons (large border-radius like 24px+) — our buttons are nearly square (2-4px radius) +- Never use hero sections with giant text and centered CTAs — the platform is a tool, not a landing page +- Never use decorative SVG blobs, waves, or organic shapes as backgrounds +- Never use glassmorphism, frosted glass, or backdrop-blur effects +- Never add decorative gradients to text +- Never use icon-heavy empty states with cute illustrations +- Never center-align body text — always left-align (except specific centered headings) + +### Typography + +- Never use font weights that aren't in our scale (no 900/black, no 100/hairline unless specified) +- Never use font sizes outside our type scale +- Never add text shadows +- Never use italic for emphasis — use weight or color instead + +### Color & Visual + +- Never use pure black (#000000) as a background — our darkest background is near-white gray +- Never introduce new accent colors — stay within the defined palette +- Never use high-saturation colors — everything in the platform is muted and desaturated +- Never use colored borders on cards — borders are always neutral gray or the specific selection blue +- Never use alternating row colors in lists/tables (zebra striping) unless it already exists in the design + +### Spacing & Layout + +- Never add more than 3 levels of visual nesting — keep the hierarchy shallow +- Never use full-bleed colored sections that span the viewport +- Never overlap elements for decorative effect — the layout is clean and grid-aligned +- Never use masonry layouts — our grids are uniform + +### Motion + +- Never add entrance animations to every element — staggered reveals are used only in UXCat quiz flow +- Never use bounce, elastic, or exaggerated easing — motion is subtle and physical +- Never animate color changes on hover — change happens instantly, only position/shadow animates +- Never add loading animations to elements that load instantly +- Never use parallax scrolling + +### Components + +- Never make buttons taller than specified — our buttons are compact (32px default, 54px max for big variant) +- Never add icons inside buttons unless the existing design does +- Never use floating action buttons (FABs) +- Never use toast notifications that slide in from corners (unless the project has them) +- Never add empty state illustrations — use simple text + +## Tone of the UI + +- *** + (Suggestion: "Quiet, structured, informational. A well-organized + reference tool — closer to a thoughtfully designed textbook than + a marketing page. The UI stays out of the way and lets dense + educational content be the focus. Nothing is flashy, playful, + or decorative without purpose. UXCat adds warmth and encouragement + to the quiz experience, but never crosses into playful or casual.") + +--- + +## Code Conventions + +This skill covers **visual design only** — colors, typography, spacing, component appearance, motion. + +For code conventions (component structure, file layout, prop typing, static data, locales, path aliases, testing, gotchas), see **`AGENTS.md`** at the repo root. That file is the authoritative source for how to write code in this repo. + +If a visual instruction here conflicts with a code convention in `AGENTS.md`, both apply — this skill governs how the UI _looks_, `AGENTS.md` governs how the code is _structured_. diff --git a/yarn.lock b/yarn.lock index 6b0784d3..4936d259 100644 --- a/yarn.lock +++ b/yarn.lock @@ -920,53 +920,6 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@cypress/react@^9.0.1": - version "9.0.1" - resolved "https://registry.npmjs.org/@cypress/react/-/react-9.0.1.tgz" - integrity sha512-qu6ziP2smdlfy3Yvrhm6PadxEtkc/cl6YhZu3h6KCtz+0s54joqxp6uGYOglpwyMBp3qjtSil1JVlFX0hUi5LQ== - -"@cypress/request@^3.0.9": - version "3.0.10" - resolved "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz" - integrity sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~4.0.4" - http-signature "~1.4.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - performance-now "^2.1.0" - qs "~6.14.1" - safe-buffer "^5.1.2" - tough-cookie "^5.0.0" - tunnel-agent "^0.6.0" - uuid "^8.3.2" - -"@cypress/vite-dev-server@^6.0.3": - version "6.0.3" - resolved "https://registry.npmjs.org/@cypress/vite-dev-server/-/vite-dev-server-6.0.3.tgz" - integrity sha512-iw5koemvIOzwjtGOKKGfNVGvmjwBmjj5DCiBW6ATUB+m1HzFM9Zmq1dpll+zym5dM+pxb5iA8zKLoAoSKutYVg== - dependencies: - debug "^4.3.4" - find-up "6.3.0" - node-html-parser "5.3.3" - semver "^7.7.1" - -"@cypress/xvfb@^1.2.4": - version "1.2.4" - resolved "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz" - integrity sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q== - dependencies: - debug "^3.1.0" - lodash.once "^4.1.1" - "@discoveryjs/json-ext@0.5.7": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" @@ -1094,40 +1047,6 @@ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz" integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== -"@hapi/address@^5.1.1": - version "5.1.1" - resolved "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz" - integrity sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA== - dependencies: - "@hapi/hoek" "^11.0.2" - -"@hapi/formula@^3.0.2": - version "3.0.2" - resolved "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz" - integrity sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw== - -"@hapi/hoek@^11.0.2", "@hapi/hoek@^11.0.7": - version "11.0.7" - resolved "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz" - integrity sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ== - -"@hapi/pinpoint@^2.0.1": - version "2.0.1" - resolved "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz" - integrity sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q== - -"@hapi/tlds@^1.1.1": - version "1.1.6" - resolved "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz" - integrity sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw== - -"@hapi/topo@^6.0.2": - version "6.0.2" - resolved "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz" - integrity sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg== - dependencies: - "@hapi/hoek" "^11.0.2" - "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz" @@ -1443,44 +1362,6 @@ resolved "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz" integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== -"@otplib/core@^12.0.1": - version "12.0.1" - resolved "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz" - integrity sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA== - -"@otplib/plugin-crypto@^12.0.1": - version "12.0.1" - resolved "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz" - integrity sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g== - dependencies: - "@otplib/core" "^12.0.1" - -"@otplib/plugin-thirty-two@^12.0.1": - version "12.0.1" - resolved "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz" - integrity sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA== - dependencies: - "@otplib/core" "^12.0.1" - thirty-two "^1.0.2" - -"@otplib/preset-default@^12.0.1": - version "12.0.1" - resolved "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz" - integrity sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ== - dependencies: - "@otplib/core" "^12.0.1" - "@otplib/plugin-crypto" "^12.0.1" - "@otplib/plugin-thirty-two" "^12.0.1" - -"@otplib/preset-v11@^12.0.1": - version "12.0.1" - resolved "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz" - integrity sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg== - dependencies: - "@otplib/core" "^12.0.1" - "@otplib/plugin-crypto" "^12.0.1" - "@otplib/plugin-thirty-two" "^12.0.1" - "@panva/hkdf@^1.0.2": version "1.2.1" resolved "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz" @@ -1498,20 +1379,6 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== -"@puppeteer/browsers@2.3.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz" - integrity sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA== - dependencies: - debug "^4.3.5" - extract-zip "^2.0.1" - progress "^2.0.3" - proxy-agent "^6.4.0" - semver "^7.6.3" - tar-fs "^3.0.6" - unbzip2-stream "^1.4.3" - yargs "^17.7.2" - "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz" @@ -1522,11 +1389,6 @@ resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz" integrity sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag== -"@standard-schema/spec@^1.0.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz" - integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== - "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz" @@ -1645,11 +1507,6 @@ dependencies: tslib "^2.4.0" -"@tootallnate/quickjs-emscripten@^0.23.0": - version "0.23.0" - resolved "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz" - integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== - "@tsconfig/node10@^1.0.7": version "1.0.12" resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz" @@ -1759,7 +1616,7 @@ resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz" integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== -"@types/node@*", "@types/node@^24.5.2": +"@types/node@^24.5.2": version "24.10.14" resolved "https://registry.npmjs.org/@types/node/-/node-24.10.14.tgz" integrity sha512-OowOUbD1lBCOFIPOZ8xnMIhgqA4sCutMiYOmPHL1PTLt5+y1XA+g2+yC9OOyz8p+deMZqPZLxfMjYIfrKsPeFg== @@ -1814,16 +1671,6 @@ resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz" integrity sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA== -"@types/sinonjs__fake-timers@8.1.1": - version "8.1.1" - resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz" - integrity sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g== - -"@types/sizzle@^2.3.2": - version "2.3.10" - resolved "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz" - integrity sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww== - "@types/swiper@6.0.0": version "6.0.0" resolved "https://registry.npmjs.org/@types/swiper/-/swiper-6.0.0.tgz" @@ -1841,13 +1688,6 @@ resolved "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.1.tgz" integrity sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg== -"@types/yauzl@^2.9.1": - version "2.10.3" - resolved "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz" - integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== - dependencies: - "@types/node" "*" - "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0": version "8.56.1" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz" @@ -2063,19 +1903,6 @@ acorn@^8.0.4, acorn@^8.11.0, acorn@^8.15.0, acorn@^8.4.1: resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== -agent-base@^7.1.0, agent-base@^7.1.2: - version "7.1.4" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" - integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== - -aggregate-error@^3.0.0: - version "3.1.0" - resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz" - integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== - dependencies: - clean-stack "^2.0.0" - indent-string "^4.0.0" - ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz" @@ -2091,24 +1918,7 @@ ajv@^6.12.4, ajv@^6.14.0: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-colors@^4.1.1: - version "4.1.3" - resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz" - integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== - -ansi-escapes@^4.3.0: - version "4.3.2" - resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" - integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== - dependencies: - type-fest "^0.21.3" - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: +ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -2123,21 +1933,11 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -arch@^2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz" - integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== - arg@^4.1.0: version "4.1.3" resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== -arg@^5.0.2: - version "5.0.2" - resolved "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz" - integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== - argparse@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" @@ -2239,35 +2039,11 @@ arraybuffer.prototype.slice@^1.0.4: get-intrinsic "^1.2.6" is-array-buffer "^3.0.4" -asn1@~0.2.3: - version "0.2.6" - resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz" - integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" - integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== - ast-types-flow@^0.0.8: version "0.0.8" resolved "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz" integrity sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ== -ast-types@^0.13.4: - version "0.13.4" - resolved "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz" - integrity sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w== - dependencies: - tslib "^2.0.1" - -astral-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz" - integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== - async-function@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz" @@ -2280,21 +2056,6 @@ async@^2.1.1: dependencies: lodash "^4.17.14" -async@^3.2.0: - version "3.2.6" - resolved "https://registry.npmjs.org/async/-/async-3.2.6.tgz" - integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz" @@ -2302,16 +2063,6 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz" - integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== - -aws4@^1.8.0: - version "1.13.2" - resolved "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz" - integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw== - axe-core@^4.10.0: version "4.11.1" resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz" @@ -2322,25 +2073,11 @@ axe-core@~4.11.3: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.3.tgz#d23cf404edaa5f97bdfd9afed6eea8405e5326e7" integrity sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg== -axios@^1.13.5: - version "1.13.5" - resolved "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz" - integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q== - dependencies: - follow-redirects "^1.15.11" - form-data "^4.0.5" - proxy-from-env "^1.1.0" - axobject-query@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz" integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== -b4a@^1.6.4: - version "1.8.0" - resolved "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz" - integrity sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg== - babel-loader@8.2.5: version "8.2.5" resolved "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz" @@ -2390,76 +2127,16 @@ balanced-match@^4.0.2: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== -bare-events@^2.5.4, bare-events@^2.7.0: - version "2.8.2" - resolved "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz" - integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== - -bare-fs@^4.0.1: - version "4.5.5" - resolved "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz" - integrity sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w== - dependencies: - bare-events "^2.5.4" - bare-path "^3.0.0" - bare-stream "^2.6.4" - bare-url "^2.2.2" - fast-fifo "^1.3.2" - -bare-os@^3.0.1: - version "3.7.0" - resolved "https://registry.npmjs.org/bare-os/-/bare-os-3.7.0.tgz" - integrity sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g== - -bare-path@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz" - integrity sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw== - dependencies: - bare-os "^3.0.1" - -bare-stream@^2.6.4: - version "2.8.0" - resolved "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz" - integrity sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA== - dependencies: - streamx "^2.21.0" - teex "^1.0.1" - -bare-url@^2.2.2: - version "2.3.2" - resolved "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz" - integrity sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw== - dependencies: - bare-path "^3.0.0" - base64-arraybuffer@^1.0.1, base64-arraybuffer@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz" integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - baseline-browser-mapping@^2.9.0: version "2.10.0" resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz" integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== -basic-ftp@^5.0.2: - version "5.2.0" - resolved "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz" - integrity sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw== - -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz" - integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== - dependencies: - tweetnacl "^0.14.3" - big.js@^5.2.2: version "5.2.2" resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" @@ -2470,16 +2147,6 @@ binary-extensions@^2.0.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -blob-util@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz" - integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== - -bluebird@3.7.2, bluebird@^3.7.2: - version "3.7.2" - resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz" - integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== - boolbase@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz" @@ -2523,14 +2190,6 @@ buffer-crc32@~0.2.3: resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== -buffer@^5.2.1, buffer@^5.7.1: - version "5.7.1" - resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - busboy@1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz" @@ -2538,11 +2197,6 @@ busboy@1.6.0: dependencies: streamsearch "^1.1.0" -cachedir@^2.3.0: - version "2.4.0" - resolved "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz" - integrity sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ== - call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz" @@ -2584,12 +2238,7 @@ caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001759: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz" integrity sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA== -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz" - integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== - -chalk@^4.0.0, chalk@^4.1.0: +chalk@^4.0.0: version "4.1.2" resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -2597,16 +2246,16 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.0.0: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + character-entities@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz" integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== -check-more-types@2.24.0, check-more-types@^2.24.0: - version "2.24.0" - resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz" - integrity sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA== - "chokidar@>=2.0.0 <4.0.0": version "3.6.0" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" @@ -2622,68 +2271,16 @@ check-more-types@2.24.0, check-more-types@^2.24.0: optionalDependencies: fsevents "~2.3.2" -chromium-bidi@0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz" - integrity sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A== - dependencies: - mitt "3.0.1" - urlpattern-polyfill "10.0.0" - zod "3.23.8" - -ci-info@^4.1.0: - version "4.4.0" - resolved "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz" - integrity sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg== - classnames@2.3.1, classnames@^2.2.5, classnames@^2.3.0: version "2.3.1" resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== -clean-stack@^2.0.0: - version "2.2.0" - resolved "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz" - integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== - -cli-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz" - integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== - dependencies: - restore-cursor "^3.1.0" - -cli-table3@0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz" - integrity sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA== - dependencies: - string-width "^4.2.0" - optionalDependencies: - colors "1.4.0" - -cli-truncate@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz" - integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== - dependencies: - slice-ansi "^3.0.0" - string-width "^4.2.0" - client-only@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== -cliui@^8.0.1: - version "8.0.1" - resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" - integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - wrap-ansi "^7.0.0" - color-convert@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" @@ -2712,43 +2309,21 @@ color@^4.2.3: color-convert "^2.0.1" color-string "^1.9.0" -colorette@^2.0.16: - version "2.0.20" - resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz" - integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== - -colors@1.4.0, colors@^1.1.2: +colors@^1.1.2: version "1.4.0" resolved "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== -combined-stream@^1.0.8, combined-stream@~1.0.6: - version "1.0.8" - resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz" integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== -commander@^6.2.1: - version "6.2.1" - resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz" - integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== - commander@^7.2.0: version "7.2.0" resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== -common-tags@^1.8.0: - version "1.8.2" - resolved "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz" - integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== - commondir@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" @@ -2786,11 +2361,6 @@ core-js-compat@^3.48.0: dependencies: browserslist "^4.28.1" -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" - integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== - cosmiconfig@^8.1.3: version "8.3.6" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz" @@ -2801,16 +2371,6 @@ cosmiconfig@^8.1.3: parse-json "^5.2.0" path-type "^4.0.0" -cosmiconfig@^9.0.0: - version "9.0.0" - resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz" - integrity sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg== - dependencies: - env-paths "^2.2.1" - import-fresh "^3.3.0" - js-yaml "^4.1.0" - parse-json "^5.2.0" - create-require@^1.1.0: version "1.1.1" resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" @@ -2823,7 +2383,7 @@ cross-env@7.0.3: dependencies: cross-spawn "^7.0.1" -cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.1, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -2855,17 +2415,6 @@ css-line-break@^2.1.0: dependencies: utrie "^1.0.2" -css-select@^4.2.1: - version "4.3.0" - resolved "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz" - integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== - dependencies: - boolbase "^1.0.0" - css-what "^6.0.1" - domhandler "^4.3.1" - domutils "^2.8.0" - nth-check "^2.0.1" - css-select@^5.1.0: version "5.2.2" resolved "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz" @@ -2901,7 +2450,7 @@ css-vendor@^2.0.8: "@babel/runtime" "^7.8.3" is-in-browser "^1.0.2" -css-what@^6.0.1, css-what@^6.1.0: +css-what@^6.1.0: version "6.2.2" resolved "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz" integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== @@ -2918,88 +2467,11 @@ csstype@^3.0.2: resolved "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== -cypress-social-logins@^1.14.2: - version "1.14.2" - resolved "https://registry.npmjs.org/cypress-social-logins/-/cypress-social-logins-1.14.2.tgz" - integrity sha512-acTzY6axp/GJnmxCA6aHiXxg6F2vIfsa6YVvTvvu4P2MLc/cdXqhbh5JJsk5r3iGLTQwLAIos8YZuAik6eWqVQ== - dependencies: - otplib "^12.0.1" - puppeteer "^22.15.0" - -cypress-v10-preserve-cookie@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/cypress-v10-preserve-cookie/-/cypress-v10-preserve-cookie-1.2.1.tgz" - integrity sha512-8JAfcrDrCCiFyce7ygtqO9Gad0yzFla9WNDGA9j4sz6kLSvh2m2fWcBcON2VwWw5GfRDXmpe+yzT2r8Mu3jGdA== - dependencies: - debug "^4.3.4" - -cypress@^14.5.2: - version "14.5.4" - resolved "https://registry.npmjs.org/cypress/-/cypress-14.5.4.tgz" - integrity sha512-0Dhm4qc9VatOcI1GiFGVt8osgpPdqJLHzRwcAB5MSD/CAAts3oybvPUPawHyvJZUd8osADqZe/xzMsZ8sDTjXw== - dependencies: - "@cypress/request" "^3.0.9" - "@cypress/xvfb" "^1.2.4" - "@types/sinonjs__fake-timers" "8.1.1" - "@types/sizzle" "^2.3.2" - arch "^2.2.0" - blob-util "^2.0.2" - bluebird "^3.7.2" - buffer "^5.7.1" - cachedir "^2.3.0" - chalk "^4.1.0" - check-more-types "^2.24.0" - ci-info "^4.1.0" - cli-cursor "^3.1.0" - cli-table3 "0.6.1" - commander "^6.2.1" - common-tags "^1.8.0" - dayjs "^1.10.4" - debug "^4.3.4" - enquirer "^2.3.6" - eventemitter2 "6.4.7" - execa "4.1.0" - executable "^4.1.1" - extract-zip "2.0.1" - figures "^3.2.0" - fs-extra "^9.1.0" - getos "^3.2.1" - hasha "5.2.2" - is-installed-globally "~0.4.0" - lazy-ass "^1.6.0" - listr2 "^3.8.3" - lodash "^4.17.21" - log-symbols "^4.0.0" - minimist "^1.2.8" - ospath "^1.2.2" - pretty-bytes "^5.6.0" - process "^0.11.10" - proxy-from-env "1.0.0" - request-progress "^3.0.0" - semver "^7.7.1" - supports-color "^8.1.1" - tmp "~0.2.3" - tree-kill "1.2.2" - untildify "^4.0.0" - yauzl "^2.10.0" - damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz" - integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== - dependencies: - assert-plus "^1.0.0" - -data-uri-to-buffer@^6.0.2: - version "6.0.2" - resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz" - integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw== - data-view-buffer@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz" @@ -3034,30 +2506,25 @@ date-fns@2.30.0: dependencies: "@babel/runtime" "^7.21.0" -dayjs@^1.10.4: - version "1.11.19" - resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz" - integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== - debounce@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== -debug@4, debug@4.4.3, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.4.0, debug@^4.4.3: - version "4.4.3" - resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - -debug@^3.1.0, debug@^3.2.7: +debug@^3.2.7: version "3.2.7" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: ms "^2.1.1" +debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + decode-named-character-reference@^1.0.0: version "1.3.0" resolved "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz" @@ -3093,20 +2560,6 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" -degenerator@^5.0.0: - version "5.0.1" - resolved "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz" - integrity sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ== - dependencies: - ast-types "^0.13.4" - escodegen "^2.1.0" - esprima "^4.0.1" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - dequal@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" @@ -3117,11 +2570,6 @@ detect-libc@^2.0.3: resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== -devtools-protocol@0.0.1312386: - version "0.0.1312386" - resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz" - integrity sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA== - diff@^4.0.1: version "4.0.4" resolved "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz" @@ -3139,15 +2587,6 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz" @@ -3157,18 +2596,11 @@ dom-serializer@^2.0.0: domhandler "^5.0.2" entities "^4.2.0" -domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: +domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== -domhandler@^4.2.0, domhandler@^4.3.1: - version "4.3.1" - resolved "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== - dependencies: - domelementtype "^2.2.0" - domhandler@^5.0.2, domhandler@^5.0.3: version "5.0.3" resolved "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz" @@ -3176,16 +2608,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== - dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" - -domutils@^3.0.1: +domutils@^3.0.1, domutils@^3.2.2: version "3.2.2" resolved "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz" integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== @@ -3216,29 +2639,16 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -duplexer@^0.1.2, duplexer@~0.1.1: +duplexer@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz" - integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - electron-to-chromium@^1.5.263: version "1.5.302" resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz" integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - emoji-regex@^9.2.2: version "9.2.2" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" @@ -3249,40 +2659,20 @@ emojis-list@^3.0.0: resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -end-of-stream@^1.1.0: - version "1.4.5" - resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz" - integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== - dependencies: - once "^1.4.0" - enquire.js@^2.1.6: version "2.1.6" resolved "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz" integrity sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw== -enquirer@^2.3.6: - version "2.4.1" - resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz" - integrity sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== - dependencies: - ansi-colors "^4.1.1" - strip-ansi "^6.0.1" - -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - entities@^4.2.0, entities@^4.4.0: version "4.5.0" resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== -env-paths@^2.2.1: - version "2.2.1" - resolved "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz" - integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== error-ex@^1.3.1: version "1.3.4" @@ -3416,32 +2806,21 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -escalade@^3.1.1, escalade@^3.2.0: +escalade@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -escodegen@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz" - integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== - dependencies: - esprima "^4.0.1" - estraverse "^5.2.0" - esutils "^2.0.2" - optionalDependencies: - source-map "~0.6.1" - eslint-config-next@^15.4.4: version "15.5.12" resolved "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.12.tgz" @@ -3639,11 +3018,6 @@ espree@^10.0.1, espree@^10.4.0: acorn-jsx "^5.3.2" eslint-visitor-keys "^4.2.1" -esprima@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - esquery@^1.5.0: version "1.7.0" resolved "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz" @@ -3668,99 +3042,16 @@ esutils@^2.0.2: resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -event-stream@=3.3.4: - version "3.3.4" - resolved "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz" - integrity sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g== - dependencies: - duplexer "~0.1.1" - from "~0" - map-stream "~0.1.0" - pause-stream "0.0.11" - split "0.3" - stream-combiner "~0.0.4" - through "~2.3.1" - -eventemitter2@6.4.7: - version "6.4.7" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz" - integrity sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg== - -events-universal@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz" - integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== - dependencies: - bare-events "^2.7.0" - -execa@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz" - integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== - dependencies: - cross-spawn "^7.0.0" - get-stream "^5.0.0" - human-signals "^1.1.1" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.0" - onetime "^5.1.0" - signal-exit "^3.0.2" - strip-final-newline "^2.0.0" - -execa@5.1.1: - version "5.1.1" - resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -executable@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz" - integrity sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg== - dependencies: - pify "^2.2.0" - -extend@^3.0.0, extend@~3.0.2: +extend@^3.0.0: version "3.0.2" resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -extract-zip@2.0.1, extract-zip@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz" - integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== - dependencies: - debug "^4.1.1" - get-stream "^5.1.0" - yauzl "^2.10.0" - optionalDependencies: - "@types/yauzl" "^2.9.1" - -extsprintf@1.3.0, extsprintf@^1.2.0: - version "1.3.0" - resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz" - integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-fifo@^1.2.0, fast-fifo@^1.3.2: - version "1.3.2" - resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz" - integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== - fast-glob@3.3.1: version "3.3.1" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz" @@ -3801,13 +3092,6 @@ fdir@^6.5.0: resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== -figures@^3.2.0: - version "3.2.0" - resolved "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz" - integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== - dependencies: - escape-string-regexp "^1.0.5" - file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz" @@ -3831,14 +3115,6 @@ find-cache-dir@^3.3.1: make-dir "^3.0.2" pkg-dir "^4.1.0" -find-up@6.3.0: - version "6.3.0" - resolved "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz" - integrity sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw== - dependencies: - locate-path "^7.1.0" - path-exists "^5.0.0" - find-up@^4.0.0: version "4.1.0" resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz" @@ -3868,11 +3144,6 @@ flatted@^3.2.9: resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz" integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== -follow-redirects@^1.15.11: - version "1.15.11" - resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== - for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz" @@ -3880,37 +3151,6 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz" - integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== - -form-data@^4.0.5, form-data@~4.0.4: - version "4.0.5" - resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz" - integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.12" - -from@~0: - version "0.1.7" - resolved "https://registry.npmjs.org/from/-/from-0.1.7.tgz" - integrity sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g== - -fs-extra@^9.1.0: - version "9.1.0" - resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" @@ -3971,11 +3211,6 @@ geoip-lite@1.4.2: rimraf "^2.5.2" yauzl "^2.9.2" -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" @@ -4000,18 +3235,6 @@ get-proto@^1.0.0, get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" -get-stream@^5.0.0, get-stream@^5.1.0: - version "5.2.0" - resolved "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz" - integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== - dependencies: - pump "^3.0.0" - -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - get-symbol-description@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz" @@ -4028,28 +3251,10 @@ get-tsconfig@^4.10.0: dependencies: resolve-pkg-maps "^1.0.0" -get-uri@^6.0.1: - version "6.0.5" - resolved "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz" - integrity sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg== - dependencies: - basic-ftp "^5.0.2" - data-uri-to-buffer "^6.0.2" - debug "^4.3.4" - -getos@^3.2.1: - version "3.2.1" - resolved "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz" - integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q== - dependencies: - async "^3.2.0" - -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz" - integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== - dependencies: - assert-plus "^1.0.0" +github-slugger@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a" + integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" @@ -4065,6 +3270,15 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@^13.0.0: + version "13.0.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" + integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== + dependencies: + minimatch "^10.2.2" + minipass "^7.1.3" + path-scurry "^2.0.2" + glob@^7.1.3: version "7.2.3" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" @@ -4077,13 +3291,6 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^3.0.0: - version "3.0.1" - resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz" - integrity sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA== - dependencies: - ini "2.0.0" - globals@^14.0.0: version "14.0.0" resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" @@ -4102,11 +3309,6 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.1.6, graceful-fs@^4.2.0: - version "4.2.11" - resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" @@ -4150,14 +3352,6 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" -hasha@5.2.2: - version "5.2.2" - resolved "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz" - integrity sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ== - dependencies: - is-stream "^2.0.0" - type-fest "^0.8.0" - hasown@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz" @@ -4230,11 +3424,6 @@ hastscript@^7.0.0: property-information "^6.0.0" space-separated-tokens "^2.0.0" -he@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - hoist-non-react-statics@^3.2.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz" @@ -4260,40 +3449,15 @@ html2canvas@^1.4.1: css-line-break "^2.1.0" text-segmentation "^1.0.3" -http-proxy-agent@^7.0.0, http-proxy-agent@^7.0.1: - version "7.0.2" - resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz" - integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== - dependencies: - agent-base "^7.1.0" - debug "^4.3.4" - -http-signature@~1.4.0: - version "1.4.0" - resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz" - integrity sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg== - dependencies: - assert-plus "^1.0.0" - jsprim "^2.0.2" - sshpk "^1.18.0" - -https-proxy-agent@^7.0.6: - version "7.0.6" - resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" - integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== +htmlparser2@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4" + integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ== dependencies: - agent-base "^7.1.2" - debug "4" - -human-signals@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz" - integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== - -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.2.2" + entities "^7.0.1" husky@^9.1.7: version "9.1.7" @@ -4312,11 +3476,6 @@ iconv-lite@^0.4.13: dependencies: safer-buffer ">= 2.1.2 < 3" -ieee754@^1.1.13: - version "1.2.1" - resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - ignore@^5.2.0: version "5.3.2" resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" @@ -4340,11 +3499,6 @@ imurmurhash@^0.1.4: resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - inflight@^1.0.4: version "1.0.6" resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" @@ -4358,11 +3512,6 @@ inherits@2: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - inline-style-parser@0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz" @@ -4377,11 +3526,6 @@ internal-slot@^1.1.0: hasown "^2.0.2" side-channel "^1.1.0" -ip-address@^10.0.1: - version "10.1.0" - resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz" - integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== - ip-address@^5.8.9: version "5.9.4" resolved "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz" @@ -4496,11 +3640,6 @@ is-finalizationregistry@^1.1.0: dependencies: call-bound "^1.0.3" -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - is-generator-function@^1.0.10: version "1.1.2" resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz" @@ -4524,14 +3663,6 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz" integrity sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g== -is-installed-globally@~0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - is-map@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz" @@ -4555,11 +3686,6 @@ is-number@^7.0.0: resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-path-inside@^3.0.2: - version "3.0.3" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-plain-obj@^4.0.0: version "4.1.0" resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz" @@ -4592,11 +3718,6 @@ is-shared-array-buffer@^1.0.4: dependencies: call-bound "^1.0.3" -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - is-string@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz" @@ -4621,16 +3742,6 @@ is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: dependencies: which-typed-array "^1.1.16" -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz" - integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== - -is-unicode-supported@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz" - integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== - is-weakmap@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz" @@ -4661,11 +3772,6 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" - integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== - iterator.prototype@^1.1.5: version "1.1.5" resolved "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz" @@ -4678,19 +3784,6 @@ iterator.prototype@^1.1.5: has-symbols "^1.1.0" set-function-name "^2.0.2" -joi@^18.0.2: - version "18.0.2" - resolved "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz" - integrity sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA== - dependencies: - "@hapi/address" "^5.1.1" - "@hapi/formula" "^3.0.2" - "@hapi/hoek" "^11.0.7" - "@hapi/pinpoint" "^2.0.1" - "@hapi/tlds" "^1.1.1" - "@hapi/topo" "^6.0.2" - "@standard-schema/spec" "^1.0.0" - jose@^4.11.4, jose@^4.15.9: version "4.15.9" resolved "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz" @@ -4713,11 +3806,6 @@ jsbn@1.1.0: resolved "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz" integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz" - integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== - jsesc@^3.0.2, jsesc@~3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz" @@ -4743,21 +3831,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== -json-schema@0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz" - integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== - json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== -json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" - integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== - json2mq@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz" @@ -4777,25 +3855,6 @@ json5@^2.1.2, json5@^2.2.1, json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonfile@^6.0.1: - version "6.2.0" - resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz" - integrity sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -jsprim@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz" - integrity sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ== - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.4.0" - verror "1.10.0" - jss-plugin-camel-case@10.10.0: version "10.10.0" resolved "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz" @@ -4969,11 +4028,6 @@ language-tags@^1.0.9: dependencies: language-subtag-registry "^0.3.20" -lazy-ass@1.6.0, lazy-ass@^1.6.0: - version "1.6.0" - resolved "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz" - integrity sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw== - lazy@^1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz" @@ -4992,19 +4046,21 @@ lines-and-columns@^1.1.6: resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -listr2@^3.8.3: - version "3.14.0" - resolved "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz" - integrity sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g== - dependencies: - cli-truncate "^2.1.0" - colorette "^2.0.16" - log-update "^4.0.0" - p-map "^4.0.0" - rfdc "^1.3.0" - rxjs "^7.5.1" - through "^2.3.8" - wrap-ansi "^7.0.0" +linkinator@^7.6.1: + version "7.6.1" + resolved "https://registry.yarnpkg.com/linkinator/-/linkinator-7.6.1.tgz#b90a46b561eb8e8b4e4270bd558660308fd81781" + integrity sha512-+VzKKZXA8wyCW1x0B0YeQvyuenDu3vZGIWMdzK1yEK0HlBfmEKopt87+Judt4VdPOGRMioKzJK0+X8ifIvOc5Q== + dependencies: + chalk "^5.0.0" + escape-html "^1.0.3" + glob "^13.0.0" + htmlparser2 "^10.0.0" + marked "^17.0.0" + marked-gfm-heading-id "^4.1.3" + meow "^14.0.0" + mime "^4.0.0" + srcset "^5.0.0" + undici "^7.16.0" loader-utils@^2.0.0: version "2.0.4" @@ -5029,13 +4085,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -locate-path@^7.1.0: - version "7.2.0" - resolved "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz" - integrity sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA== - dependencies: - p-locate "^6.0.0" - lodash.debounce@4.0.8, lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz" @@ -5046,39 +4095,16 @@ lodash.merge@^4.6.2: resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash.once@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz" - integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== - lodash.unescape@4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz" integrity sha512-DhhGRshNS1aX6s5YdBE3njCCouPgnG29ebyHvImlZzXZf2SHgt+J08DHgytTPnpywNbO1Y8mNUFyQuIDBq2JZg== -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.23: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.21: version "4.17.23" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== -log-symbols@^4.0.0: - version "4.1.0" - resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz" - integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== - dependencies: - chalk "^4.1.0" - is-unicode-supported "^0.1.0" - -log-update@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz" - integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg== - dependencies: - ansi-escapes "^4.3.0" - cli-cursor "^3.1.0" - slice-ansi "^4.0.0" - wrap-ansi "^6.2.0" - loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" @@ -5093,6 +4119,11 @@ lower-case@^2.0.2: dependencies: tslib "^2.0.3" +lru-cache@^11.0.0: + version "11.3.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.3.5.tgz#29047d348c0b2793e3112a01c739bb7c6d855637" + integrity sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz" @@ -5107,11 +4138,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lru-cache@^7.14.1: - version "7.18.3" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz" - integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== - make-dir@^3.0.2, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz" @@ -5124,10 +4150,22 @@ make-error@^1.1.1: resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== -map-stream@~0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz" - integrity sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g== +marked-gfm-heading-id@^4.1.3: + version "4.1.4" + resolved "https://registry.yarnpkg.com/marked-gfm-heading-id/-/marked-gfm-heading-id-4.1.4.tgz#9f0ee7bace35ce9c90c58700593d6cdbb4618706" + integrity sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA== + dependencies: + github-slugger "^2.0.0" + +marked@^17.0.0: + version "17.0.6" + resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.6.tgz#2a97586a272d3be5880f198e020b74ad27cf86ba" + integrity sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA== + +marked@^18.0.2: + version "18.0.2" + resolved "https://registry.yarnpkg.com/marked/-/marked-18.0.2.tgz#180cb158a2d2dc377821cfb088a10ca1b5630ef0" + integrity sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg== math-intrinsics@^1.1.0: version "1.1.0" @@ -5197,10 +4235,10 @@ memoize-one@^5.1.1: resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== +meow@^14.0.0: + version "14.1.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-14.1.0.tgz#3cd2d16ad534829ab12fcb5010fc2fdb89facd31" + integrity sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw== merge2@^1.3.0: version "1.4.1" @@ -5409,22 +4447,10 @@ micromatch@^4.0.4: braces "^3.0.3" picomatch "^2.3.1" -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12, mime-types@~2.1.19: - version "2.1.35" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mime@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-4.1.0.tgz#ec55df7aa21832a36d44f0bbee5c04639b27802f" + integrity sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw== minimatch@^10.2.2: version "10.2.4" @@ -5440,12 +4466,17 @@ minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^3.1.3: dependencies: brace-expansion "^1.1.7" -minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: +minimist@^1.2.0, minimist@^1.2.6: version "1.2.8" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -mitt@3.0.1, mitt@^3.0.0: +minipass@^7.1.2, minipass@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +mitt@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz" integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== @@ -5494,11 +4525,6 @@ neo-async@^2.6.2: resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -netmask@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz" - integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== - next-auth@4.23.2: version "4.23.2" resolved "https://registry.npmjs.org/next-auth/-/next-auth-4.23.2.tgz" @@ -5555,14 +4581,6 @@ node-exports-info@^1.6.0: object.entries "^1.1.9" semver "^6.3.1" -node-html-parser@5.3.3: - version "5.3.3" - resolved "https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz" - integrity sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw== - dependencies: - css-select "^4.2.1" - he "1.2.0" - node-releases@^2.0.27: version "2.0.27" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz" @@ -5578,13 +4596,6 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -npm-run-path@^4.0.0, npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - nth-check@^2.0.1: version "2.1.1" resolved "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz" @@ -5673,20 +4684,13 @@ oidc-token-hash@^5.0.3: resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz" integrity sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw== -once@^1.3.0, once@^1.3.1, once@^1.4.0: +once@^1.3.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" -onetime@^5.1.0, onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -5714,20 +4718,6 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" -ospath@^1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz" - integrity sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA== - -otplib@^12.0.1: - version "12.0.1" - resolved "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz" - integrity sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg== - dependencies: - "@otplib/core" "^12.0.1" - "@otplib/preset-default" "^12.0.1" - "@otplib/preset-v11" "^12.0.1" - own-keys@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz" @@ -5751,13 +4741,6 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-limit@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz" - integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== - dependencies: - yocto-queue "^1.0.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz" @@ -5772,47 +4755,11 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" -p-locate@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz" - integrity sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw== - dependencies: - p-limit "^4.0.0" - -p-map@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz" - integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== - dependencies: - aggregate-error "^3.0.0" - p-try@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -pac-proxy-agent@^7.1.0: - version "7.2.0" - resolved "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz" - integrity sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA== - dependencies: - "@tootallnate/quickjs-emscripten" "^0.23.0" - agent-base "^7.1.2" - debug "^4.3.4" - get-uri "^6.0.1" - http-proxy-agent "^7.0.0" - https-proxy-agent "^7.0.6" - pac-resolver "^7.0.1" - socks-proxy-agent "^8.0.5" - -pac-resolver@^7.0.1: - version "7.0.1" - resolved "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz" - integrity sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg== - dependencies: - degenerator "^5.0.0" - netmask "^2.0.2" - parent-module@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" @@ -5840,17 +4787,12 @@ path-exists@^4.0.0: resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== -path-exists@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz" - integrity sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ== - path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== -path-key@^3.0.0, path-key@^3.1.0: +path-key@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== @@ -5860,28 +4802,24 @@ path-parse@^1.0.7: resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" + integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -pause-stream@0.0.11: - version "0.0.11" - resolved "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz" - integrity sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A== - dependencies: - through "~2.3" - pend@~1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz" - integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== - picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" @@ -5897,11 +4835,6 @@ picomatch@^4.0.3: resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== -pify@^2.2.0: - version "2.3.0" - resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" - integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== - pkg-dir@^4.1.0: version "4.2.0" resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz" @@ -5968,26 +4901,11 @@ prettier@^3.6.2: resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz" integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== -pretty-bytes@^5.6.0: - version "5.6.0" - resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz" - integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== - pretty-format@^3.8.0: version "3.8.0" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz" integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew== -process@^0.11.10: - version "0.11.10" - resolved "https://registry.npmjs.org/process/-/process-0.11.10.tgz" - integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== - -progress@^2.0.3: - version "2.0.3" - resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - prop-types@^15.0.0, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" @@ -6002,78 +4920,11 @@ property-information@^6.0.0: resolved "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz" integrity sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig== -proxy-agent@^6.4.0: - version "6.5.0" - resolved "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz" - integrity sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A== - dependencies: - agent-base "^7.1.2" - debug "^4.3.4" - http-proxy-agent "^7.0.1" - https-proxy-agent "^7.0.6" - lru-cache "^7.14.1" - pac-proxy-agent "^7.1.0" - proxy-from-env "^1.1.0" - socks-proxy-agent "^8.0.5" - -proxy-from-env@1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz" - integrity sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A== - -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== - -ps-tree@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz" - integrity sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA== - dependencies: - event-stream "=3.3.4" - -pump@^3.0.0: - version "3.0.3" - resolved "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz" - integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - punycode@^2.1.0: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -puppeteer-core@22.15.0: - version "22.15.0" - resolved "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz" - integrity sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA== - dependencies: - "@puppeteer/browsers" "2.3.0" - chromium-bidi "0.6.3" - debug "^4.3.6" - devtools-protocol "0.0.1312386" - ws "^8.18.0" - -puppeteer@^22.15.0: - version "22.15.0" - resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz" - integrity sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q== - dependencies: - "@puppeteer/browsers" "2.3.0" - cosmiconfig "^9.0.0" - devtools-protocol "0.0.1312386" - puppeteer-core "22.15.0" - -qs@~6.14.1: - version "6.14.2" - resolved "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz" - integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q== - dependencies: - side-channel "^1.1.0" - queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" @@ -6341,18 +5192,6 @@ remark-rehype@^10.0.0: mdast-util-to-hast "^12.1.0" unified "^10.0.0" -request-progress@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz" - integrity sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg== - dependencies: - throttleit "^1.0.0" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - resize-observer-polyfill@^1.5.0: version "1.5.1" resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz" @@ -6389,24 +5228,11 @@ resolve@^2.0.0-next.5: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -restore-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz" - integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== - dependencies: - onetime "^5.1.0" - signal-exit "^3.0.2" - reusify@^1.0.4: version "1.1.0" resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== -rfdc@^1.3.0: - version "1.4.1" - resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz" - integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== - rimraf@^2.5.2: version "2.7.1" resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz" @@ -6421,13 +5247,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rxjs@^7.5.1, rxjs@^7.8.2: - version "7.8.2" - resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz" - integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== - dependencies: - tslib "^2.1.0" - sade@^1.7.3: version "1.8.1" resolved "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz" @@ -6446,11 +5265,6 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@^5.0.1, safe-buffer@^5.1.2: - version "5.2.1" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - safe-push-apply@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz" @@ -6468,7 +5282,7 @@ safe-regex-test@^1.0.3, safe-regex-test@^1.1.0: es-errors "^1.3.0" is-regex "^1.2.1" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -6634,11 +5448,6 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.2, signal-exit@^3.0.3: - version "3.0.7" - resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - simple-swizzle@^0.2.2: version "0.2.4" resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz" @@ -6655,34 +5464,11 @@ sirv@^2.0.3: mrmime "^2.0.0" totalist "^3.0.0" -slice-ansi@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz" - integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" - -slice-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz" - integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" - slick-carousel@1.8.1: version "1.8.1" resolved "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz" integrity sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA== -smart-buffer@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz" - integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== - snake-case@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz" @@ -6691,84 +5477,31 @@ snake-case@^3.0.4: dot-case "^3.0.4" tslib "^2.0.3" -socks-proxy-agent@^8.0.5: - version "8.0.5" - resolved "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz" - integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw== - dependencies: - agent-base "^7.1.2" - debug "^4.3.4" - socks "^2.8.3" - -socks@^2.8.3: - version "2.8.7" - resolved "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz" - integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A== - dependencies: - ip-address "^10.0.1" - smart-buffer "^4.2.0" - source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== -source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - space-separated-tokens@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz" integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== -split@0.3: - version "0.3.3" - resolved "https://registry.npmjs.org/split/-/split-0.3.3.tgz" - integrity sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA== - dependencies: - through "2" - sprintf-js@1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz" integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug== -sshpk@^1.18.0: - version "1.18.0" - resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz" - integrity sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ== - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" +srcset@^5.0.0: + version "5.0.3" + resolved "https://registry.yarnpkg.com/srcset/-/srcset-5.0.3.tgz#4d174bcff89a4ac995ec3d23b213bf8661bbc8ca" + integrity sha512-AZswtOXIsu0LeHdo6YY7d0r2pCH2Rl1D8ae1utvXUX4GxG3RggsVUAOFX1r8RI4YHFMYb4g89+UBPBv3mNUU2g== stable-hash@^0.0.5: version "0.0.5" resolved "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz" integrity sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA== -start-server-and-test@^2.0.12: - version "2.1.5" - resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.5.tgz" - integrity sha512-A/SbXpgXE25ScSkpLLqvGvVZT0ykN6+AzS8tVqMBCTxbJy2Nwuen59opT+afalK5aS+AuQmZs0EsLwjnuDN+/g== - dependencies: - arg "^5.0.2" - bluebird "3.7.2" - check-more-types "2.24.0" - debug "4.4.3" - execa "5.1.1" - lazy-ass "1.6.0" - ps-tree "1.2.0" - wait-on "9.0.4" - stop-iteration-iterator@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz" @@ -6777,41 +5510,16 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -stream-combiner@~0.0.4: - version "0.0.4" - resolved "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz" - integrity sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw== - dependencies: - duplexer "~0.1.1" - streamsearch@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -streamx@^2.12.5, streamx@^2.15.0, streamx@^2.21.0: - version "2.23.0" - resolved "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz" - integrity sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg== - dependencies: - events-universal "^1.0.0" - fast-fifo "^1.3.2" - text-decoder "^1.1.0" - string-convert@^0.2.0: version "0.2.1" resolved "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz" integrity sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A== -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string.prototype.includes@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz" @@ -6880,23 +5588,11 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz" integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" @@ -6923,13 +5619,6 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.1.1: - version "8.1.1" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" @@ -6963,40 +5652,6 @@ symbol-observable@^1.2.0: resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== -tar-fs@^3.0.6: - version "3.1.1" - resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz" - integrity sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg== - dependencies: - pump "^3.0.0" - tar-stream "^3.1.5" - optionalDependencies: - bare-fs "^4.0.1" - bare-path "^3.0.0" - -tar-stream@^3.1.5: - version "3.1.7" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz" - integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== - dependencies: - b4a "^1.6.4" - fast-fifo "^1.2.0" - streamx "^2.15.0" - -teex@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz" - integrity sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg== - dependencies: - streamx "^2.12.5" - -text-decoder@^1.1.0: - version "1.2.7" - resolved "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz" - integrity sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ== - dependencies: - b4a "^1.6.4" - text-segmentation@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz" @@ -7014,21 +5669,6 @@ theming@^3.3.0: react-display-name "^0.2.4" tiny-warning "^1.0.2" -thirty-two@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz" - integrity sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA== - -throttleit@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz" - integrity sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ== - -through@2, through@^2.3.8, through@~2.3, through@~2.3.1: - version "2.3.8" - resolved "https://registry.npmjs.org/through/-/through-2.3.8.tgz" - integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== - tiny-invariant@^1.0.6: version "1.3.3" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz" @@ -7047,23 +5687,6 @@ tinyglobby@^0.2.13, tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.3" -tldts-core@^6.1.86: - version "6.1.86" - resolved "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz" - integrity sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA== - -tldts@^6.1.32: - version "6.1.86" - resolved "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz" - integrity sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ== - dependencies: - tldts-core "^6.1.86" - -tmp@~0.2.3: - version "0.2.5" - resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz" - integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== - to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" @@ -7076,18 +5699,6 @@ totalist@^3.0.0: resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== -tough-cookie@^5.0.0: - version "5.1.2" - resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz" - integrity sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A== - dependencies: - tldts "^6.1.32" - -tree-kill@1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz" - integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== - trim-lines@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz" @@ -7132,28 +5743,16 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0: +tslib@^2.0.3, tslib@^2.4.0: version "2.8.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz" - integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== - dependencies: - safe-buffer "^5.0.1" - tween-functions@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz" integrity sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA== -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz" - integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" @@ -7161,16 +5760,6 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== - -type-fest@^0.8.0: - version "0.8.1" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz" - integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== - typed-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz" @@ -7231,19 +5820,16 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -unbzip2-stream@^1.4.3: - version "1.4.3" - resolved "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz" - integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== - dependencies: - buffer "^5.2.1" - through "^2.3.8" - undici-types@~7.16.0: version "7.16.0" resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== +undici@^7.16.0: + version "7.25.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.25.0.tgz#7d72fc429a0421769ca2966fd07cac875c85b781" + integrity sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz" @@ -7323,11 +5909,6 @@ unist-util-visit@^4.0.0: unist-util-is "^5.0.0" unist-util-visit-parents "^5.1.1" -universalify@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz" - integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== - unrs-resolver@^1.6.2: version "1.11.1" resolved "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz" @@ -7355,11 +5936,6 @@ unrs-resolver@^1.6.2: "@unrs/resolver-binding-win32-ia32-msvc" "1.11.1" "@unrs/resolver-binding-win32-x64-msvc" "1.11.1" -untildify@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz" - integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== - update-browserslist-db@^1.2.0: version "1.2.3" resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz" @@ -7375,11 +5951,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -urlpattern-polyfill@10.0.0: - version "10.0.0" - resolved "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz" - integrity sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg== - use-memo-one@^1.1.1: version "1.1.3" resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz" @@ -7412,15 +5983,6 @@ v8-compile-cache-lib@^3.0.1: resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz" integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== -verror@1.10.0: - version "1.10.0" - resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz" - integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw== - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - vfile-location@^4.0.0: version "4.1.0" resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-4.1.0.tgz" @@ -7447,17 +6009,6 @@ vfile@^5.0.0: unist-util-stringify-position "^3.0.0" vfile-message "^3.0.0" -wait-on@9.0.4: - version "9.0.4" - resolved "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz" - integrity sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ== - dependencies: - axios "^1.13.5" - joi "^18.0.2" - lodash "^4.17.23" - minimist "^1.2.8" - rxjs "^7.8.2" - web-namespaces@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz" @@ -7547,24 +6098,6 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -wrap-ansi@^6.2.0: - version "6.2.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz" - integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrappy@1: version "1.0.2" resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" @@ -7575,16 +6108,6 @@ ws@^7.3.1: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -ws@^8.18.0: - version "8.19.0" - resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz" - integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - yallist@^3.0.2: version "3.1.1" resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz" @@ -7595,25 +6118,7 @@ yallist@^4.0.0: resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -yargs@^17.7.2: - version "17.7.2" - resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" - integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== - dependencies: - cliui "^8.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.1.1" - -yauzl@^2.10.0, yauzl@^2.9.2: +yauzl@^2.9.2: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz" integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== @@ -7631,16 +6136,6 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yocto-queue@^1.0.0: - version "1.2.2" - resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz" - integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== - -zod@3.23.8: - version "3.23.8" - resolved "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz" - integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== - zwitch@^2.0.0: version "2.0.4" resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz" From cbd5a90e8d9df306f657331675dfa99ce6d55b7e Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 11 May 2026 18:39:56 +0400 Subject: [PATCH 03/87] chore: sync yarn.lock with package.json Previous commit added axe-core, pixelmatch, pngjs, web-vitals, and yaml to package.json devDependencies but did not run `yarn install`, so the lockfile was missing those entries. CI's `yarn install --frozen-lockfile` correctly rejected the mismatch. Co-Authored-By: Claude Opus 4.7 (1M context) --- yarn.lock | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/yarn.lock b/yarn.lock index 4936d259..5882be75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2068,6 +2068,11 @@ axe-core@^4.10.0: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz" integrity sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A== +axe-core@^4.11.4: + version "4.11.4" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.4.tgz#5b535e381ff1e61ffdd615e5483d16186d3b46a5" + integrity sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA== + axe-core@~4.11.3: version "4.11.3" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.3.tgz#d23cf404edaa5f97bdfd9afed6eea8405e5326e7" @@ -4835,6 +4840,13 @@ picomatch@^4.0.3: resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +pixelmatch@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-7.2.0.tgz#59f4e6faca733f763756d175e2579ed71369fd72" + integrity sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg== + dependencies: + pngjs "^7.0.0" + pkg-dir@^4.1.0: version "4.2.0" resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz" @@ -4856,6 +4868,11 @@ playwright@1.59.1: optionalDependencies: fsevents "2.3.2" +pngjs@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26" + integrity sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow== + possible-typed-array-names@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz" @@ -6014,6 +6031,11 @@ web-namespaces@^2.0.0: resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== +web-vitals@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-5.2.0.tgz#5de39106a652228bb5c40021d62b669d213e6e17" + integrity sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA== + webpack-bundle-analyzer@4.10.1: version "4.10.1" resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz#84b7473b630a7b8c21c741f81d8fe4593208b454" @@ -6118,6 +6140,11 @@ yallist@^4.0.0: resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml@^2.8.4: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== + yauzl@^2.9.2: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz" From ccf9ea2cac30e01e394cfeccd1f369eeb873738b Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 11 May 2026 21:36:04 +0400 Subject: [PATCH 04/87] feat(auth): add magic-link sign-in and Twitter/Yandex/Mail.ru/LinkedIn providers - Magic-link request/consume/complete-registration flow with shared storeJwtSession() so OAuth and magic-link behave identically - Twitter v2, Yandex, Mail.ru providers + LinkedIn email scope - SettingsModal email-edit path for Twitter users (Twitter OAuth v2 doesn't return email) - Header opens LogIn modal on ?authError= so failed sign-ins resurface - i18n copy (en/ru) for the modal, magic-link, and profile-completion - Strip ?token= from URL on magic-link consume to avoid Referer leakage - Playwright spec covering consume + limit-reached flows Co-Authored-By: Claude Opus 4.7 (1M context) --- src/api/auth.ts | 157 +++++++++++++-- src/api/settings.ts | 2 + src/assets/icons/MailRuIcon.tsx | 18 ++ src/assets/icons/XIcon.tsx | 18 ++ src/assets/icons/YandexIcon.tsx | 19 ++ src/components/Header/Header.tsx | 12 ++ src/components/LogIn/LogIn.module.scss | 29 ++- src/components/LogIn/LogIn.tsx | 90 +++++++-- .../MagicLinkEmailForm.module.scss | 136 +++++++++++++ .../MagicLinkEmailForm/MagicLinkEmailForm.tsx | 149 +++++++++++++++ .../LogIn/MagicLinkEmailForm/index.ts | 3 + .../MagicLinkProfileForm.module.scss | 116 +++++++++++ .../MagicLinkProfileForm.tsx | 171 +++++++++++++++++ src/components/MagicLinkProfileForm/index.ts | 3 + .../SettingsModal/SettingsModal.tsx | 46 ++++- src/data/auth/en.ts | 58 ++++++ src/data/auth/index.ts | 7 + src/data/auth/ru.ts | 58 ++++++ src/data/settings/en.ts | 2 + src/data/settings/ru.ts | 2 + src/pages/api/auth/[...nextauth].ts | 57 +++++- src/pages/auth.tsx | 25 ++- src/pages/auth/magic-link.module.scss | 58 ++++++ src/pages/auth/magic-link.tsx | 179 +++++++++++++++++ tests/p1/magic-link.spec.ts | 180 ++++++++++++++++++ 25 files changed, 1557 insertions(+), 38 deletions(-) create mode 100644 src/assets/icons/MailRuIcon.tsx create mode 100644 src/assets/icons/XIcon.tsx create mode 100644 src/assets/icons/YandexIcon.tsx create mode 100644 src/components/LogIn/MagicLinkEmailForm/MagicLinkEmailForm.module.scss create mode 100644 src/components/LogIn/MagicLinkEmailForm/MagicLinkEmailForm.tsx create mode 100644 src/components/LogIn/MagicLinkEmailForm/index.ts create mode 100644 src/components/MagicLinkProfileForm/MagicLinkProfileForm.module.scss create mode 100644 src/components/MagicLinkProfileForm/MagicLinkProfileForm.tsx create mode 100644 src/components/MagicLinkProfileForm/index.ts create mode 100644 src/data/auth/en.ts create mode 100644 src/data/auth/index.ts create mode 100644 src/data/auth/ru.ts create mode 100644 src/pages/auth/magic-link.module.scss create mode 100644 src/pages/auth/magic-link.tsx create mode 100644 tests/p1/magic-link.spec.ts diff --git a/src/api/auth.ts b/src/api/auth.ts index 8d33b3e8..b257248f 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,5 +1,15 @@ import { signOut } from 'next-auth/react'; +type MagicLinkLocale = 'en' | 'ru'; + +export type MagicLinkConsumeData = + | { jwt: string; user: any } + | { requiresProfile: true; registrationToken: string; email: string }; + +export type MagicLinkResult = + | { ok: true; data: T } + | { ok: false; code: string; status: number; message?: string }; + export const logout = async (): Promise => { await signOut({ redirect: false, @@ -16,37 +26,156 @@ export const logout = async (): Promise => { window.location.reload(); }; +// Shared JWT persistence used by both OAuth and magic-link flows. The two flows +// MUST behave identically once a JWT is in hand — keep this the only writer. +export const storeJwtSession = ( + jwt: string, + user: any, + setAccountData: (value: any) => void, + setToken: (value: any) => void, +): void => { + localStorage.setItem('accessToken', jwt); + setAccountData(user); + setToken(jwt); + document.cookie = `accessToken=${encodeURIComponent( + jwt, + )}; path=/; Secure; SameSite=Strict;`; +}; + +export type AuthenticateErrorCode = + | 'EMAIL_TAKEN' + | 'NO_PROVIDER' + | 'AUTH_FAILED' + | 'NETWORK_ERROR'; + +export type AuthenticateResult = + | { ok: true } + | { ok: false; code: AuthenticateErrorCode; message?: string }; + export const authenticate = async ( token: unknown, setAccountData: any, setToken: (value: any) => void, -): Promise => { +): Promise => { try { const provider = localStorage.getItem('provider'); if (!provider) { console.error('No provider found in query'); - return; + return { ok: false, code: 'NO_PROVIDER' }; } const authLink = `${process.env.NEXT_PUBLIC_STRAPI}/api/auth/${provider}/callback?access_token=${token}`; if (typeof token === 'string') { localStorage.setItem('googleToken', token); } - const response = await fetch(authLink).then(resp => resp.json()); - if (response.jwt) { - localStorage.setItem('accessToken', response.jwt); - } + const response = await fetch(authLink); + const body = await response.json().catch(() => null); - if (response.user) { - setAccountData(response.user); - const accessToken = localStorage.getItem('accessToken'); - setToken(accessToken); - document.cookie = `accessToken=${encodeURIComponent( - accessToken, - )}; path=/; Secure; SameSite=Strict;`; + if (response.ok && body?.jwt && body?.user) { + storeJwtSession(body.jwt, body.user, setAccountData, setToken); + return { ok: true }; } - } catch (e) { + + // Strapi users-permissions rejects the callback when the email is already + // bound to a different provider. Surface that distinctly so the UI can + // tell the user to use their original sign-in method. + const rawMessage: string = + body?.error?.message || + body?.message?.[0]?.messages?.[0]?.message || + (typeof body?.message === 'string' ? body.message : '') || + ''; + const code: AuthenticateErrorCode = /taken|exist/i.test(rawMessage) + ? 'EMAIL_TAKEN' + : 'AUTH_FAILED'; + return { ok: false, code, message: rawMessage }; + } catch (e: any) { console.error(e); document.cookie = `accessToken=; path=/; Secure; SameSite=Strict;`; localStorage.removeItem('accessToken'); + return { ok: false, code: 'NETWORK_ERROR', message: e?.message }; + } +}; + +const magicLinkUrl = (path: string) => + `${process.env.NEXT_PUBLIC_STRAPI}/api/auth/magic-link/${path}`; + +const parseError = async ( + response: Response, +): Promise<{ code: string; status: number; message?: string }> => { + let body: any = null; + try { + body = await response.json(); + } catch { + body = null; + } + return { + code: body?.error?.code || body?.code || 'UNKNOWN_ERROR', + status: response.status, + message: body?.error?.message || body?.message, + }; +}; + +export const requestMagicLink = async ({ + email, + locale, +}: { + email: string; + locale: MagicLinkLocale; +}): Promise> => { + try { + const response = await fetch(magicLinkUrl('request'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, locale }), + }); + if (!response.ok) { + return { ok: false, ...(await parseError(response)) }; + } + return { ok: true, data: null }; + } catch (e: any) { + return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message }; + } +}; + +export const consumeMagicLink = async ( + token: string, +): Promise> => { + try { + const response = await fetch(magicLinkUrl('consume'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!response.ok) { + return { ok: false, ...(await parseError(response)) }; + } + const data = await response.json(); + return { ok: true, data }; + } catch (e: any) { + return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message }; + } +}; + +export const completeMagicLinkRegistration = async ({ + registrationToken, + name, + surname, +}: { + registrationToken: string; + name: string; + surname: string; +}): Promise> => { + try { + const response = await fetch(magicLinkUrl('complete-registration'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ registrationToken, name, surname }), + }); + if (!response.ok) { + return { ok: false, ...(await parseError(response)) }; + } + const data = await response.json(); + return { ok: true, data }; + } catch (e: any) { + return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message }; } }; diff --git a/src/api/settings.ts b/src/api/settings.ts index 8901b625..65c7a295 100644 --- a/src/api/settings.ts +++ b/src/api/settings.ts @@ -8,6 +8,7 @@ export const userInfoUpdate = async ( gender?: string, npsScore?: number, isModalShown?: boolean, + email?: string, ) => { const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/user/me`; const body = JSON.stringify({ @@ -19,6 +20,7 @@ export const userInfoUpdate = async ( ...(gender !== undefined && { gender }), ...(npsScore !== undefined && { npsScore }), ...(isModalShown !== undefined && { isModalShown }), + ...(email !== undefined && { email }), }); const headers = { 'Content-Type': 'application/json', diff --git a/src/assets/icons/MailRuIcon.tsx b/src/assets/icons/MailRuIcon.tsx new file mode 100644 index 00000000..f8e82698 --- /dev/null +++ b/src/assets/icons/MailRuIcon.tsx @@ -0,0 +1,18 @@ +const MailRuIcon = () => { + return ( + + + + ); +}; + +export default MailRuIcon; diff --git a/src/assets/icons/XIcon.tsx b/src/assets/icons/XIcon.tsx new file mode 100644 index 00000000..6d80e1e8 --- /dev/null +++ b/src/assets/icons/XIcon.tsx @@ -0,0 +1,18 @@ +const XIcon = () => { + return ( + + + + ); +}; + +export default XIcon; diff --git a/src/assets/icons/YandexIcon.tsx b/src/assets/icons/YandexIcon.tsx new file mode 100644 index 00000000..78906aa7 --- /dev/null +++ b/src/assets/icons/YandexIcon.tsx @@ -0,0 +1,19 @@ +const YandexIcon = () => { + return ( + + + + + ); +}; + +export default YandexIcon; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 697734e2..02a699a6 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -52,6 +52,12 @@ const Header: FC = () => { setToken(storedToken); }, []); + useEffect(() => { + if (router.query.authError) { + setOpenLogin(true); + } + }, [router.query.authError]); + const handleToggleTheme = useCallback(() => { toggleIsDarkTheme(); }, []); @@ -70,6 +76,7 @@ const Header: FC = () => { isEmailPublic: string, isLinkedinPublic: string, title?: string, + email?: string, ) => { const mailIsPublic = isEmailPublic === 'everyone'; const linkedInIsPublic = isLinkedinPublic === 'everyone'; @@ -81,6 +88,10 @@ const Header: FC = () => { mailIsPublic, linkedInIsPublic, title, + undefined, + undefined, + undefined, + email, ); const data = await getMyInfo(); @@ -257,6 +268,7 @@ const Header: FC = () => { mailStatus={accountData?.publicEmail} linkedin={accountData?.linkedIn} linkedinStatus={accountData?.publicLinkedin} + provider={accountData?.provider} handleSaveClick={handleSaveClick} setUsernameIsTakenError={setUsernameIsTakenError} usernameIsTakenError={usernameIsTakenError} diff --git a/src/components/LogIn/LogIn.module.scss b/src/components/LogIn/LogIn.module.scss index 0ba3e775..9ad2697a 100644 --- a/src/components/LogIn/LogIn.module.scss +++ b/src/components/LogIn/LogIn.module.scss @@ -15,6 +15,18 @@ padding: 0; margin: 0; } + .errorBanner { + width: 100%; + margin: 0; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #f5c2c7; + background: #fde8e8; + color: #b42318; + font-size: 14px; + line-height: 1.4; + text-align: center; + } .btnWrapper { display: flex; flex-direction: column; @@ -44,7 +56,22 @@ .discordBtn { background-color: #5865f2; color: white; - margin-bottom: 114px; + } + .linkedinBtn { + background-color: #0177b5; + color: white; + } + .twitterBtn { + background-color: #000; + color: white; + } + .mailruBtn { + background-color: #168de2; + color: white; + } + .yandexBtn { + background-color: #fc3f1d; + color: white; } } } diff --git a/src/components/LogIn/LogIn.tsx b/src/components/LogIn/LogIn.tsx index f1cb1a9e..4af3d6d6 100644 --- a/src/components/LogIn/LogIn.tsx +++ b/src/components/LogIn/LogIn.tsx @@ -5,30 +5,60 @@ import React, { FC, useContext } from 'react'; import { setRedirectCookie } from '@lib/cookies'; +import auth from '@data/auth'; + import DiscordIcon from '@icons/DiscordIcon'; import GoogleIcon from '@icons/GoogleIcon'; +import LinkedInIcon from '@icons/LinkedInIcon'; +import MailRuIcon from '@icons/MailRuIcon'; +import XIcon from '@icons/XIcon'; +import YandexIcon from '@icons/YandexIcon'; import { GlobalContext } from '@components/Context/GlobalContext'; import Heading from '@components/Heading'; import Modal from '@components/Modal'; import type { LogInProps } from './LogIn.types'; +import MagicLinkEmailForm from './MagicLinkEmailForm'; import styles from './LogIn.module.scss'; -const LogIn: FC = ({ setShowLogIn, source }) => { +const LogIn: FC = ({ setShowLogIn }) => { const { accountData } = useContext(GlobalContext); const router = useRouter(); const { data: session } = useSession(); + const locale = router.locale === 'ru' ? 'ru' : 'en'; + const copy = auth[locale].logIn; + + const queryAuthError = Array.isArray(router.query.authError) + ? router.query.authError[0] + : router.query.authError; + const rawAuthError = queryAuthError ? String(queryAuthError) : undefined; + const errorBannerMessage = (() => { + if (!rawAuthError) return undefined; + if (rawAuthError === 'EMAIL_TAKEN') return copy.errors.emailTaken; + if ( + rawAuthError === 'AUTH_FAILED' || + rawAuthError === 'NO_PROVIDER' || + rawAuthError === 'NETWORK_ERROR' + ) { + return copy.errors.generic; + } + // Free-text messages (e.g. from MagicLinkProfileForm) come through as-is. + return rawAuthError; + })(); const handleClose = () => { setShowLogIn(false); + if (router.query.authError) { + const { authError, ...rest } = router.query; + router.replace({ pathname: router.pathname, query: rest }, undefined, { + shallow: true, + }); + } }; - const handleProviderSignIn = async ( - provider: string, - logInSource: string, - ) => { + const handleProviderSignIn = async (provider: string) => { const returnTo = router.asPath; setRedirectCookie(returnTo); @@ -53,29 +83,67 @@ const LogIn: FC = ({ setShowLogIn, source }) => {
-

Choose one of the options below

+

{copy.subtitle}

+ {errorBannerMessage && ( +

+ {errorBannerMessage} +

+ )}
+ + + +
+
); diff --git a/src/components/LogIn/MagicLinkEmailForm/MagicLinkEmailForm.module.scss b/src/components/LogIn/MagicLinkEmailForm/MagicLinkEmailForm.module.scss new file mode 100644 index 00000000..028c8a5c --- /dev/null +++ b/src/components/LogIn/MagicLinkEmailForm/MagicLinkEmailForm.module.scss @@ -0,0 +1,136 @@ +.wrapper { + display: flex; + flex-direction: column; + width: 100%; + gap: 1rem; + align-items: stretch; +} + +.divider { + display: flex; + align-items: center; + gap: 0.75rem; + font-family: 'Jost', system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: #8a8480; + margin: 0; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: #c8c0b5; + } +} + +.form { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.fieldGroup { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.label { + font-family: 'Jost', system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: #1c1c1a; +} + +.input { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 1rem; + color: #1c1c1a; + background: #faf6ef; + border: 1px solid #ddd7ce; + padding: 0.75rem 1rem; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: #b83232; + } +} + +.submit { + font-family: 'Jost', system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: #1c1c1a; + background: transparent; + border: 1px solid #c8c0b5; + padding: 0.75rem 1.5rem; + cursor: pointer; + transition: + border-color 0.2s ease, + color 0.2s ease; + + &:hover:not(:disabled) { + border-color: #b83232; + color: #b83232; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.error { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 0.875rem; + color: #b83232; + margin: 0; +} + +.confirmation { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + border: 1px solid #ddd7ce; + background: #f5f1ea; + text-align: center; +} + +.confirmationHeading { + font-family: 'Aboreto', Georgia, serif; + font-size: 1.125rem; + font-weight: 400; + letter-spacing: 0.08em; + color: #1c1c1a; + margin: 0; +} + +.confirmationBody { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 0.875rem; + line-height: 1.6; + color: #5c5650; + margin: 0; +} + +.banner { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 0.875rem; + line-height: 1.6; + color: #5c5650; + padding: 0.75rem 1rem; + border: 1px solid #ddd7ce; + background: #f5f1ea; + margin: 0; + text-align: center; +} diff --git a/src/components/LogIn/MagicLinkEmailForm/MagicLinkEmailForm.tsx b/src/components/LogIn/MagicLinkEmailForm/MagicLinkEmailForm.tsx new file mode 100644 index 00000000..a44b2832 --- /dev/null +++ b/src/components/LogIn/MagicLinkEmailForm/MagicLinkEmailForm.tsx @@ -0,0 +1,149 @@ +import cn from 'classnames'; +import { useRouter } from 'next/router'; +import { FC, FormEvent, useState } from 'react'; + +import { requestMagicLink } from '@api/auth'; + +import auth from '@data/auth'; + +import styles from './MagicLinkEmailForm.module.scss'; + +interface MagicLinkEmailFormProps { + className?: string; + initialError?: string; +} + +type Status = 'idle' | 'submitting' | 'sent' | 'limitReached' | 'invalidEmail'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const MagicLinkEmailForm: FC = ({ + className, + initialError, +}) => { + const router = useRouter(); + const locale = router.locale === 'ru' ? 'ru' : 'en'; + const copy = auth[locale].magicLinkForm; + + const [email, setEmail] = useState(''); + const [status, setStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState( + initialError || null, + ); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const trimmed = email.trim(); + + if (!EMAIL_REGEX.test(trimmed)) { + setStatus('invalidEmail'); + setErrorMessage(copy.invalidEmail); + return; + } + + setStatus('submitting'); + setErrorMessage(null); + + const result = await requestMagicLink({ email: trimmed, locale }); + + if (result.ok) { + setStatus('sent'); + return; + } + + // strict mode is off in this project, so the union doesn't narrow on `ok`. + // `'code' in result` narrows to the failure variant. + if ('code' in result) { + if (result.status === 429 || result.code === 'LIMIT_REACHED') { + setStatus('limitReached'); + return; + } + + if (result.status === 400) { + setStatus('invalidEmail'); + setErrorMessage(copy.invalidEmail); + return; + } + } + + // Any other error: surface as inline validation so the user can retry. + setStatus('invalidEmail'); + setErrorMessage(copy.invalidEmail); + }; + + if (status === 'sent') { + return ( +
+

{copy.sentHeading}

+

{copy.sentBody}

+
+ ); + } + + if (status === 'limitReached') { + return ( +

+ {copy.limitReached} +

+ ); + } + + return ( +
+

{copy.divider}

+
+
+ + { + setEmail(e.target.value); + if (status === 'invalidEmail') { + setStatus('idle'); + setErrorMessage(null); + } + }} + placeholder={copy.emailPlaceholder} + required + data-cy="magic-link-email-input" + /> +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + +
+
+ ); +}; + +export default MagicLinkEmailForm; diff --git a/src/components/LogIn/MagicLinkEmailForm/index.ts b/src/components/LogIn/MagicLinkEmailForm/index.ts new file mode 100644 index 00000000..07f477c3 --- /dev/null +++ b/src/components/LogIn/MagicLinkEmailForm/index.ts @@ -0,0 +1,3 @@ +import MagicLinkEmailForm from './MagicLinkEmailForm'; + +export default MagicLinkEmailForm; diff --git a/src/components/MagicLinkProfileForm/MagicLinkProfileForm.module.scss b/src/components/MagicLinkProfileForm/MagicLinkProfileForm.module.scss new file mode 100644 index 00000000..df960358 --- /dev/null +++ b/src/components/MagicLinkProfileForm/MagicLinkProfileForm.module.scss @@ -0,0 +1,116 @@ +.form { + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 100%; + max-width: 420px; + margin: 0 auto; + padding: 2rem 1.5rem; + background: #f5f1ea; + border: 1px solid #ddd7ce; +} + +.heading { + font-family: 'Aboreto', Georgia, serif; + font-size: 1.5rem; + font-weight: 400; + letter-spacing: 0.08em; + color: #1c1c1a; + margin: 0; + text-align: center; +} + +.body { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 1rem; + line-height: 1.7; + color: #5c5650; + margin: 0; + text-align: center; +} + +.email { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 1rem; + color: #1c1c1a; + text-align: center; + word-break: break-all; +} + +.fieldGroup { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.label { + font-family: 'Jost', system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: #1c1c1a; +} + +.input { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 1rem; + color: #1c1c1a; + background: #faf6ef; + border: 1px solid #ddd7ce; + padding: 0.75rem 1rem; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: #b83232; + } +} + +.submit { + font-family: 'Jost', system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: #1c1c1a; + background: transparent; + border: 1px solid #c8c0b5; + padding: 0.75rem 1.5rem; + cursor: pointer; + transition: + border-color 0.2s ease, + color 0.2s ease; + + &:hover:not(:disabled) { + border-color: #b83232; + color: #b83232; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.error { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 0.875rem; + color: #b83232; + margin: 0; + text-align: center; +} + +.requestNewLink { + font-family: 'Jost', system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: #b83232; + background: transparent; + border: 1px solid #b83232; + padding: 0.75rem 1.5rem; + cursor: pointer; + margin-top: 0.5rem; +} diff --git a/src/components/MagicLinkProfileForm/MagicLinkProfileForm.tsx b/src/components/MagicLinkProfileForm/MagicLinkProfileForm.tsx new file mode 100644 index 00000000..4702a4ef --- /dev/null +++ b/src/components/MagicLinkProfileForm/MagicLinkProfileForm.tsx @@ -0,0 +1,171 @@ +import cn from 'classnames'; +import { useRouter } from 'next/router'; +import { FC, FormEvent, useContext, useState } from 'react'; + +import { deleteRedirectCookie, getRedirectCookie } from '@lib/cookies'; + +import { completeMagicLinkRegistration, storeJwtSession } from '@api/auth'; + +import auth from '@data/auth'; + +import { GlobalContext } from '@components/Context/GlobalContext'; + +import styles from './MagicLinkProfileForm.module.scss'; + +interface MagicLinkProfileFormProps { + registrationToken: string; + email: string; + className?: string; +} + +const MagicLinkProfileForm: FC = ({ + registrationToken, + email, + className, +}) => { + const { setAccountData, setToken } = useContext(GlobalContext) as any; + const router = useRouter(); + const locale = router.locale === 'ru' ? 'ru' : 'en'; + const copy = auth[locale].profileForm; + const consumeCopy = auth[locale].consumePage; + + const [name, setName] = useState(''); + const [surname, setSurname] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [tokenInvalidated, setTokenInvalidated] = useState(false); + + const trimmedName = name.trim(); + const trimmedSurname = surname.trim(); + const isDisabled = !trimmedName || !trimmedSurname || submitting; + + const handleRequestNew = () => { + router.push('/'); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (isDisabled) return; + setError(null); + setSubmitting(true); + + const result = await completeMagicLinkRegistration({ + registrationToken, + name: trimmedName, + surname: trimmedSurname, + }); + + if (result.ok) { + const { jwt, user } = result.data; + storeJwtSession(jwt, user, setAccountData, setToken); + const redirectUrl = getRedirectCookie() || '/uxcore'; + deleteRedirectCookie(); + window.location.href = redirectUrl; + return; + } + + setSubmitting(false); + + // strict mode is off, so the union doesn't narrow on `ok`. `'code' in + // result` narrows to the failure variant. + if ('code' in result) { + if (result.code === 'INVALID_REGISTRATION_TOKEN') { + setTokenInvalidated(true); + return; + } + if (result.status === 409 || result.code === 'EMAIL_ALREADY_REGISTERED') { + // Send the user back to the LogIn modal entry. Encode the localized + // "account already exists" message in the URL so the modal surfaces + // it on next open. + const message = encodeURIComponent(consumeCopy.accountAlreadyExists); + router.push(`/?authError=${message}`); + return; + } + } + setError(copy.invalidProfile); + }; + + if (tokenInvalidated) { + return ( +
+

{copy.invalidRegistrationToken}

+ +
+ ); + } + + return ( +
+

{copy.heading}

+

+ {copy.body} {email} +

+ +
+ + setName(e.target.value)} + placeholder={copy.namePlaceholder} + autoComplete="given-name" + required + data-cy="magic-link-profile-name" + /> +
+ +
+ + setSurname(e.target.value)} + placeholder={copy.surnamePlaceholder} + autoComplete="family-name" + required + data-cy="magic-link-profile-surname" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +}; + +export default MagicLinkProfileForm; diff --git a/src/components/MagicLinkProfileForm/index.ts b/src/components/MagicLinkProfileForm/index.ts new file mode 100644 index 00000000..ad014606 --- /dev/null +++ b/src/components/MagicLinkProfileForm/index.ts @@ -0,0 +1,3 @@ +import MagicLinkProfileForm from './MagicLinkProfileForm'; + +export default MagicLinkProfileForm; diff --git a/src/components/SettingsModal/SettingsModal.tsx b/src/components/SettingsModal/SettingsModal.tsx index 455a4545..a56c0704 100644 --- a/src/components/SettingsModal/SettingsModal.tsx +++ b/src/components/SettingsModal/SettingsModal.tsx @@ -30,6 +30,7 @@ type SettingsModalProps = { linkedinStatus?: boolean; changeTitlePermission?: boolean; usernameIsTakenError?: string; + provider?: string; setUsernameIsTakenError: (usernameIsTakenError: string) => void; setChangedTitle: (selected: boolean) => void; handleSaveClick: ( @@ -38,6 +39,7 @@ type SettingsModalProps = { isEmailPublic: string, isLinkedinPublic: string, title?: string, + email?: string, ) => void; }; @@ -54,10 +56,13 @@ const SettingsModal: FC = ({ defaultSelectedTitle, changeTitlePermission, setChangedTitle, + provider, }) => { const router = useRouter(); const { locale } = router as TRouter; const currentLocale = locale === 'ru' ? 'ru' : 'en'; + const isTwitterUser = provider === 'twitter'; + const initialEmail = isValidEmail(currentEmail) ? currentEmail : ''; const [isEmailPublic, setIsEmailPublic] = useState( !!mailStatus ? 'everyone' : 'onlyMe', ); @@ -66,8 +71,13 @@ const SettingsModal: FC = ({ ); const [username, setUsername] = useState(currentUsername); const [linkedInUrl, setLinkedInUrl] = useState(linkedin); + const [emailValue, setEmailValue] = useState(initialEmail); const [selectedTitle] = useState(defaultSelectedTitle); - const [isValid, setIsValid] = useState({ username: true, linkedin: true }); + const [isValid, setIsValid] = useState({ + username: true, + linkedin: true, + email: true, + }); const { title, @@ -81,6 +91,8 @@ const SettingsModal: FC = ({ cancelBtn, usernameValidationMessage, invalidLinkedIn, + invalidEmail, + emailPlaceholder, } = settingsData[currentLocale]; const closeSettings = () => { @@ -100,7 +112,7 @@ const SettingsModal: FC = ({ }; const handleValidation = useCallback( - (value: boolean, type: 'username' | 'linkedin') => { + (value: boolean, type: 'username' | 'linkedin' | 'email') => { setIsValid(prevIsValid => ({ ...prevIsValid, [type]: value, @@ -110,13 +122,14 @@ const SettingsModal: FC = ({ ); const handleSave = () => { - if (isValid.username && isValid.linkedin) { + if (isValid.username && isValid.linkedin && isValid.email) { handleSaveClick( username, linkedInUrl, isEmailPublic, isLinkedinPublic, changeTitlePermission ? selectedTitle : undefined, + isTwitterUser && emailValue !== initialEmail ? emailValue : undefined, ); } setChangedTitle && setChangedTitle(true); @@ -157,10 +170,22 @@ const SettingsModal: FC = ({
{email} - + {isTwitterUser ? ( + setEmailValue(value)} + validationFunction={isValidEmail} + isValidCallback={v => handleValidation(v, 'email')} + showMessage={!isValid.email} + errorMessage={invalidEmail} + /> + ) : ( + + )}
= ({ }} variant="black" className={styles.SaveBtn} - disabled={!isValid.username || !isValid.linkedin || !username} + disabled={ + !isValid.username || + !isValid.linkedin || + !isValid.email || + !username + } />
diff --git a/src/data/auth/en.ts b/src/data/auth/en.ts new file mode 100644 index 00000000..c519a6d7 --- /dev/null +++ b/src/data/auth/en.ts @@ -0,0 +1,58 @@ +const en = { + logIn: { + heading: 'Log In', + subtitle: 'Choose one of the options below', + google: 'Continue with Google', + discord: 'Continue with Discord', + linkedin: 'Continue with LinkedIn', + twitter: 'Continue with X', + mailru: 'Continue with Mail.ru', + yandex: 'Continue with Yandex', + errors: { + emailTaken: + 'An account with this email already exists. Try a different email or sign in with your original provider.', + generic: 'Sign-in failed. Please try again.', + }, + }, + magicLinkForm: { + divider: 'or sign in with email', + emailLabel: 'Email', + emailPlaceholder: 'you@example.com', + submit: 'Send magic link', + submitting: 'Sending…', + invalidEmail: 'Please enter a valid email address.', + sentHeading: 'Check your inbox', + sentBody: + 'If an account is associated with that email, we’ve sent a sign-in link. The link expires in 15 minutes.', + limitReached: + 'Magic link sign-in is temporarily unavailable. Please sign in with Google or Discord.', + }, + consumePage: { + invalidLink: { + title: 'This link is no longer valid', + body: 'Magic links expire after 15 minutes and can only be used once. Please request a new one.', + cta: 'Request a new link', + }, + blocked: { + title: 'Sign-in unavailable', + }, + accountAlreadyExists: + 'An account with this email already exists. Please sign in instead.', + }, + profileForm: { + heading: 'Finish setting up your account', + body: 'We just need a name to associate with', + nameLabel: 'First name', + namePlaceholder: 'Your first name', + surnameLabel: 'Last name', + surnamePlaceholder: 'Your last name', + submit: 'Continue', + submitting: 'Creating account…', + invalidProfile: 'Please double-check the name fields and try again.', + invalidRegistrationToken: + 'Your registration session has expired. Please request a new sign-in link.', + requestNewLink: 'Request a new link', + }, +}; + +export default en; diff --git a/src/data/auth/index.ts b/src/data/auth/index.ts new file mode 100644 index 00000000..108250d8 --- /dev/null +++ b/src/data/auth/index.ts @@ -0,0 +1,7 @@ +import en from './en'; +import ru from './ru'; + +export default { en, ru } as { + en: typeof en; + ru: typeof ru; +}; diff --git a/src/data/auth/ru.ts b/src/data/auth/ru.ts new file mode 100644 index 00000000..66023f4e --- /dev/null +++ b/src/data/auth/ru.ts @@ -0,0 +1,58 @@ +const ru = { + logIn: { + heading: 'Вход', + subtitle: 'Выберите один из способов ниже', + google: 'Войти через Google', + discord: 'Войти через Discord', + linkedin: 'Войти через LinkedIn', + twitter: 'Войти через X', + mailru: 'Войти через Mail.ru', + yandex: 'Войти через Yandex', + errors: { + emailTaken: + 'Аккаунт с этим email уже существует. Попробуйте другой email или войдите тем способом, которым регистрировались.', + generic: 'Не удалось войти. Попробуйте ещё раз.', + }, + }, + magicLinkForm: { + divider: 'или войти по email', + emailLabel: 'Email', + emailPlaceholder: 'you@example.com', + submit: 'Отправить ссылку', + submitting: 'Отправляем…', + invalidEmail: 'Введите корректный email.', + sentHeading: 'Проверьте почту', + sentBody: + 'Если аккаунт с таким email существует, мы отправили ссылку для входа. Ссылка действительна 15 минут.', + limitReached: + 'Вход по ссылке временно недоступен. Пожалуйста, войдите через Google или Discord.', + }, + consumePage: { + invalidLink: { + title: 'Ссылка больше недействительна', + body: 'Magic-ссылка действует 15 минут и может быть использована один раз. Запросите новую.', + cta: 'Запросить новую ссылку', + }, + blocked: { + title: 'Вход недоступен', + }, + accountAlreadyExists: + 'Аккаунт с таким email уже существует. Пожалуйста, войдите.', + }, + profileForm: { + heading: 'Завершите настройку аккаунта', + body: 'Укажите имя для аккаунта', + nameLabel: 'Имя', + namePlaceholder: 'Ваше имя', + surnameLabel: 'Фамилия', + surnamePlaceholder: 'Ваша фамилия', + submit: 'Продолжить', + submitting: 'Создаём аккаунт…', + invalidProfile: 'Проверьте поля имени и попробуйте снова.', + invalidRegistrationToken: + 'Срок регистрации истёк. Пожалуйста, запросите новую ссылку для входа.', + requestNewLink: 'Запросить новую ссылку', + }, +}; + +export default ru; diff --git a/src/data/settings/en.ts b/src/data/settings/en.ts index 3795ccec..fb381ea9 100644 --- a/src/data/settings/en.ts +++ b/src/data/settings/en.ts @@ -12,6 +12,8 @@ const en = { usernameValidationMessage: 'Must be 6-30 characters. No special characters allowed.', invalidLinkedIn: 'Please enter a valid LinkedIn URL.', + invalidEmail: 'Please enter a valid email address.', + emailPlaceholder: 'you@example.com', }; export default en; diff --git a/src/data/settings/ru.ts b/src/data/settings/ru.ts index 13f5280b..b98f9947 100644 --- a/src/data/settings/ru.ts +++ b/src/data/settings/ru.ts @@ -12,6 +12,8 @@ const ru = { usernameValidationMessage: 'Разрешено 6-30 символов, без специальных символов.', invalidLinkedIn: 'Не верный формат ссылки', + invalidEmail: 'Введите корректный email.', + emailPlaceholder: 'you@example.com', }; export default ru; diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts index 3b447845..2f0456bd 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/pages/api/auth/[...nextauth].ts @@ -2,6 +2,8 @@ import NextAuth from 'next-auth'; import DiscordProvider from 'next-auth/providers/discord'; import GoogleProvider from 'next-auth/providers/google'; import LinkedInProvider from 'next-auth/providers/linkedin'; +import TwitterProvider from 'next-auth/providers/twitter'; +import YandexProvider from 'next-auth/providers/yandex'; const GOOGLE_AUTHORIZATION_URL = 'https://accounts.google.com/o/oauth2/v2/auth?' + @@ -31,6 +33,9 @@ async function refreshAccessToken(token) { url = 'https://www.linkedin.com/oauth/v2/accessToken'; searchParams.append('client_id', process.env.LINKEDIN_CLIENT_ID); searchParams.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET); + } else if (token.provider === 'twitter') { + url = 'https://api.twitter.com/2/oauth2/token'; + searchParams.append('client_id', process.env.TWITTER_CLIENT_ID); } else { return token; } @@ -38,8 +43,18 @@ async function refreshAccessToken(token) { searchParams.append('grant_type', 'refresh_token'); searchParams.append('refresh_token', token.refreshToken); + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + if (token.provider === 'twitter') { + const basic = Buffer.from( + `${process.env.TWITTER_CLIENT_ID}:${process.env.TWITTER_CLIENT_SECRET}`, + ).toString('base64'); + headers.Authorization = `Basic ${basic}`; + } + const response = await fetch(url, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers, method: 'POST', body: searchParams.toString(), }); @@ -95,6 +110,46 @@ export default NextAuth({ clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET, }), + { + id: 'mailru', + name: 'Mail.ru', + type: 'oauth', + authorization: { + url: 'https://oauth.mail.ru/login', + params: { scope: 'userinfo', response_type: 'code' }, + }, + token: 'https://oauth.mail.ru/token', + userinfo: 'https://oauth.mail.ru/userinfo', + clientId: process.env.MAILRU_CLIENT_ID, + clientSecret: process.env.MAILRU_CLIENT_SECRET, + client: { token_endpoint_auth_method: 'client_secret_post' }, + profile(profile) { + return { + id: String(profile.id ?? profile.sub), + name: + profile.name || + `${profile.first_name ?? ''} ${profile.last_name ?? ''}`.trim() || + profile.email, + email: profile.email, + image: profile.image ?? null, + }; + }, + }, + TwitterProvider({ + clientId: process.env.TWITTER_CLIENT_ID || '', + clientSecret: process.env.TWITTER_CLIENT_SECRET || '', + version: '2.0', + authorization: { + params: { scope: 'users.read tweet.read offline.access' }, + }, + }), + YandexProvider({ + clientId: process.env.YANDEX_CLIENT_ID, + clientSecret: process.env.YANDEX_CLIENT_SECRET, + authorization: { + params: { scope: 'login:info login:email login:avatar' }, + }, + }), ], callbacks: { async jwt({ token, user, account }) { diff --git a/src/pages/auth.tsx b/src/pages/auth.tsx index 84dbaf30..cb70981d 100644 --- a/src/pages/auth.tsx +++ b/src/pages/auth.tsx @@ -1,5 +1,5 @@ import { useRouter } from 'next/router'; -import { signIn, useSession } from 'next-auth/react'; +import { signIn, signOut, useSession } from 'next-auth/react'; import { FC, useContext, useEffect } from 'react'; import { deleteRedirectCookie, getRedirectCookie } from '@lib/cookies'; @@ -56,8 +56,27 @@ const Auth: FC = () => { } if (status === 'authenticated' && !localStorage.getItem('accessToken')) { - //@ts-ignore - await authenticate(data.accessToken, setAccountData, setToken); + const result = await authenticate( + //@ts-ignore + data.accessToken, + setAccountData, + setToken, + ); + + // strict mode is off in this project, so the union doesn't narrow on + // `ok`. `'code' in result` narrows to the failure variant. + if ('code' in result) { + // Tear down the next-auth session so the user can retry cleanly, + // then bounce them back with an error code the LogIn modal renders. + await signOut({ redirect: false }); + localStorage.removeItem('provider'); + const returnTo = getRedirectCookie() || '/'; + deleteRedirectCookie(); + const separator = returnTo.includes('?') ? '&' : '?'; + window.location.href = `${returnTo}${separator}authError=${result.code}`; + return; + } + const redirectUrl = getRedirectCookie() || '/uxcore'; deleteRedirectCookie(); window.location.href = redirectUrl; diff --git a/src/pages/auth/magic-link.module.scss b/src/pages/auth/magic-link.module.scss new file mode 100644 index 00000000..066403b7 --- /dev/null +++ b/src/pages/auth/magic-link.module.scss @@ -0,0 +1,58 @@ +.page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 4rem 1.5rem; +} + +.errorCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.25rem; + max-width: 480px; + width: 100%; + padding: 2.5rem 2rem; + text-align: center; + background: #f5f1ea; + border: 1px solid #ddd7ce; +} + +.title { + font-family: 'Aboreto', Georgia, serif; + font-size: 1.5rem; + font-weight: 400; + letter-spacing: 0.08em; + color: #1c1c1a; + margin: 0; +} + +.body { + font-family: 'Source Serif 4', Georgia, serif; + font-size: 1rem; + line-height: 1.7; + color: #5c5650; + margin: 0; +} + +.cta { + font-family: 'Jost', system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: #b83232; + background: transparent; + border: 1px solid #b83232; + padding: 0.75rem 1.5rem; + cursor: pointer; + transition: + background 0.2s ease, + color 0.2s ease; + + &:hover { + background: #b83232; + color: #faf6ef; + } +} diff --git a/src/pages/auth/magic-link.tsx b/src/pages/auth/magic-link.tsx new file mode 100644 index 00000000..dc00570d --- /dev/null +++ b/src/pages/auth/magic-link.tsx @@ -0,0 +1,179 @@ +import { useRouter } from 'next/router'; +import { signOut, useSession } from 'next-auth/react'; +import { FC, useContext, useEffect, useRef, useState } from 'react'; + +import { deleteRedirectCookie, getRedirectCookie } from '@lib/cookies'; + +import { consumeMagicLink, storeJwtSession } from '@api/auth'; + +import auth from '@data/auth'; + +import { GlobalContext } from '@components/Context/GlobalContext'; +import MagicLinkProfileForm from '@components/MagicLinkProfileForm'; +import Spinner from '@components/Spinner'; + +import styles from './magic-link.module.scss'; + +type ConsumeState = + | { kind: 'loading' } + | { kind: 'profile'; registrationToken: string; email: string } + | { kind: 'invalidLink' } + | { kind: 'blocked'; message?: string }; + +const tearDownExistingSession = async () => { + await signOut({ redirect: false }); + localStorage.removeItem('accessToken'); + localStorage.removeItem('googleToken'); + localStorage.removeItem('provider'); + document.cookie = + 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT; Secure; SameSite=Strict;'; +}; + +const MagicLinkConsumePage: FC = () => { + const router = useRouter(); + const { setAccountData, setToken } = useContext(GlobalContext) as any; + const session = useSession(); + + const locale = router.locale === 'ru' ? 'ru' : 'en'; + const copy = auth[locale].consumePage; + + const [state, setState] = useState({ kind: 'loading' }); + const startedRef = useRef(false); + + useEffect(() => { + if (!router.isReady) return; + if (startedRef.current) return; + // Wait for NextAuth to settle so we can decide whether to tear down. + if (session.status === 'loading') return; + + startedRef.current = true; + + const token = router.query.token; + const tokenStr = Array.isArray(token) ? token[0] : token; + + if (!tokenStr) { + router.replace('/'); + return; + } + + // Strip ?token= from the URL before any async work fires. Tokens are + // single-use + 15-min TTL, but the URL would otherwise leak into the + // Referer header of any third-party script (analytics, fonts, ads). + router.replace({ pathname: router.pathname, query: {} }, undefined, { + shallow: true, + }); + + const run = async () => { + const hadSession = + session.status === 'authenticated' || + (typeof window !== 'undefined' && + !!localStorage.getItem('accessToken')); + if (hadSession) { + await tearDownExistingSession(); + } + + const result = await consumeMagicLink(tokenStr); + + // strict mode is off in this project; narrow via `'code' in result` + // (failure) and `'data' in result` (success) instead of relying on the + // discriminant flowing through. + if ('code' in result) { + if ( + result.code === 'INVALID_TOKEN' || + result.code === 'TOKEN_EXPIRED' || + result.code === 'TOKEN_ALREADY_USED' + ) { + setState({ kind: 'invalidLink' }); + return; + } + if (result.status === 403 || result.code === 'USER_BLOCKED') { + setState({ kind: 'blocked', message: result.message }); + return; + } + // Unknown errors fall through to the invalid-link state — safer than + // a blank screen, and the CTA still routes the user back home. + setState({ kind: 'invalidLink' }); + return; + } + + const data = result.data; + if ('requiresProfile' in data && data.requiresProfile) { + setState({ + kind: 'profile', + registrationToken: data.registrationToken, + email: data.email, + }); + return; + } + + if ('jwt' in data && data.jwt) { + storeJwtSession(data.jwt, data.user, setAccountData, setToken); + const redirectUrl = getRedirectCookie() || '/uxcore'; + deleteRedirectCookie(); + window.location.href = redirectUrl; + } + }; + + run(); + }, [ + router, + router.isReady, + router.query.token, + session.status, + setAccountData, + setToken, + ]); + + if (state.kind === 'loading') { + return ; + } + + if (state.kind === 'profile') { + return ( +
+ +
+ ); + } + + if (state.kind === 'blocked') { + return ( +
+
+

{copy.blocked.title}

+ {state.message &&

{state.message}

} +
+
+ ); + } + + return ( +
+
+

{copy.invalidLink.title}

+

{copy.invalidLink.body}

+ +
+
+ ); +}; + +export default MagicLinkConsumePage; diff --git a/tests/p1/magic-link.spec.ts b/tests/p1/magic-link.spec.ts new file mode 100644 index 00000000..26ff91f8 --- /dev/null +++ b/tests/p1/magic-link.spec.ts @@ -0,0 +1,180 @@ +import type { Route } from '@playwright/test'; + +import { expect, test } from '../fixtures/base'; + +const MAGIC_LINK_PATTERN = '**/api/auth/magic-link/**'; + +const fulfillJson = (route: Route, status: number, body: any) => + route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(body), + }); + +// Strapi may be reached on different hosts depending on env, so we mock the +// magic-link endpoints regardless of host. Each spec installs its own handler. + +test.describe('P1 @magic-link — /auth/magic-link consume flow', () => { + test('new email — profile form appears, submit lands on /uxcore with JWT', async ({ + page, + }) => { + await page.route(MAGIC_LINK_PATTERN, async route => { + const url = route.request().url(); + if (url.includes('/consume')) { + return fulfillJson(route, 200, { + requiresProfile: true, + registrationToken: 'reg-token-1', + email: 'new-user@example.com', + }); + } + if (url.includes('/complete-registration')) { + return fulfillJson(route, 200, { + jwt: 'fake-jwt-new-user', + user: { + id: 1, + email: 'new-user@example.com', + username: 'new-user', + }, + }); + } + return route.continue(); + }); + + await page.goto('/auth/magic-link?token=new-user-token'); + + const profileForm = page.locator('[data-cy="magic-link-profile-form"]'); + await expect(profileForm).toBeVisible(); + + await page.locator('[data-cy="magic-link-profile-name"]').fill('Ada'); + await page + .locator('[data-cy="magic-link-profile-surname"]') + .fill('Lovelace'); + await page.locator('[data-cy="magic-link-profile-submit"]').click(); + + await page.waitForURL(/\/uxcore(?:[/?#]|$)/, { timeout: 15_000 }); + + const accessToken = await page.evaluate(() => + localStorage.getItem('accessToken'), + ); + expect(accessToken).toBe('fake-jwt-new-user'); + }); + + test('existing email — no profile form, lands on /uxcore with JWT', async ({ + page, + }) => { + await page.route(MAGIC_LINK_PATTERN, async route => { + const url = route.request().url(); + if (url.includes('/consume')) { + return fulfillJson(route, 200, { + jwt: 'fake-jwt-existing', + user: { + id: 2, + email: 'existing@example.com', + username: 'existing', + }, + }); + } + return route.continue(); + }); + + await page.goto('/auth/magic-link?token=existing-user-token'); + + await page.waitForURL(/\/uxcore(?:[/?#]|$)/, { timeout: 15_000 }); + + await expect( + page.locator('[data-cy="magic-link-profile-form"]'), + ).toBeHidden(); + + const accessToken = await page.evaluate(() => + localStorage.getItem('accessToken'), + ); + expect(accessToken).toBe('fake-jwt-existing'); + }); + + test('TOKEN_ALREADY_USED — invalid-link UI with "Request a new link" button', async ({ + page, + }) => { + await page.route(MAGIC_LINK_PATTERN, async route => { + if (route.request().url().includes('/consume')) { + return fulfillJson(route, 400, { + error: { code: 'TOKEN_ALREADY_USED', message: 'Already used' }, + }); + } + return route.continue(); + }); + + await page.goto('/auth/magic-link?token=already-used'); + + const invalidCard = page.locator('[data-cy="magic-link-invalid"]'); + await expect(invalidCard).toBeVisible(); + + const cta = page.locator('[data-cy="magic-link-request-new"]'); + await expect(cta).toBeVisible(); + }); + + test('TOKEN_EXPIRED — invalid-link UI with "Request a new link" button', async ({ + page, + }) => { + await page.route(MAGIC_LINK_PATTERN, async route => { + if (route.request().url().includes('/consume')) { + return fulfillJson(route, 400, { + error: { code: 'TOKEN_EXPIRED', message: 'Expired' }, + }); + } + return route.continue(); + }); + + await page.goto('/auth/magic-link?token=expired'); + + await expect(page.locator('[data-cy="magic-link-invalid"]')).toBeVisible(); + await expect( + page.locator('[data-cy="magic-link-request-new"]'), + ).toBeVisible(); + }); +}); + +test.describe('P1 @magic-link — /request limit-reached banner', () => { + test('LIMIT_REACHED hides email form, shows banner, OAuth buttons remain', async ({ + page, + dismissCookieBanner, + }) => { + await page.route(MAGIC_LINK_PATTERN, async route => { + if (route.request().url().includes('/request')) { + return fulfillJson(route, 429, { + error: { code: 'LIMIT_REACHED', message: 'Too many requests' }, + }); + } + return route.continue(); + }); + + await page.goto('/'); + await dismissCookieBanner(); + + // Open the LogIn modal via the global avatar/login trigger. + await page + .getByText(/^Log In$/) + .first() + .click(); + + const emailForm = page.locator('[data-cy="magic-link-email-form"]'); + await expect(emailForm).toBeVisible(); + + await page + .locator('[data-cy="magic-link-email-input"]') + .fill('rate-limited@example.com'); + await page.locator('[data-cy="magic-link-email-submit"]').click(); + + await expect( + page.locator('[data-cy="magic-link-limit-reached"]'), + ).toBeVisible(); + await expect(emailForm).toBeHidden(); + + // OAuth buttons stay visible — they're outside the email-form subtree. + await expect( + page.getByRole('button', { name: /continue with google/i }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /continue with discord/i }), + ).toBeVisible(); + }); +}); From 96f20009e692eb10fe80e52f08a5d5b23a8590dd Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 11 May 2026 22:09:38 +0400 Subject: [PATCH 05/87] feat(longevity): animate brain age, workout, and nav with reduced-motion support Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BrainAgeActivity.module.scss | 67 +++++++++++++++---- .../BrainAgeActivity/BrainAgeActivity.tsx | 63 +++++++++++++++-- .../EnvironmentSubSection.module.scss | 19 ++++++ .../EnvironmentSubSection.tsx | 13 +++- .../LongevitySubSection.module.scss | 11 +++ .../Navigation/Navigation.module.scss | 53 +++++++++++++-- .../StrengthAndTimeCompression.tsx | 14 +++- .../WeeklyWorkout/WeeklyWorkout.module.scss | 35 +++++++++- .../longevity/WeeklyWorkout/WeeklyWorkout.tsx | 9 ++- src/hooks/useAnimatedNumber.ts | 55 +++++++++++++++ src/hooks/useInView.ts | 43 ++++++++++++ 11 files changed, 352 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useAnimatedNumber.ts create mode 100644 src/hooks/useInView.ts diff --git a/src/components/BrainAgeActivity/BrainAgeActivity.module.scss b/src/components/BrainAgeActivity/BrainAgeActivity.module.scss index 2f9b6a98..9cc70332 100644 --- a/src/components/BrainAgeActivity/BrainAgeActivity.module.scss +++ b/src/components/BrainAgeActivity/BrainAgeActivity.module.scss @@ -35,6 +35,8 @@ .buttonContainer { display: flex; gap: 70px; + position: relative; + padding-bottom: 8px; } .ageButton { @@ -45,23 +47,31 @@ font-size: 32px; font-family: Aboreto-Regular, sans-serif; font-weight: 700; + padding: 0; + transition: color 280ms cubic-bezier(0.4, 0, 0.2, 1); + + svg path { + transition: fill 280ms cubic-bezier(0.4, 0, 0.2, 1); + } } .selectedButton { color: #ce2128; - display: flex; - flex-direction: column; - align-items: center; - - &::after { - content: ''; - display: block; - width: 36px; - height: 4px; - background-image: url('/keepsimple_/assets/longevity/line.svg'); - background-size: contain; - background-repeat: no-repeat; - } + } + + .indicator { + position: absolute; + bottom: 0; + left: 0; + height: 4px; + background-image: url('/keepsimple_/assets/longevity/line.svg'); + background-size: contain; + background-repeat: no-repeat; + pointer-events: none; + transition: + transform 360ms cubic-bezier(0.4, 0, 0.2, 1), + width 360ms cubic-bezier(0.4, 0, 0.2, 1); + will-change: transform, width; } .result { @@ -215,3 +225,34 @@ } } } + +.fadeSwap { + display: inline-flex; + align-items: center; + animation: brainAgeFade 360ms cubic-bezier(0.4, 0, 0.2, 1) both; +} + +@keyframes brainAgeFade { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .section { + .ageButton, + .ageButton svg path, + .indicator { + transition: none !important; + } + } + + .fadeSwap { + animation: none !important; + } +} diff --git a/src/components/BrainAgeActivity/BrainAgeActivity.tsx b/src/components/BrainAgeActivity/BrainAgeActivity.tsx index ec9bf40f..95894b10 100644 --- a/src/components/BrainAgeActivity/BrainAgeActivity.tsx +++ b/src/components/BrainAgeActivity/BrainAgeActivity.tsx @@ -1,5 +1,13 @@ import cn from 'classnames'; -import { FC, ReactNode, useCallback, useMemo, useState } from 'react'; +import { + FC, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { BRAIN_AGE_TABLE } from '@constants/longevity'; @@ -17,6 +25,32 @@ const BrainAgeActivity: FC = ({ locale }) => { const { totalWeeklyActivity } = longevityData[locale]; const [selectedBaseline, setSelectedBaseline] = useState(32); + const buttonRefs = useRef>([]); + const buttonContainerRef = useRef(null); + const [indicator, setIndicator] = useState<{ + left: number; + width: number; + } | null>(null); + + const updateIndicator = useCallback(() => { + const idx = BRAIN_AGE_TABLE.findIndex(r => r.baseline === selectedBaseline); + const btn = buttonRefs.current[idx]; + const container = buttonContainerRef.current; + if (!btn || !container) return; + const btnRect = btn.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + setIndicator({ + left: btnRect.left - containerRect.left, + width: btnRect.width, + }); + }, [selectedBaseline]); + + useEffect(() => { + updateIndicator(); + window.addEventListener('resize', updateIndicator); + return () => window.removeEventListener('resize', updateIndicator); + }, [updateIndicator]); + const formatDelta = useCallback( (delta: number): ReactNode => { const sign = delta > 0 ? '+ ' : ''; @@ -75,10 +109,13 @@ const BrainAgeActivity: FC = ({ locale }) => { showLeftIcon={false} className={styles.subHeading} /> -
- {BRAIN_AGE_TABLE.map(row => ( +
+ {BRAIN_AGE_TABLE.map((row, idx) => ( ))} + {indicator && ( + + )}
{result && ( @@ -112,7 +159,10 @@ const BrainAgeActivity: FC = ({ locale }) => { )}

- + {' '} {result.sedentaryDeltaText} @@ -131,7 +181,10 @@ const BrainAgeActivity: FC = ({ locale }) => { )}

- +
diff --git a/src/components/longevity/EnvironmentSubSection/EnvironmentSubSection.module.scss b/src/components/longevity/EnvironmentSubSection/EnvironmentSubSection.module.scss index b72b595b..d220c04c 100644 --- a/src/components/longevity/EnvironmentSubSection/EnvironmentSubSection.module.scss +++ b/src/components/longevity/EnvironmentSubSection/EnvironmentSubSection.module.scss @@ -1,5 +1,16 @@ .environmentSubSection { padding: 20px 60px 0 40px; + opacity: 0; + transform: translateX(-32px); + transition: + opacity 600ms cubic-bezier(0.4, 0, 0.2, 1), + transform 600ms cubic-bezier(0.4, 0, 0.2, 1); + will-change: opacity, transform; + + &.visible { + opacity: 1; + transform: translateX(0); + } .titleAndIcon { display: flex; @@ -68,3 +79,11 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .environmentSubSection { + opacity: 1; + transform: none; + transition: none !important; + } +} diff --git a/src/components/longevity/EnvironmentSubSection/EnvironmentSubSection.tsx b/src/components/longevity/EnvironmentSubSection/EnvironmentSubSection.tsx index b6827cfb..6faff3b2 100644 --- a/src/components/longevity/EnvironmentSubSection/EnvironmentSubSection.tsx +++ b/src/components/longevity/EnvironmentSubSection/EnvironmentSubSection.tsx @@ -1,6 +1,9 @@ +import cn from 'classnames'; import Image from 'next/image'; import { FC } from 'react'; +import useInView from '@hooks/useInView'; + import { EnvironmentSubSectionProps } from './EnvironmentSubSection.types'; import styles from './EnvironmentSubSection.module.scss'; @@ -10,9 +13,15 @@ const EnvironmentSubSection: FC = ({ description, iconUrl, }) => { - // TODO: Add alt text + const { ref, inView } = useInView(); + return ( -
+
{''} {name} diff --git a/src/components/longevity/LongevitySubSection/LongevitySubSection.module.scss b/src/components/longevity/LongevitySubSection/LongevitySubSection.module.scss index 1c55dd1b..7a5dff6f 100644 --- a/src/components/longevity/LongevitySubSection/LongevitySubSection.module.scss +++ b/src/components/longevity/LongevitySubSection/LongevitySubSection.module.scss @@ -68,6 +68,7 @@ clip-path: inset(0 100% 0 0); animation: drawRedLine 1.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; animation-delay: 0.2s; + animation-iteration-count: 1; } } @@ -249,3 +250,13 @@ filter: drop-shadow(0px 0px 18px rgba(53, 35, 13, 0.2)); } } + +@media (prefers-reduced-motion: reduce) { + .subSection { + .habitTooltip, + .habitTooltip::after, + .dateTxt::after { + animation: none !important; + } + } +} diff --git a/src/components/longevity/Navigation/Navigation.module.scss b/src/components/longevity/Navigation/Navigation.module.scss index 3ec696fb..89e71d01 100644 --- a/src/components/longevity/Navigation/Navigation.module.scss +++ b/src/components/longevity/Navigation/Navigation.module.scss @@ -37,7 +37,7 @@ background-repeat: no-repeat; background-size: cover; opacity: 0; - transition: opacity 200ms ease; + transition: opacity 320ms cubic-bezier(0.4, 0, 0.2, 1); } &:hover::after { @@ -46,11 +46,27 @@ } .subItem { + position: relative; background-image: url('/keepsimple_/assets/longevity/nav-sub-bg.png'); - &:hover { + &::before { + content: ''; + position: absolute; + inset: 0; background-image: url('/keepsimple_/assets/longevity/nav-sub-bg-hover.png'); + background-repeat: no-repeat; + background-size: cover; + opacity: 0; + transition: opacity 320ms cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + z-index: 0; + } + &:hover::before { + opacity: 1; + } + + &:hover { .link, .subLink { color: #b67636; @@ -116,6 +132,13 @@ justify-content: center; align-items: center; gap: 8px; + transition: color 320ms cubic-bezier(0.4, 0, 0.2, 1); + + svg { + path { + transition: fill 320ms cubic-bezier(0.4, 0, 0.2, 1); + } + } } } @@ -150,12 +173,12 @@ justify-content: center; align-items: center; gap: 4px; - transition: color 200ms ease; + transition: color 320ms cubic-bezier(0.4, 0, 0.2, 1); svg { path { fill: #bdbdbd; - transition: fill 200ms ease; + transition: fill 320ms cubic-bezier(0.4, 0, 0.2, 1); } } } @@ -185,3 +208,25 @@ margin: 0 15px; } } + +@media (prefers-reduced-motion: reduce) { + .ul, + .subUl { + .item::after, + .subItem::before, + .link, + .subLink, + .link svg path, + .subLink svg path { + transition: none !important; + } + } + + .subNav { + .curtains, + .subLink, + .subLink svg path { + transition: none !important; + } + } +} diff --git a/src/components/longevity/StrengthAndTimeCompression/StrengthAndTimeCompression.tsx b/src/components/longevity/StrengthAndTimeCompression/StrengthAndTimeCompression.tsx index 8dd8d8a9..aa89e313 100644 --- a/src/components/longevity/StrengthAndTimeCompression/StrengthAndTimeCompression.tsx +++ b/src/components/longevity/StrengthAndTimeCompression/StrengthAndTimeCompression.tsx @@ -3,6 +3,8 @@ import React, { FC, useMemo, useState } from 'react'; import { ACTIVITY_LEVELS } from '@constants/longevity'; +import useAnimatedNumber from '@hooks/useAnimatedNumber'; + import longevityData from '@data/longevity'; import Heading from '@components/Heading'; @@ -26,6 +28,10 @@ const StrengthAndTimeCompression: FC = ({ [selectedIndex], ); + const animatedTotalMinutes = useAnimatedNumber( + selectedLevel.totalMinutesPerWeek, + ); + return (
= ({ stops[selectedIndex] === 3 || stops[selectedIndex] === 4, })} > - - {selectedLevel.totalMinutesPerWeek} + + {animatedTotalMinutes} {totalWeeklyActivity.min} diff --git a/src/components/longevity/WeeklyWorkout/WeeklyWorkout.module.scss b/src/components/longevity/WeeklyWorkout/WeeklyWorkout.module.scss index 3cb59945..644a9fff 100644 --- a/src/components/longevity/WeeklyWorkout/WeeklyWorkout.module.scss +++ b/src/components/longevity/WeeklyWorkout/WeeklyWorkout.module.scss @@ -56,9 +56,15 @@ .imageWrapper { opacity: 70%; transform: translateX(-6px); - transition: transform 200ms ease; + transition: + transform 200ms ease, + opacity 240ms cubic-bezier(0.4, 0, 0.2, 1); z-index: 1; cursor: pointer; + + &:hover { + opacity: 1; + } } .activeImageWrapper { @@ -92,6 +98,21 @@ color: #4d8539; } } + + .riskValue { + animation: riskFadeIn 360ms cubic-bezier(0.4, 0, 0.2, 1) both; + } +} + +@keyframes riskFadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } } @media (max-width: 965px) { @@ -175,3 +196,15 @@ } } } + +@media (prefers-reduced-motion: reduce) { + .weeklyWorkout { + .imageWrapper { + transition: none !important; + } + + .riskValue { + animation: none !important; + } + } +} diff --git a/src/components/longevity/WeeklyWorkout/WeeklyWorkout.tsx b/src/components/longevity/WeeklyWorkout/WeeklyWorkout.tsx index ef4f1220..a59178d1 100644 --- a/src/components/longevity/WeeklyWorkout/WeeklyWorkout.tsx +++ b/src/components/longevity/WeeklyWorkout/WeeklyWorkout.tsx @@ -127,7 +127,8 @@ const WeeklyWorkout: FC = ({ locale }) => {

{totalWeeklyActivity.earlyDyingRisk} = ({ locale }) => {

{totalWeeklyActivity.dementiaRisk} = ({ locale }) => {

{totalWeeklyActivity.agingTrajectory} 1 - Math.pow(1 - t, 3); + +const useAnimatedNumber = ( + target: number, + duration = 420, + step = 5, +): number => { + const [value, setValue] = useState(target); + const fromRef = useRef(target); + const rafRef = useRef(null); + + useEffect(() => { + const from = fromRef.current; + if (from === target) return; + + // Respect prefers-reduced-motion: jump to the target value without tweening. + if ( + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + setValue(target); + fromRef.current = target; + return; + } + + const start = performance.now(); + const distance = target - from; + + const tick = (now: number) => { + const elapsed = now - start; + const t = Math.min(1, elapsed / duration); + const eased = easeOutCubic(t); + const raw = from + distance * eased; + const snapped = t < 1 ? Math.round(raw / step) * step : target; + setValue(snapped); + if (t < 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + fromRef.current = target; + } + }; + + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + fromRef.current = target; + }; + }, [target, duration, step]); + + return value; +}; + +export default useAnimatedNumber; diff --git a/src/hooks/useInView.ts b/src/hooks/useInView.ts new file mode 100644 index 00000000..f39830c1 --- /dev/null +++ b/src/hooks/useInView.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseInViewOptions { + rootMargin?: string; + threshold?: number | number[]; + once?: boolean; +} + +const useInView = ({ + rootMargin = '0px 0px -10% 0px', + threshold = 0.15, + once = true, +}: UseInViewOptions = {}) => { + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const node = ref.current; + if (!node || typeof IntersectionObserver === 'undefined') { + setInView(true); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + if (once) observer.disconnect(); + } else if (!once) { + setInView(false); + } + }, + { rootMargin, threshold }, + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [rootMargin, threshold, once]); + + return { ref, inView }; +}; + +export default useInView; From ecb3f27620a46d860e71c6b47f3dc7b0f8d0028a Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 11 May 2026 22:24:27 +0400 Subject: [PATCH 06/87] fix: guard mixpanel calls without token and correct avatar fallback path Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/mixpanel.ts | 4 ++++ src/components/UserProfile/UserProfile.tsx | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/mixpanel.ts b/lib/mixpanel.ts index 65818aa7..29680b2e 100644 --- a/lib/mixpanel.ts +++ b/lib/mixpanel.ts @@ -20,6 +20,8 @@ export const handleMixpanelClick = ( location: string, element: string, ) => { + if (!MIXPANEL_TOKEN) return; + mixpanel.track(eventName, { path: path, location: location, @@ -30,6 +32,8 @@ export const handleMixpanelClick = ( let bounceTimer: ReturnType | null = null; export const trackPageView = (url: string) => { + if (!MIXPANEL_TOKEN) return; + if (bounceTimer) { clearTimeout(bounceTimer); bounceTimer = null; diff --git a/src/components/UserProfile/UserProfile.tsx b/src/components/UserProfile/UserProfile.tsx index ccd12b7b..75d8d63a 100644 --- a/src/components/UserProfile/UserProfile.tsx +++ b/src/components/UserProfile/UserProfile.tsx @@ -86,9 +86,9 @@ const UserProfile: FC = ({ const renderUserImage = () => { if (!isLoggedIn && isAccessTokenExist) { - return '/assets/avatar.svg'; + return '/keepsimple_/assets/avatar.svg'; } - return userImage; + return userImage || '/keepsimple_/assets/avatar.svg'; }; useEffect(() => { const accessToken = localStorage.getItem('accessToken'); From 397bb3bd08eba477f5f85b17fb5ca803ceb52b01 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 12 May 2026 17:43:41 +0400 Subject: [PATCH 07/87] chore(security): sanitize CMS HTML, add security headers, refine auth UI Co-Authored-By: Claude Opus 4.7 (1M context) --- next.config.js | 59 ++++ package.json | 2 + src/api/auth.ts | 49 +++ .../AboutProjects/AboutProjects.tsx | 8 +- src/components/ArticleInfo/ArticleInfo.tsx | 6 +- .../ContentParser/ContentParser.tsx | 3 +- src/components/Header/Header.tsx | 6 +- src/components/Headline/Headline.tsx | 14 +- src/components/SeoGenerator/SeoGenerator.tsx | 7 +- .../SettingsModal/SettingsModal.module.scss | 61 ++++ .../SettingsModal/SettingsModal.tsx | 193 +++++++++-- .../SupporterContainer/SupporterContainer.tsx | 6 +- .../ToolContainer/ToolContainer.tsx | 4 +- .../PyramidAuthors/PyramidAuthors.tsx | 4 +- .../PyramidInfoSection/PyramidInfoSection.tsx | 10 +- .../EnvironmentSubSection.tsx | 4 +- .../longevity/FlipCard/FlipCard.tsx | 6 +- .../longevity/HTMLClamp/HTMLClamp.tsx | 4 +- .../LongevitySubSection.tsx | 4 +- .../MainInfoSection/MainInfoSection.tsx | 4 +- .../longevity/StudySection/StudySection.tsx | 6 +- .../longevity/Supplement/Supplement.tsx | 4 +- .../AboutTheProduct/AboutTheProduct.tsx | 4 +- .../WhatToEatOrAvoid/WhatToEatOrAvoid.tsx | 4 +- .../WhyDoThisTooltip/WhyDoThisTooltip.tsx | 4 +- src/data/auth/en.ts | 44 +++ src/data/auth/ru.ts | 44 +++ .../ContributorsLayout/ContributorsLayout.tsx | 6 +- src/layouts/ResultsLayout/ResultsLayout.tsx | 6 +- src/layouts/WorkoutLayout/WorkoutLayout.tsx | 8 +- src/lib/sanitizeHtml.ts | 9 + src/lib/settings-helpers.ts | 10 + src/pages/auth/email-change.tsx | 173 ++++++++++ yarn.lock | 310 +++++++++++++++++- 34 files changed, 1013 insertions(+), 73 deletions(-) create mode 100644 src/lib/sanitizeHtml.ts create mode 100644 src/pages/auth/email-change.tsx diff --git a/next.config.js b/next.config.js index a537c441..6243bfca 100644 --- a/next.config.js +++ b/next.config.js @@ -23,6 +23,65 @@ module.exports = withBundleAnalyzer({ { source: '/robots.txt', destination: '/keepsimple_/robots.txt' }, ]; }, + async headers() { + const isDev = process.env.NODE_ENV !== 'production'; + const scriptSrc = [ + "'self'", + "'unsafe-inline'", + // Next.js dev mode (Fast Refresh) requires eval. + isDev ? "'unsafe-eval'" : '', + 'https://analytics.ahrefs.com', + 'https://www.googletagmanager.com', + 'https://www.google-analytics.com', + 'https://cdn.mxpnl.com', + ] + .filter(Boolean) + .join(' '); + const connectSrc = [ + "'self'", + // Next.js dev HMR uses ws:// to localhost. + isDev ? 'ws:' : '', + 'https://*.keepsimple.io', + 'https://metrics.administration.ae', + 'https://api.mixpanel.com', + 'https://www.google-analytics.com', + ] + .filter(Boolean) + .join(' '); + + return [ + { + source: '/:path*', + headers: [ + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + `script-src ${scriptSrc}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https://lh3.googleusercontent.com https://cdn.discordapp.com https://strapi.keepsimple.io https://staging-strapi.keepsimple.io https://www.google-analytics.com", + "font-src 'self' data:", + `connect-src ${connectSrc}`, + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join('; '), + }, + ], + }, + ]; + }, env: { NEXTAUTH_URL: process.env.NEXTAUTH_URL, }, diff --git a/package.json b/package.json index dcf50f54..16b5b140 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "geoip-lite": "1.4.2", "html2canvas": "^1.4.1", + "isomorphic-dompurify": "^3.12.0", "lodash.debounce": "4.0.8", "lodash.unescape": "4.0.1", "mixpanel-browser": "^2.65.0", @@ -53,6 +54,7 @@ "react-slick": "0.29.0", "react-tooltip": "5.27.1", "rehype-raw": "6.1.1", + "rehype-sanitize": "^6.0.0", "remark-breaks": "3.0.2", "sass": "1.32.8", "slick-carousel": "1.8.1", diff --git a/src/api/auth.ts b/src/api/auth.ts index b257248f..619a23e7 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -179,3 +179,52 @@ export const completeMagicLinkRegistration = async ({ return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message }; } }; + +const twitterEmailChangeUrl = (path: string) => + `${process.env.NEXT_PUBLIC_STRAPI}/api/auth/twitter/email-change/${path}`; + +export const requestTwitterEmailChange = async ({ + email, + locale, + token, +}: { + email: string; + locale: MagicLinkLocale; + token: string; +}): Promise> => { + try { + const response = await fetch(twitterEmailChangeUrl('request'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ email, locale }), + }); + if (!response.ok) { + return { ok: false, ...(await parseError(response)) }; + } + return { ok: true, data: null }; + } catch (e: any) { + return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message }; + } +}; + +export const confirmTwitterEmailChange = async ( + token: string, +): Promise> => { + try { + const response = await fetch(twitterEmailChangeUrl('confirm'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!response.ok) { + return { ok: false, ...(await parseError(response)) }; + } + const data = await response.json(); + return { ok: true, data }; + } catch (e: any) { + return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message }; + } +}; diff --git a/src/components/AboutProjects/AboutProjects.tsx b/src/components/AboutProjects/AboutProjects.tsx index 2f1c8bb8..bd4e2641 100644 --- a/src/components/AboutProjects/AboutProjects.tsx +++ b/src/components/AboutProjects/AboutProjects.tsx @@ -2,6 +2,8 @@ import cn from 'classnames'; import { FC } from 'react'; import { useInView } from 'react-intersection-observer'; +import { sanitizeHtml } from '@lib/sanitizeHtml'; + import styles from './AboutProjects.module.scss'; type projectsProps = { @@ -35,7 +37,11 @@ const AboutProjects: FC = ({ projects, darkTheme }) => { })} >

{project.project_name}

-
+
))}
diff --git a/src/components/ArticleInfo/ArticleInfo.tsx b/src/components/ArticleInfo/ArticleInfo.tsx index 1bc68da0..1e06f7f2 100644 --- a/src/components/ArticleInfo/ArticleInfo.tsx +++ b/src/components/ArticleInfo/ArticleInfo.tsx @@ -8,6 +8,8 @@ import { Tooltip as ReactTooltip } from 'react-tooltip'; import { useIsWidthLessThan } from '@hooks/useScreenSize'; +import { sanitizeHtml } from '@lib/sanitizeHtml'; + import ArticleTag from '@components/articles/ArticleTag'; import { GlobalContext } from '@components/Context/GlobalContext'; @@ -133,7 +135,7 @@ const ArticleInfo: FC = ({
@@ -147,7 +149,7 @@ const ArticleInfo: FC = ({ })} > diff --git a/src/components/ContentParser/ContentParser.tsx b/src/components/ContentParser/ContentParser.tsx index 643fa751..e3d1bca3 100644 --- a/src/components/ContentParser/ContentParser.tsx +++ b/src/components/ContentParser/ContentParser.tsx @@ -2,6 +2,7 @@ import unescape from 'lodash.unescape'; import { FC, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; import remarkBreaks from 'remark-breaks'; import useContentType from '@hooks/useContentType'; @@ -31,7 +32,7 @@ const ContentParser: FC = ({ className={styles.content} components={componentList} remarkPlugins={[[remarkBreaks]]} - rehypePlugins={[rehypeRaw]} + rehypePlugins={[rehypeRaw, rehypeSanitize]} > {modifiedData} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 02a699a6..7d9ac8ae 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -76,7 +76,6 @@ const Header: FC = () => { isEmailPublic: string, isLinkedinPublic: string, title?: string, - email?: string, ) => { const mailIsPublic = isEmailPublic === 'everyone'; const linkedInIsPublic = isLinkedinPublic === 'everyone'; @@ -88,10 +87,6 @@ const Header: FC = () => { mailIsPublic, linkedInIsPublic, title, - undefined, - undefined, - undefined, - email, ); const data = await getMyInfo(); @@ -269,6 +264,7 @@ const Header: FC = () => { linkedin={accountData?.linkedIn} linkedinStatus={accountData?.publicLinkedin} provider={accountData?.provider} + token={token} handleSaveClick={handleSaveClick} setUsernameIsTakenError={setUsernameIsTakenError} usernameIsTakenError={usernameIsTakenError} diff --git a/src/components/Headline/Headline.tsx b/src/components/Headline/Headline.tsx index d7fea65a..5222b59b 100644 --- a/src/components/Headline/Headline.tsx +++ b/src/components/Headline/Headline.tsx @@ -15,6 +15,8 @@ import { socialMediaLinks } from '@constants/common'; import { TRouter } from '@local-types/global'; +import { sanitizeHtml } from '@lib/sanitizeHtml'; + import contributors from '@data/contributors'; import AudioPlayer from '@components/AudioPlayer'; @@ -264,7 +266,9 @@ const Headline: FC = ({ headline, darkTheme, russianView }) => { [styles.fadeOut]: fadeOutIndexes.includes(1), [styles.fadeIn]: fadeInIndexes.includes(1), })} - dangerouslySetInnerHTML={{ __html: highlightedText }} + dangerouslySetInnerHTML={{ + __html: sanitizeHtml(highlightedText), + }} >

)} {title && secondDescription && ( @@ -277,7 +281,9 @@ const Headline: FC = ({ headline, darkTheme, russianView }) => { [styles.fadeIn]: fadeInIndexes.includes(2) && !fadeOutIndexes.includes(2), })} - dangerouslySetInnerHTML={{ __html: secondDescription }} + dangerouslySetInnerHTML={{ + __html: sanitizeHtml(secondDescription), + }} >

)} {title && lastDescription && ( @@ -290,7 +296,9 @@ const Headline: FC = ({ headline, darkTheme, russianView }) => { fadeOutIndexes.includes(3) && !fadeInIndexes.includes(3), [styles.fadeIn]: fadeInIndexes.includes(3), })} - dangerouslySetInnerHTML={{ __html: lastDescription }} + dangerouslySetInnerHTML={{ + __html: sanitizeHtml(lastDescription), + }} >

)}
diff --git a/src/components/SeoGenerator/SeoGenerator.tsx b/src/components/SeoGenerator/SeoGenerator.tsx index e66f745e..f7563830 100644 --- a/src/components/SeoGenerator/SeoGenerator.tsx +++ b/src/components/SeoGenerator/SeoGenerator.tsx @@ -294,7 +294,12 @@ const SeoGenerator: FC = ({ + + 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..506604ff --- /dev/null +++ b/widget/src/AskUxCore.tsx @@ -0,0 +1,2133 @@ +import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; + +import { askConcierge, Citation, 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; +}; + +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 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: "I'm always here", + 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: 'Я всегда тут', + 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 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. */ +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) out.push(idMatch); + document.querySelectorAll('a[href]').forEach(a => { + 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 (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(), + ); + const onBeginUxcatTest = () => { + trackEvent('uxcat_begin_test_click', {}); + 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(); + 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; + } + })(); + setTurns(cur => [ + ...cur, + { + id: `land-${Date.now()}`, + query: '', + answer: text, + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: false, + kind: 'landing', + navTitle: urlTitle || cleanPageTitle(rawTitle), + }, + ]); + }) + .catch(() => { + /* aborted or upstream fail — silent */ + }); + }; + + const currentPage: LastPage = { + url: window.location.href, + title: document.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); + + 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 next = { url, title }; + 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); + }; + 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); + }; + window.addEventListener('beforeunload', onUnload); + window.addEventListener('pagehide', onUnload); + + 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); + 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; + 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; + 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); + } + const next = cur.slice(); + next[idx] = { + ...next[idx], + answer: text, + isStreaming: false, + /* H1 wins over the optimistic card-title seed. */ + navTitle: resolvedTitle || next[idx].navTitle, + }; + return next; + } + if (!text) return cur; + return [ + ...cur, + { + id: `land-${Date.now()}`, + query: '', + answer: text, + citations: [], + suggestions: [], + mode: 'answer', + isStreaming: false, + kind: 'landing', + navTitle: resolvedTitle, + }, + ]; + }); + }) + .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]); + + 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 }); + + 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; + })(); + const onChunk = (current: string) => { + const cleanedPartial = stripMarkers(current); + setTurns(prev => + prev.map(tt => + tt.id === id ? { ...tt, answer: cleanedPartial } : tt, + ), + ); + }; + const result = await askConcierge( + query, + lang, + history, + recentCardUrls, + '/api/concierge', + onChunk, + lastPick, + ); + const cleaned = stripMarkers(result.answer); + + 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 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 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} + {(() => { + /* 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' && + isCurrentSpatial && + 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 => ( + + ))} + + ); + })()} + + ); + })} +
+ )} + + )} +
+ ); + }); + })()} +
+ )} + +
+