diff --git a/.claude/launch.json b/.claude/launch.json index 3f017ca3b..853b8bf65 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -1,6 +1,12 @@ { "version": "0.0.1", "configurations": [ + { + "name": "preview-dist", + "runtimeExecutable": "npx", + "runtimeArgs": ["vite", "preview", "--port", "4173", "--strictPort"], + "port": 4173 + }, { "name": "dev", "runtimeExecutable": "npm", diff --git a/.github/workflows/deploy-gh-pages.yml b/.github/workflows/deploy-gh-pages.yml new file mode 100644 index 000000000..e57c67d5f --- /dev/null +++ b/.github/workflows/deploy-gh-pages.yml @@ -0,0 +1,66 @@ +name: Rollback deploy to GitHub Pages + +# Emergency path only. The press runs on Cloudflare Pages +# (deploy.yml); this workflow re-publishes to the gh-pages branch by +# hand if Cloudflare is down or the cutover has to be reversed. +# Run it, then point kernel.chat DNS back at GitHub Pages. +on: + workflow_dispatch: {} + +permissions: + contents: write + pages: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Design adherence — no raw hex in the editorial design-system layer + run: npm run lint:adherence + + - name: Editorial law — artifact-first, no POPEYE naming, no emoji + run: npm run lint:editorial + + - name: Type-check + run: npx tsc --noEmit + + - name: Build sitemap from the issue registry + run: npm run sitemap + + - name: Build + run: npm run build + env: + VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} + VITE_SUPABASE_KEY: ${{ secrets.VITE_SUPABASE_KEY }} + VITE_STRIPE_PUBLISHABLE_KEY: ${{ secrets.VITE_STRIPE_PUBLISHABLE_KEY }} + VITE_STRIPE_KERNEL_PRICE_ID: ${{ secrets.VITE_STRIPE_KERNEL_PRICE_ID }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} + + - name: Build artifact index + run: npm run artifacts:index + + - name: Include editorial artifacts + run: | + mkdir -p dist/artifacts + cp artifacts/*.html dist/artifacts/ + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist + cname: kernel.chat + enable_jekyll: false diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 726af81ff..98ea7e64c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to GitHub Pages +name: Deploy to Cloudflare Pages on: push: @@ -6,8 +6,7 @@ on: workflow_dispatch: {} permissions: - contents: write - pages: write + contents: read jobs: deploy: @@ -34,6 +33,9 @@ jobs: - name: Type-check run: npx tsc --noEmit + - name: Build sitemap from the issue registry + run: npm run sitemap + - name: Build run: npm run build env: @@ -52,10 +54,9 @@ jobs: mkdir -p dist/artifacts cp artifacts/*.html dist/artifacts/ - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./dist - cname: kernel.chat - enable_jekyll: false + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist --project-name=kernel-chat diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index 178031964..af8be08df 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -2,6 +2,35 @@ > This file persists context between Claude Code sessions. +## Session 2026-07-20 — UX anti-pattern audit → real URLs (PR #62) + +- Ran the design-QA audit against live kernel.chat. Editorial surface + healthy (no overflow, alt clean, reduced-motion complete, focus + visible). The real anti-patterns: (1) artifact index + 421 were + 404 live (fixed by merging fix/editorial-enforcement → main, now + 200); (2) every deep link served HTTP 404 → hash bounce; (3) the + outline:none / clickable-div debt all sits in UNROUTED legacy + surfaces — EnginePage is the parked chat product, not junk. +- Merge to main taught check-editorial.mjs the documented-exception + path: ARTIFACT_WAIVER ("no separate artifact edition" in rendered + colophon copy) — 427 uses it legitimately. +- **PR #62 (`feat/real-urls-cloudflare`)**, three commits: + 1. createBrowserRouter + legacyHashRedirect.ts (module-scope, BEFORE + router creation — the router captures location at import time, a + boot-sequence shim renders the wrong route); _redirects; sitemap + generator (scripts/build-sitemap.mjs, 77 URLs); PostHog + capture_pageview 'history_change'; deploy.yml → Cloudflare Pages + (wrangler-action v3, project kernel-chat); deploy-gh-pages.yml + rollback; e2e path URLs; PUBLISHING.md §VII rewritten. + 2. Cut nine dead pages (4,068 lines) incl. PricingPage; engine + tree parked, untouched. + 3. A11y: back-to-top 98×13 → 122×44 (nowrap needed — 40px-wide + colophon column wraps padded labels); skip link → #feature-well. +- **BLOCKED on Isaac before merge:** create CF Pages project + `kernel-chat` + repo secrets CLOUDFLARE_API_TOKEN / + CLOUDFLARE_ACCOUNT_ID; DNS cutover after pages.dev verifies. +- Gates at branch head: tsc clean, 794/794 tests, 236KB gzip. + ## Session 2026-07-20 (cont.) — ISSUE 427: THE MOAT IS REALITY - Pressed ISSUE 427 · FEB 2027 from a reader-supplied transcript of diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index a683b9696..52b49071d 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -28,7 +28,7 @@ test.describe('Authentication', () => { }) test('redirects unauthenticated users from protected routes', async ({ page }) => { - await page.goto('/#/admin') + await page.goto('/admin') await page.waitForSelector('.ka-gate, .ka-landing, .engine-body', { timeout: 15000 }) // Should not show admin page const admin = await page.$('.admin-page') diff --git a/e2e/tests/issue-415-close.spec.ts b/e2e/tests/issue-415-close.spec.ts index 84969f892..c812a8fac 100644 --- a/e2e/tests/issue-415-close.spec.ts +++ b/e2e/tests/issue-415-close.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' test.describe('ISSUE 415 — close primitive', () => { test('reader can show more items, then stop, and gets a receipt', async ({ page }) => { - await page.goto('/#/issues/415') + await page.goto('/issues/415') await expect(page.getByRole('button', { name: 'Show me one more' })).toBeVisible() await expect(page.getByRole('button', { name: "I'll stop here" })).toBeVisible() @@ -21,7 +21,7 @@ test.describe('ISSUE 415 — close primitive', () => { test('mobile viewport renders both controls at equal size', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) - await page.goto('/#/issues/415') + await page.goto('/issues/415') const more = page.getByRole('button', { name: 'Show me one more' }) const stop = page.getByRole('button', { name: "I'll stop here" }) await expect(more).toBeVisible() @@ -36,7 +36,7 @@ test.describe('ISSUE 415 — close primitive', () => { page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()) }) - await page.goto('/#/issues/415') + await page.goto('/issues/415') await page.getByRole('button', { name: 'Show me one more' }).click() await page.getByRole('button', { name: "I'll stop here" }).click() expect(errors).toEqual([]) diff --git a/package.json b/package.json index 5da90de5b..77567d217 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint:adherence": "node scripts/check-adherence.mjs", "lint:editorial": "node scripts/check-editorial.mjs", "artifacts:index": "node scripts/build-artifact-index.mjs", + "sitemap": "node scripts/build-sitemap.mjs", "preview": "vite preview", "test": "vitest run", "test:e2e": "playwright test", diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 000000000..df4f6d1f1 --- /dev/null +++ b/public/_redirects @@ -0,0 +1,4 @@ +# Cloudflare Pages SPA fallback. Real files in the upload manifest +# (artifacts/*.html, sitemap.xml, issue plates) are served before this +# rule applies, so only unmatched paths fall through to the app shell. +/* /index.html 200 diff --git a/public/sitemap.xml b/public/sitemap.xml index 4ef855f04..c87233e84 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,26 +2,387 @@ https://kernel.chat/ - 2026-03-18 weekly 1.0 - https://kernel.chat/#/engine - 2026-03-18 + https://kernel.chat/issues weekly 0.9 - https://kernel.chat/#/terms - 2026-03-01 + https://kernel.chat/about + monthly + 0.5 + + + https://kernel.chat/pressroom + monthly + 0.5 + + + https://kernel.chat/refusals + monthly + 0.4 + + + https://kernel.chat/atelier + monthly + 0.4 + + + https://kernel.chat/artifacts/index.html + weekly + 0.4 + + + https://kernel.chat/privacy monthly 0.3 - https://kernel.chat/#/privacy - 2026-03-01 + https://kernel.chat/terms monthly 0.3 + + https://kernel.chat/issues/360 + yearly + 0.6 + + + https://kernel.chat/issues/361 + yearly + 0.6 + + + https://kernel.chat/issues/362 + yearly + 0.6 + + + https://kernel.chat/issues/363 + yearly + 0.6 + + + https://kernel.chat/issues/364 + yearly + 0.6 + + + https://kernel.chat/issues/365 + yearly + 0.6 + + + https://kernel.chat/issues/366 + yearly + 0.6 + + + https://kernel.chat/issues/367 + yearly + 0.6 + + + https://kernel.chat/issues/368 + yearly + 0.6 + + + https://kernel.chat/issues/369 + yearly + 0.6 + + + https://kernel.chat/issues/370 + yearly + 0.6 + + + https://kernel.chat/issues/371 + yearly + 0.6 + + + https://kernel.chat/issues/372 + yearly + 0.6 + + + https://kernel.chat/issues/373 + yearly + 0.6 + + + https://kernel.chat/issues/374 + yearly + 0.6 + + + https://kernel.chat/issues/375 + yearly + 0.6 + + + https://kernel.chat/issues/376 + yearly + 0.6 + + + https://kernel.chat/issues/377 + yearly + 0.6 + + + https://kernel.chat/issues/378 + yearly + 0.6 + + + https://kernel.chat/issues/379 + yearly + 0.6 + + + https://kernel.chat/issues/380 + yearly + 0.6 + + + https://kernel.chat/issues/381 + yearly + 0.6 + + + https://kernel.chat/issues/382 + yearly + 0.6 + + + https://kernel.chat/issues/383 + yearly + 0.6 + + + https://kernel.chat/issues/384 + yearly + 0.6 + + + https://kernel.chat/issues/385 + yearly + 0.6 + + + https://kernel.chat/issues/386 + yearly + 0.6 + + + https://kernel.chat/issues/387 + yearly + 0.6 + + + https://kernel.chat/issues/388 + yearly + 0.6 + + + https://kernel.chat/issues/389 + yearly + 0.6 + + + https://kernel.chat/issues/390 + yearly + 0.6 + + + https://kernel.chat/issues/391 + yearly + 0.6 + + + https://kernel.chat/issues/392 + yearly + 0.6 + + + https://kernel.chat/issues/393 + yearly + 0.6 + + + https://kernel.chat/issues/394 + yearly + 0.6 + + + https://kernel.chat/issues/395 + yearly + 0.6 + + + https://kernel.chat/issues/396 + yearly + 0.6 + + + https://kernel.chat/issues/397 + yearly + 0.6 + + + https://kernel.chat/issues/398 + yearly + 0.6 + + + https://kernel.chat/issues/399 + yearly + 0.6 + + + https://kernel.chat/issues/400 + yearly + 0.6 + + + https://kernel.chat/issues/401 + yearly + 0.6 + + + https://kernel.chat/issues/402 + yearly + 0.6 + + + https://kernel.chat/issues/403 + yearly + 0.6 + + + https://kernel.chat/issues/404 + yearly + 0.6 + + + https://kernel.chat/issues/405 + yearly + 0.6 + + + https://kernel.chat/issues/406 + yearly + 0.6 + + + https://kernel.chat/issues/407 + yearly + 0.6 + + + https://kernel.chat/issues/408 + yearly + 0.6 + + + https://kernel.chat/issues/409 + yearly + 0.6 + + + https://kernel.chat/issues/410 + yearly + 0.6 + + + https://kernel.chat/issues/411 + yearly + 0.6 + + + https://kernel.chat/issues/412 + yearly + 0.6 + + + https://kernel.chat/issues/413 + yearly + 0.6 + + + https://kernel.chat/issues/414 + yearly + 0.6 + + + https://kernel.chat/issues/415 + yearly + 0.6 + + + https://kernel.chat/issues/416 + yearly + 0.6 + + + https://kernel.chat/issues/417 + yearly + 0.6 + + + https://kernel.chat/issues/418 + yearly + 0.6 + + + https://kernel.chat/issues/419 + yearly + 0.6 + + + https://kernel.chat/issues/420 + yearly + 0.6 + + + https://kernel.chat/issues/421 + yearly + 0.6 + + + https://kernel.chat/issues/422 + yearly + 0.6 + + + https://kernel.chat/issues/423 + yearly + 0.6 + + + https://kernel.chat/issues/424 + yearly + 0.6 + + + https://kernel.chat/issues/425 + yearly + 0.6 + + + https://kernel.chat/issues/426 + yearly + 0.6 + + + https://kernel.chat/issues/427 + yearly + 0.6 + diff --git a/scripts/build-sitemap.mjs b/scripts/build-sitemap.mjs new file mode 100644 index 000000000..e1673be4c --- /dev/null +++ b/scripts/build-sitemap.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Build public/sitemap.xml from the issue registry. + * + * Companion to build-artifact-index.mjs: paths went real when the + * router left hash mode, so the sitemap enumerates every routed + * surface plus one entry per catalogued issue. CI runs it before the + * build; run it locally after pressing an issue. Idempotent. + */ +import { readdirSync, writeFileSync } from 'node:fs' + +const SITE = 'https://kernel.chat' +const ISSUES_DIR = 'src/content/issues' +const OUT = 'public/sitemap.xml' + +const STATIC_ROUTES = [ + { path: '/', priority: '1.0', changefreq: 'weekly' }, + { path: '/issues', priority: '0.9', changefreq: 'weekly' }, + { path: '/about', priority: '0.5', changefreq: 'monthly' }, + { path: '/pressroom', priority: '0.5', changefreq: 'monthly' }, + { path: '/refusals', priority: '0.4', changefreq: 'monthly' }, + { path: '/atelier', priority: '0.4', changefreq: 'monthly' }, + { path: '/artifacts/index.html', priority: '0.4', changefreq: 'weekly' }, + { path: '/privacy', priority: '0.3', changefreq: 'monthly' }, + { path: '/terms', priority: '0.3', changefreq: 'monthly' }, +] + +const issues = readdirSync(ISSUES_DIR) + .map((f) => /^(\d+)\.ts$/.exec(f)?.[1]) + .filter(Boolean) + .map(Number) + .sort((a, b) => a - b) + +const url = ({ path, priority, changefreq }) => + [ + ' ', + ` ${SITE}${path}`, + ` ${changefreq}`, + ` ${priority}`, + ' ', + ].join('\n') + +const body = [ + '', + '', + ...STATIC_ROUTES.map(url), + ...issues.map((n) => + url({ path: `/issues/${n}`, priority: '0.6', changefreq: 'yearly' }), + ), + '', + '', +].join('\n') + +writeFileSync(OUT, body) +console.log(`${OUT}: ${STATIC_ROUTES.length} routes + ${issues.length} issues`) diff --git a/src/components/IssueArchiveNav.tsx b/src/components/IssueArchiveNav.tsx index 8d8f206cd..ea58db6a0 100644 --- a/src/components/IssueArchiveNav.tsx +++ b/src/components/IssueArchiveNav.tsx @@ -60,7 +60,7 @@ export function IssueArchiveNav({ issue, prev, next, isCurrent = false }: IssueA
- + ALL BACK ISSUES
diff --git a/src/components/IssueColophon.tsx b/src/components/IssueColophon.tsx index 821498e40..ed7b196b2 100644 --- a/src/components/IssueColophon.tsx +++ b/src/components/IssueColophon.tsx @@ -37,13 +37,13 @@ export function IssueColophon({ issue }: IssueColophonProps) {
- About - Made to Order - Back Issues - Figma Spec - The Refusals - Privacy - Terms + About + Made to Order + Back Issues + Figma Spec + The Refusals + Privacy + Terms

diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 0c583b133..3155eb38c 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -46,10 +46,15 @@ export function Layout() { }, [location.pathname]) return ( -

-
- -
-
+ <> + + Skip to the feature well + +
+
+ +
+
+ ) } diff --git a/src/components/LoginGate.tsx b/src/components/LoginGate.tsx index 5eaf42811..5d6e7ce6f 100644 --- a/src/components/LoginGate.tsx +++ b/src/components/LoginGate.tsx @@ -239,9 +239,9 @@ export function LoginGate() {

{t('modal.legalAgreement')}{' '} - {t('modal.termsLink')} + {t('modal.termsLink')} {' & '} - {t('modal.privacyLink')} + {t('modal.privacyLink')}

)} diff --git a/src/components/MagazineFrame.tsx b/src/components/MagazineFrame.tsx index e37675db5..bb3cda6f8 100644 --- a/src/components/MagazineFrame.tsx +++ b/src/components/MagazineFrame.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' import { ISSUE } from '../content/issue' import type { IssueRecord } from '../content/issues' import { PopIcon } from './ornaments' @@ -58,12 +59,13 @@ export function MagazineFrame({ return () => { document.body.classList.remove('ka-scrollable-page') } }, []) + const navigate = useNavigate() const stockClass = `pop-stock-${stock}` const issue = issueOverride ?? ISSUE const folio = page === undefined ? kicker : `${kicker} · P. ${String(page).padStart(2, '0')}` const goHome = () => { - window.location.hash = '#/' + navigate('/') } return ( @@ -126,13 +128,13 @@ export function MagazineFrame({ > ← BACK TO COVER - + ABOUT → - + MADE TO ORDER → - + ISSUES → diff --git a/src/components/PreviouslyStrip.tsx b/src/components/PreviouslyStrip.tsx index 636227809..de183ec5a 100644 --- a/src/components/PreviouslyStrip.tsx +++ b/src/components/PreviouslyStrip.tsx @@ -14,7 +14,7 @@ interface PreviouslyStripProps { */ export function PreviouslyStrip({ previous }: PreviouslyStripProps) { return ( - + PREVIOUSLY ISSUE {previous.number} · {previous.month} {previous.year} diff --git a/src/components/discovery/SearchBar.tsx b/src/components/discovery/SearchBar.tsx deleted file mode 100644 index c252ec7d5..000000000 --- a/src/components/discovery/SearchBar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// ─── Search Bar ──────────────────────────────────────────────── -// -// Debounced full-text search input for the explore page. - -import { useState, useCallback, useRef, useEffect } from 'react' - -interface SearchBarProps { - value: string - onSearch: (query: string) => void - placeholder?: string - debounceMs?: number -} - -export function SearchBar({ - value, - onSearch, - placeholder = 'Search articles...', - debounceMs = 400, -}: SearchBarProps) { - const [localValue, setLocalValue] = useState(value) - const timerRef = useRef | null>(null) - - useEffect(() => { - setLocalValue(value) - }, [value]) - - const handleChange = useCallback((e: React.ChangeEvent) => { - const v = e.target.value - setLocalValue(v) - - if (timerRef.current) clearTimeout(timerRef.current) - timerRef.current = setTimeout(() => { - onSearch(v.trim()) - }, debounceMs) - }, [onSearch, debounceMs]) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - if (timerRef.current) clearTimeout(timerRef.current) - if (localValue.trim()) onSearch(localValue.trim()) - } - }, [localValue, onSearch]) - - const handleClear = useCallback(() => { - setLocalValue('') - if (timerRef.current) clearTimeout(timerRef.current) - onSearch('') - }, [onSearch]) - - return ( -
- {'\u2315'} - - {localValue && ( - - )} -
- ) -} diff --git a/src/components/discovery/TopicGrid.tsx b/src/components/discovery/TopicGrid.tsx deleted file mode 100644 index 61d6fab75..000000000 --- a/src/components/discovery/TopicGrid.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// ─── Topic Grid ──────────────────────────────────────────────── -// -// Grid of popular topic tags. Clicking a tag filters the feed. - -interface TopicGridProps { - topics: string[] - activeTopic: string | null - onSelect: (topic: string) => void -} - -export function TopicGrid({ topics, activeTopic, onSelect }: TopicGridProps) { - if (topics.length === 0) return null - - return ( -
- {topics.map(topic => ( - - ))} -
- ) -} diff --git a/src/content/issues/PUBLISHING.md b/src/content/issues/PUBLISHING.md index 47cc089c5..0eacf3485 100644 --- a/src/content/issues/PUBLISHING.md +++ b/src/content/issues/PUBLISHING.md @@ -360,24 +360,30 @@ Browse: ## VII. Publishing **Main is the only publisher.** Pushing to `main` triggers the CI -deployer (`.github/workflows/deploy.yml`), which type-checks, builds -with the production secrets, and publishes `dist/` to `gh-pages` -stamped with the source commit. Live within ~90 seconds. +deployer (`.github/workflows/deploy.yml`), which runs the law gates, +type-checks, builds with the production secrets, regenerates the +sitemap and artifact index, and publishes `dist/` to Cloudflare +Pages (project `kernel-chat`). Live within ~90 seconds. ```bash git push origin main # that IS the deploy ``` -**Verify by provenance, not asset hashes** — CI builds carry env -secrets a local build lacks, so the same commit produces different -asset fingerprints in different environments. Check that gh-pages -was built from your commit: +**Verify by reading the press, not asset hashes** — CI builds carry +env secrets a local build lacks, so the same commit produces +different asset fingerprints in different environments. Check the +run and the live routes: ```bash -git fetch origin gh-pages -git log FETCH_HEAD --oneline -1 # message reads "deploy: " +gh run list --workflow=deploy.yml --limit 1 # completed · success +curl -s -o /dev/null -w '%{http_code}\n' https://kernel.chat/issues ``` +Deep links serve real 200s — the SPA fallback is `public/_redirects` +(`/* /index.html 200`); real files (artifacts, plates, sitemap) are +served ahead of it. Legacy `/#/…` citations resolve client-side via +`src/utils/legacyHashRedirect.ts`. + **Emergency re-publish** (CI hiccup, cache poison): re-run the same pipeline by hand — `gh workflow run deploy.yml`. There is no other path: the manual `npm run deploy` script was **retired** per @@ -385,6 +391,16 @@ ISSUE 399's own prescription (two uncoordinated writers on one target; last write wins). The script now prints a refusal and points here. +**Rollback to GitHub Pages** (Cloudflare outage or reversed +cutover): `gh workflow run deploy-gh-pages.yml`, then point +kernel.chat DNS back at GitHub Pages. The `gh-pages` branch, +`public/404.html` hash shim, and `public/CNAME` are kept for exactly +this path. + +**Domain cutover state:** kernel.chat is a custom domain on the +Cloudflare Pages project; the DNS records point at Pages. The +GitHub Pages site remains configured but unrouted. + ### If your branch is NOT main Nothing you do on a feature branch touches the live site — only a diff --git a/src/hooks/useSynthesisState.ts b/src/hooks/useSynthesisState.ts deleted file mode 100644 index 432e5d8ea..000000000 --- a/src/hooks/useSynthesisState.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { supabase } from '../engine/SupabaseClient' - -export interface SkillMapEntry { - agent: string - overall: { mu: number; sigma: number; confidence: string } - categories: Record - status: 'proven' | 'developing' | 'untested' -} - -export interface ActiveCorrection { - rule: string - source: 'explicit' | 'reflection' | 'pattern_failure' - severity: 'high' | 'medium' | 'low' - occurrences: number -} - -export interface ToolAdoption { - name: string - url: string - stars: number - reason: string - status: 'evaluated' | 'adopted' | 'rejected' -} - -export interface PaperInsight { - title: string - technique: string - applicableTo: string - status: 'proposed' | 'implemented' | 'rejected' -} - -export interface SynthesisData { - totalCycles: number - lastCycleAt: string - stats: Record - skillMap: SkillMapEntry[] - activeCorrections: ActiveCorrection[] - toolAdoptions: ToolAdoption[] - paperInsights: PaperInsight[] - discoveryState: { - stats?: Record - knownStars?: number - knownDownloads?: number - hnScore?: number - } - pulseData: Record - learningSummary: { - patterns_count?: number - solutions_count?: number - reflections_count?: number - routing_entries?: number - total_messages?: number - sessions?: number - observer_total?: number - task_patterns?: Record - preferred_agents?: Record - } - crossPollinatedCount: number - updatedAt: string -} - -export function useSynthesisState(pollMs = 30_000) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - - const fetchData = useCallback(async () => { - try { - const { data: row } = await supabase - .from('kbot_synthesis_state') - .select('*') - .eq('instance_id', 'primary') - .single() - - if (!row) return - - setData({ - totalCycles: row.total_cycles ?? 0, - lastCycleAt: row.last_cycle_at ?? '', - stats: row.stats ?? {}, - skillMap: row.skill_map ?? [], - activeCorrections: row.active_corrections ?? [], - toolAdoptions: row.tool_adoptions ?? [], - paperInsights: row.paper_insights ?? [], - discoveryState: row.discovery_state ?? {}, - pulseData: row.pulse_data ?? {}, - learningSummary: row.learning_summary ?? {}, - crossPollinatedCount: row.cross_pollinated_count ?? 0, - updatedAt: row.updated_at ?? '', - }) - setLoading(false) - } catch { /* silent — dashboard is non-critical */ } - }, []) - - useEffect(() => { - fetchData() - const id = setInterval(fetchData, pollMs) - return () => clearInterval(id) - }, [fetchData, pollMs]) - - return { data, loading } -} diff --git a/src/index.css b/src/index.css index 65ac75c47..9f740bb9a 100644 --- a/src/index.css +++ b/src/index.css @@ -29495,3 +29495,27 @@ h4 { } } + +/* ── Skip link — the keyboard reader's shortcut past the masthead ── + Off-canvas until focused; surfaces in folio grammar on Tab. */ +.pop-skip-link { + position: fixed; + top: -100px; + left: 16px; + z-index: 1000; + padding: 12px 18px; + background: var(--pop-ink); + color: var(--pop-cream); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + text-decoration: none; + border: 1px solid var(--pop-tomato); + transition: top 120ms ease; +} +.pop-skip-link:focus { + top: 16px; + outline: 2px solid var(--pop-tomato); + outline-offset: 2px; +} diff --git a/src/main.tsx b/src/main.tsx index 4c8357a48..cdad08ed5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -41,8 +41,8 @@ if (hash && hash.includes('access_token=')) { await supabase.auth.setSession({ access_token, refresh_token }) } - // Replace hash with clean route so the router works - window.history.replaceState({}, '', window.location.pathname + '#/') + // Replace hash with the clean path so the router works + window.history.replaceState({}, '', window.location.pathname) } // Also handle PKCE ?code= in query params @@ -58,7 +58,7 @@ if (searchParams.has('code')) { } else { console.log('[Auth] PKCE exchange success') // Only clean URL on success - window.history.replaceState({}, '', window.location.pathname + (window.location.hash || '#/')) + window.history.replaceState({}, '', window.location.pathname + window.location.hash) } } catch (err) { console.error('[Auth] PKCE exchange threw:', err) @@ -171,7 +171,7 @@ deferLoad(() => { posthog.init(POSTHOG_KEY, { api_host: 'https://us.i.posthog.com', autocapture: true, - capture_pageview: true, + capture_pageview: 'history_change', persistence: 'localStorage', }) }) diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx deleted file mode 100644 index 0a5a1cd29..000000000 --- a/src/pages/AdminPage.tsx +++ /dev/null @@ -1,1228 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' -import { motion } from 'motion/react' -import { Navigate, useNavigate } from 'react-router-dom' -import { IconArrowLeft, IconUsers, IconChats, IconBrain, IconTrendingUp, IconCrown, IconActivity, IconDownload, IconTrash, IconShieldCheck, IconShieldOff, IconSearch, IconRefresh, IconChevronRight, IconStar } from '../components/KernelIcons' -import { useAuthContext } from '../providers/AuthProvider' -import { supabase } from '../engine/SupabaseClient' - -// ─── Types ────────────────────────────────────────── -interface UserRow { - id: string - email: string - created_at: string - provider: string - subscription?: string - messageCount: number - memoryProfile?: Record - kgEntityCount?: number -} - -interface ConversationRow { - id: string - title: string | null - created_at: string - updated_at: string -} - -interface MessageRow { - id: string - agent_id: string - content: string - created_at: string -} - -interface StorageUsage { - userId: string - email: string - totalBytes: number - fileCount: number -} - -interface ScoreSummary { - user_id: string - email: string - total_submissions: number - avg_score: number | null - avg_project: number | null - avg_session: number | null - avg_work: number | null - latest_score_at: string -} - -interface ScoreEntry { - id: string - user_id: string - email: string - conversation_id: string | null - score_type: string - score: number - notes: string | null - created_at: string -} - -interface ParsedScoreNotes { - categories: { e: number; rd: number; qa: number; p: number; d: number; l: number } - market: { label: string; multiplier: number } - relevance: { label: string; multiplier: number } - rd: { complexity: string; multiplier: number } - webMultiplier: number - tier: string - total: number - tax: number - stripeFee: number - subtotal: number -} - -function parseScoreNotes(notes: string | null): ParsedScoreNotes | null { - if (!notes) return null - const parts = notes.split(' | ').map(s => s.trim()) - if (parts.length < 6) return null - try { - // "E15 RD12 QA14 P10 D8 L7" - const catMatch = parts[0].match(/E(\d+)\s+RD(\d+)\s+QA(\d+)\s+P(\d+)\s+D(\d+)\s+L(\d+)/) - if (!catMatch) return null - // "Finance & Banking ×1.8" - const marketMatch = parts[1].match(/^(.+?)\s+×([\d.]+)$/) - // "Focused ×1.28" - const relMatch = parts[2].match(/^(.+?)\s+×([\d.]+)$/) - // "Significant R&D ×1.25" - const rdMatch = parts[3].match(/^(.+?)\s+×([\d.]+)$/) - // "Web ×1.1" - const webMatch = parts[4].match(/×([\d.]+)/) - // "Premium $34,267" - const tierMatch = parts[5].match(/^(\w+)\s+\$([\d,]+)$/) - // "tax$2997 fee$1024 sub$34267" (optional 7th segment) - let tax = 0, stripeFee = 0, subtotal = 0 - if (parts[6]) { - const taxMatch = parts[6].match(/tax\$(\d+)/) - const feeMatch = parts[6].match(/fee\$(\d+)/) - const subMatch = parts[6].match(/sub\$(\d+)/) - tax = +(taxMatch?.[1] || 0) - stripeFee = +(feeMatch?.[1] || 0) - subtotal = +(subMatch?.[1] || 0) - } - return { - categories: { - e: +catMatch[1], rd: +catMatch[2], qa: +catMatch[3], - p: +catMatch[4], d: +catMatch[5], l: +catMatch[6], - }, - market: { label: marketMatch?.[1] || '—', multiplier: +(marketMatch?.[2] || 1) }, - relevance: { label: relMatch?.[1] || '—', multiplier: +(relMatch?.[2] || 1) }, - rd: { complexity: rdMatch?.[1] || '—', multiplier: +(rdMatch?.[2] || 1) }, - webMultiplier: +(webMatch?.[1] || 1), - tier: tierMatch?.[1] || '—', - total: +(tierMatch?.[2]?.replace(/,/g, '') || 0), - tax, - stripeFee, - subtotal, - } - } catch { - return null - } -} - -interface Stats { - totalUsers: number - subscribers: number - messages: number - conversations: number - memoryProfiles: number - qualitySignals: number - helpfulRate: number - mrr: number - kgEntities: number - kgRelations: number -} - -interface ActivityEntry { - type: 'message' | 'signup' | 'subscription' - email: string - detail: string - timestamp: string -} - -// ─── Component ────────────────────────────────────── -export function AdminPage() { - const { isAdmin } = useAuthContext() - const navigate = useNavigate() - const [users, setUsers] = useState([]) - const [stats, setStats] = useState({ - totalUsers: 0, subscribers: 0, messages: 0, - conversations: 0, memoryProfiles: 0, qualitySignals: 0, - helpfulRate: 0, mrr: 0, kgEntities: 0, kgRelations: 0, - }) - const [selectedUser, setSelectedUser] = useState(null) - const [insights, setInsights] = useState([]) - const [activity, setActivity] = useState([]) - const [loading, setLoading] = useState(true) - const [refreshing, setRefreshing] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [userKGEntities, setUserKGEntities] = useState([]) - const [modQueue, setModQueue] = useState([]) - const [modLoading, setModLoading] = useState(false) - const [userConversations, setUserConversations] = useState([]) - const [selectedConvId, setSelectedConvId] = useState(null) - const [convMessages, setConvMessages] = useState([]) - const [convsLoading, setConvsLoading] = useState(false) - const [storageUsage, setStorageUsage] = useState([]) - const [scoreSummaries, setScoreSummaries] = useState([]) - const [scoreEntries, setScoreEntries] = useState([]) - const [scoresExpanded, setScoresExpanded] = useState(false) - const [sendingFile, setSendingFile] = useState(false) - const [sendFileStatus, setSendFileStatus] = useState(null) - // Invoicing: minimum score to bill, margin multiplier - const INVOICE_THRESHOLD = 70 // minimum avg score to invoice (must hit 70+) - const MARGIN_MULTIPLIER = 3 // 3x markup on ideas marketplace - - if (!isAdmin) return - - const fetchData = useCallback(async () => { - const [ - { count: msgCount }, - { count: convCount }, - { count: memCount }, - { count: sigCount }, - { count: helpfulCount }, - { count: kgEntCount }, - { count: kgRelCount }, - { data: subsData }, - { data: insightsData }, - { data: memoryData }, - { data: msgData }, - { data: recentMsgs }, - { data: kgPerUser }, - ] = await Promise.all([ - supabase.from('messages').select('id', { count: 'exact', head: true }), - supabase.from('conversations').select('id', { count: 'exact', head: true }), - supabase.from('user_memory').select('user_id', { count: 'exact', head: true }), - supabase.from('response_signals').select('id', { count: 'exact', head: true }), - supabase.from('response_signals').select('id', { count: 'exact', head: true }).eq('response_quality', 'helpful'), - supabase.from('knowledge_graph_entities').select('id', { count: 'exact', head: true }), - supabase.from('knowledge_graph_relations').select('id', { count: 'exact', head: true }), - supabase.from('subscriptions').select('user_id, status'), - supabase.from('collective_insights').select('*').order('strength', { ascending: false }).limit(10), - supabase.from('user_memory').select('*'), - supabase.from('messages').select('user_id'), - supabase.from('messages').select('user_id, agent_id, content, created_at').order('created_at', { ascending: false }).limit(20), - supabase.from('knowledge_graph_entities').select('user_id'), - ]) - - // Count messages per user - const msgCountMap: Record = {} - for (const m of msgData || []) { - if (m.user_id) msgCountMap[m.user_id] = (msgCountMap[m.user_id] || 0) + 1 - } - - // KG entities per user - const kgCountMap: Record = {} - for (const e of kgPerUser || []) { - if (e.user_id) kgCountMap[e.user_id] = (kgCountMap[e.user_id] || 0) + 1 - } - - // Subscription map - const subMap: Record = {} - let activeSubs = 0 - for (const s of subsData || []) { - subMap[s.user_id] = s.status - if (s.status === 'active') activeSubs++ - } - - // Memory map - const memMap: Record = {} - for (const m of memoryData || []) { - memMap[m.user_id] = m.profile - } - - // Try to get auth users - let authUsers: UserRow[] = [] - try { - const { data: authData } = await supabase.auth.admin.listUsers() - authUsers = (authData?.users || []).map(u => ({ - id: u.id, - email: u.email || u.id.slice(0, 16), - created_at: u.created_at || '', - provider: u.app_metadata?.provider || 'email', - subscription: subMap[u.id], - messageCount: msgCountMap[u.id] || 0, - memoryProfile: memMap[u.id], - kgEntityCount: kgCountMap[u.id] || 0, - })) - } catch { - // Fallback without auth admin - } - - // Sort: subscribers first, then by messages - authUsers.sort((a, b) => { - const aSub = a.subscription === 'active' ? 1 : 0 - const bSub = b.subscription === 'active' ? 1 : 0 - if (bSub !== aSub) return bSub - aSub - return b.messageCount - a.messageCount - }) - - // Build activity feed from recent messages - const userMap = new Map(authUsers.map(u => [u.id, u.email])) - const activityEntries: ActivityEntry[] = (recentMsgs || []).map(m => ({ - type: 'message' as const, - email: userMap.get(m.user_id) || m.user_id.slice(0, 8), - detail: m.agent_id === 'user' - ? (m.content || '').slice(0, 60) + ((m.content?.length || 0) > 60 ? '...' : '') - : `${m.agent_id} responded`, - timestamp: m.created_at, - })) - - const totalSigs = sigCount ?? 0 - const totalHelpful = helpfulCount ?? 0 - - // Storage usage per user - try { - const { data: fileRows } = await supabase - .from('user_files') - .select('user_id, size_bytes') - if (fileRows && fileRows.length > 0) { - const byUser: Record = {} - for (const f of fileRows) { - if (!byUser[f.user_id]) byUser[f.user_id] = { totalBytes: 0, fileCount: 0 } - byUser[f.user_id].totalBytes += f.size_bytes - byUser[f.user_id].fileCount++ - } - const userMap = new Map(authUsers.map(u => [u.id, u.email])) - const usage: StorageUsage[] = Object.entries(byUser) - .map(([uid, v]) => ({ userId: uid, email: userMap.get(uid) || uid.slice(0, 8), ...v })) - .sort((a, b) => b.totalBytes - a.totalBytes) - setStorageUsage(usage) - } else { - setStorageUsage([]) - } - } catch { setStorageUsage([]) } - - // Client scores - try { - const [{ data: summaries }, { data: entries }] = await Promise.all([ - supabase.rpc('get_client_score_summary'), - supabase.rpc('get_all_client_scores'), - ]) - setScoreSummaries(summaries || []) - setScoreEntries(entries || []) - } catch { - setScoreSummaries([]) - setScoreEntries([]) - } - - setUsers(authUsers) - setInsights(insightsData || []) - setActivity(activityEntries) - setStats({ - totalUsers: authUsers.length, - subscribers: activeSubs, - messages: msgCount ?? 0, - conversations: convCount ?? 0, - memoryProfiles: memCount ?? 0, - qualitySignals: totalSigs, - helpfulRate: totalSigs > 0 ? Math.round((totalHelpful / totalSigs) * 100) : 0, - mrr: activeSubs * 20, - kgEntities: kgEntCount ?? 0, - kgRelations: kgRelCount ?? 0, - }) - setLoading(false) - setRefreshing(false) - }, []) - - useEffect(() => { fetchData() }, [fetchData]) - - // Auto-refresh every 30s - useEffect(() => { - const iv = setInterval(fetchData, 30_000) - return () => clearInterval(iv) - }, [fetchData]) - - // Fetch user KG entities when selecting a user - useEffect(() => { - if (!selectedUser) { setUserKGEntities([]); return } - supabase - .from('knowledge_graph_entities') - .select('name, entity_type, confidence, mention_count') - .eq('user_id', selectedUser.id) - .order('mention_count', { ascending: false }) - .limit(12) - .then(({ data }) => setUserKGEntities(data || [])) - }, [selectedUser?.id]) - - // Fetch conversations for selected user - const fetchUserConversations = useCallback(async (userId: string) => { - setConvsLoading(true) - setSelectedConvId(null) - setConvMessages([]) - const { data } = await supabase - .from('conversations') - .select('id, title, created_at, updated_at') - .eq('user_id', userId) - .order('updated_at', { ascending: false }) - .limit(50) - setUserConversations(data || []) - setConvsLoading(false) - }, []) - - // Fetch messages for selected conversation - const fetchConvMessages = useCallback(async (convId: string) => { - const { data } = await supabase - .from('messages') - .select('id, agent_id, content, created_at') - .eq('channel_id', convId) - .order('created_at', { ascending: true }) - .limit(200) - setConvMessages(data || []) - }, []) - - // ── Admin Actions ── - const grantSubscription = async (userId: string) => { - await supabase.from('subscriptions').upsert({ - user_id: userId, - status: 'active', - stripe_customer_id: `admin_grant_${Date.now()}`, - }, { onConflict: 'user_id' }) - fetchData() - } - - const revokeSubscription = async (userId: string) => { - await supabase.from('subscriptions') - .update({ status: 'canceled' }) - .eq('user_id', userId) - fetchData() - } - - const deleteUserConversations = async (userId: string) => { - if (!confirm(`Delete ALL conversations for ${selectedUser?.email}?`)) return - const { data: convs } = await supabase - .from('conversations').select('id').eq('user_id', userId) - if (convs) { - for (const c of convs) { - await supabase.from('messages').delete().eq('channel_id', c.id) - } - await supabase.from('conversations').delete().eq('user_id', userId) - } - await supabase.from('user_memory').delete().eq('user_id', userId) - await supabase.from('knowledge_graph_entities').delete().eq('user_id', userId) - await supabase.from('knowledge_graph_relations').delete().eq('user_id', userId) - fetchData() - setSelectedUser(null) - } - - // ── Stripe Invoice ── - const createStripeInvoice = async ( - targetUserId: string, - email: string, - amount: number, - tier: string, - parsed: ParsedScoreNotes | null, - ) => { - if (!confirm(`Create Stripe invoice for ${email}?\n\nAmount: $${amount.toLocaleString()} (${tier} Tier)\n\nThis will send a real invoice via Stripe.`)) return - try { - const { data: { session } } = await supabase.auth.getSession() - const res = await fetch( - `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/admin-invoice`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${session?.access_token}`, - apikey: import.meta.env.VITE_SUPABASE_KEY || '', - }, - body: JSON.stringify({ - target_user_id: targetUserId, - email, - amount_cents: amount * 100, - description: `Kernel ${tier} Tier — Project Invoice`, - metadata: parsed ? { - tier, - market: parsed.market.label, - market_mult: parsed.market.multiplier, - relevance: parsed.relevance.label, - rd: parsed.rd.complexity, - subtotal: parsed.subtotal, - tax: parsed.tax, - stripe_fee: parsed.stripeFee, - } : {}, - }), - }, - ) - const result = await res.json() - if (result.success) { - const openDashboard = confirm(`Draft invoice created for ${email}.\n\nInvoice ID: ${result.invoice_id}\n\nOpen Stripe dashboard to review and send?`) - if (openDashboard) { - window.open(result.dashboard_url, '_blank') - } - } else { - alert(`Invoice failed: ${result.error}`) - } - } catch (err) { - alert(`Invoice error: ${String(err)}`) - } - } - - // ── Send File to User ── - const sendFileToUser = async (targetUserId: string) => { - const input = document.createElement('input') - input.type = 'file' - input.onchange = async () => { - const file = input.files?.[0] - if (!file) return - if (file.size > 50 * 1024 * 1024) { - setSendFileStatus('File too large (50MB max)') - return - } - setSendingFile(true) - setSendFileStatus(null) - try { - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - const result = reader.result as string - resolve(result.split(',')[1]) // strip data:...;base64, - } - reader.onerror = reject - reader.readAsDataURL(file) - }) - const { data: { session } } = await supabase.auth.getSession() - const res = await fetch( - `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/admin-send-file`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${session?.access_token}`, - apikey: import.meta.env.VITE_SUPABASE_KEY || '', - }, - body: JSON.stringify({ - target_user_id: targetUserId, - filename: file.name, - mime_type: file.type || 'application/octet-stream', - file_base64: base64, - }), - }, - ) - const result = await res.json() - if (result.success) { - setSendFileStatus(`Sent "${file.name}" to ${result.target_email} (Inbox folder)`) - } else { - setSendFileStatus(`Error: ${result.error}`) - } - } catch (err) { - setSendFileStatus(`Failed: ${String(err)}`) - } finally { - setSendingFile(false) - } - } - input.click() - } - - // ── Moderation Queue ── - const fetchModQueue = useCallback(async () => { - setModLoading(true) - try { - const { data } = await supabase - .from('content_moderation') - .select(` - id, content_id, status, verdict, review_note, created_at, - content_items!inner (title, slug, author_name, user_id, tags) - `) - .in('status', ['pending', 'flagged']) - .order('created_at', { ascending: false }) - .limit(50) - setModQueue(data || []) - } catch { - // non-critical - } finally { - setModLoading(false) - } - }, []) - - useEffect(() => { fetchModQueue() }, [fetchModQueue]) - - const moderateContent = async (contentId: string, moderationId: string, action: 'approved' | 'rejected', note?: string) => { - const { user } = (await supabase.auth.getUser()).data || {} - await supabase.from('content_moderation') - .update({ - status: action, - reviewed_by: user?.id, - review_note: note || null, - updated_at: new Date().toISOString(), - }) - .eq('id', moderationId) - - await supabase.from('content_items') - .update({ moderation_status: action }) - .eq('id', contentId) - - fetchModQueue() - } - - const exportUsersCSV = () => { - const header = 'email,provider,subscription,messages,kg_entities,joined' - const rows = users.map(u => - `${u.email},${u.provider},${u.subscription || 'free'},${u.messageCount},${u.kgEntityCount || 0},${u.created_at}` - ) - const csv = [header, ...rows].join('\n') - const blob = new Blob([csv], { type: 'text/csv' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url; a.download = `kernel-users-${new Date().toISOString().slice(0, 10)}.csv` - a.click() - URL.revokeObjectURL(url) - } - - // Filtered users - const filteredUsers = useMemo(() => { - if (!searchQuery.trim()) return users - const q = searchQuery.toLowerCase() - return users.filter(u => - u.email.toLowerCase().includes(q) || - u.provider.toLowerCase().includes(q) || - (u.subscription === 'active' && 'pro'.includes(q)) - ) - }, [users, searchQuery]) - - const handleRefresh = () => { - setRefreshing(true) - fetchData() - } - - return ( -
- {/* Header */} -
- -

Admin Dashboard

-
- - -
-
- - {loading ? ( -
-

Loading dashboard...

-
- ) : ( - <> - {/* Metric Cards */} -
- } label="Users" value={stats.totalUsers} /> - } label="Subscribers" value={stats.subscribers} accent /> - } label="MRR" value={`$${stats.mrr}`} accent /> - } label="Messages" value={stats.messages} /> - } label="KG Entities" value={stats.kgEntities} /> - } label="Helpful Rate" value={`${stats.helpfulRate}%`} /> - {scoreSummaries.length > 0 && ( - } - label="Avg Score" - value={Math.round(scoreSummaries.reduce((sum, s) => sum + (s.avg_score ?? 0), 0) / scoreSummaries.length)} - accent - /> - )} -
- - {/* Storage Warning — users approaching 100GB */} - {storageUsage.some(u => u.totalBytes > 80 * 1024 * 1024 * 1024) && ( -
- - - Storage alert:{' '} - {storageUsage.filter(u => u.totalBytes > 80 * 1024 * 1024 * 1024).map(u => - `${u.email} (${formatBytes(u.totalBytes)})` - ).join(', ')}{' '} - approaching 100GB - -
- )} - - {/* Main Content */} -
- {/* User Directory */} -
-

USER DIRECTORY

-
- - setSearchQuery(e.target.value)} - /> -
-
- {filteredUsers.map(u => ( - setSelectedUser(u)} - initial={{ opacity: 0, x: -8 }} - animate={{ opacity: 1, x: 0 }} - > -
- - {u.subscription === 'active' && } - {u.email} - - - {u.messageCount} msgs · {u.kgEntityCount || 0} entities · {u.provider} - -
- -
- ))} - {filteredUsers.length === 0 && ( -

- {searchQuery ? 'No matching users' : 'No users yet'} -

- )} -
-
- - {/* User Detail */} -
-

USER PROFILE

- {selectedUser ? ( -
-

{selectedUser.email}

-
- {selectedUser.subscription === 'active' ? 'Active Subscriber' : 'Free'} - {selectedUser.messageCount} messages - {selectedUser.kgEntityCount || 0} entities - via {selectedUser.provider} - Joined {new Date(selectedUser.created_at).toLocaleDateString()} -
- - {/* Storage Usage */} - {(() => { - const su = storageUsage.find(s => s.userId === selectedUser.id) - return su ? ( -
- 80 * 1024 * 1024 * 1024 ? { color: '#c53030', fontWeight: 700 } : {}}> - {formatBytes(su.totalBytes)} storage - - {su.fileCount} files -
- ) : null - })()} - - {/* Admin Actions */} -
- {selectedUser.subscription === 'active' ? ( - - ) : ( - - )} - - -
- {sendFileStatus && ( -
- {sendFileStatus} -
- )} - - {/* User's KG Entities */} - {userKGEntities.length > 0 && ( -
-

KNOWLEDGE GRAPH

-
- {userKGEntities.map((e, i) => ( - - {e.name} - {e.mention_count}x - - ))} -
-
- )} - - {/* View Conversations */} -
- - - {userConversations.length > 0 && ( -
-

- CONVERSATIONS ({userConversations.length}) -

-
- {userConversations.map(c => ( -
{ setSelectedConvId(c.id); fetchConvMessages(c.id) }} - style={{ padding: '8px 12px', cursor: 'pointer' }} - > -
- - {c.title || 'Untitled'} - - - {new Date(c.updated_at).toLocaleDateString()} - -
-
- ))} -
-
- )} - - {/* Message thread */} - {selectedConvId && convMessages.length > 0 && ( -
-

- MESSAGES ({convMessages.length}) -

- {convMessages.map(m => ( -
-
- {m.agent_id === 'user' ? 'User' : m.agent_id} · {new Date(m.created_at).toLocaleTimeString()} -
-
- {(m.content || '').slice(0, 2000)} - {(m.content || '').length > 2000 && '... (truncated)'} -
-
- ))} -
- )} -
- - {selectedUser.memoryProfile && Object.keys(selectedUser.memoryProfile).length > 0 ? ( -
-

LEARNED MEMORY

- {Object.entries(selectedUser.memoryProfile).map(([key, val]) => ( -
- {key}: - - {Array.isArray(val) ? val.join(', ') : String(val)} - -
- ))} -
- ) : ( -

- No learned memory yet -

- )} -
- ) : ( -

- Select a user to view their profile -

- )} -
-
- - {/* Activity Feed + Insights side by side */} -
- {/* Activity Feed */} -
-

RECENT ACTIVITY

- {activity.length > 0 ? ( -
- {activity.map((a, i) => ( -
- - {relativeTime(a.timestamp)} - - {a.email} - {a.detail} -
- ))} -
- ) : ( -

No recent activity

- )} -
- - {/* Collective Insights */} -
-

COLLECTIVE INSIGHTS

- {insights.length > 0 ? ( -
- {insights.map(i => ( -
-
- {Math.round(i.strength * 100)}% - {i.content} -
- ))} -
- ) : ( -

- No collective insights yet -

- )} -
-
- - {/* Content Moderation Queue */} -
-

CONTENT MODERATION QUEUE

- {modLoading ? ( -

Loading...

- ) : modQueue.length === 0 ? ( -

- No content pending review -

- ) : ( -
- {modQueue.map((item: any) => { - const ci = item.content_items - const verdict = item.verdict || {} - return ( -
-
- - {ci?.title || 'Untitled'} - - - by {ci?.author_name || 'Unknown'} · {item.status} - {ci?.slug && ( - <> · view - )} - - {verdict.reasoning && ( - {verdict.reasoning} - )} - - toxicity: {(verdict.toxicity ?? 0).toFixed(2)} · spam: {(verdict.spam ?? 0).toFixed(2)} · guidelines: {(verdict.guidelines ?? 0).toFixed(2)} - -
-
- - -
-
- ) - })} -
- )} -
- {/* Client Scores — Invoicing Dashboard */} -
-
-

- - CLIENT SCORES -

-
- - -
-
- - {/* Invoicing threshold bar */} - {scoreSummaries.length > 0 && ( -
- INVOICE THRESHOLD: {INVOICE_THRESHOLD}/100 - MARGIN: {MARGIN_MULTIPLIER}x - - {scoreSummaries.filter(s => (s.avg_score ?? 0) >= INVOICE_THRESHOLD).length} billable - - - {scoreSummaries.filter(s => (s.avg_score ?? 0) < INVOICE_THRESHOLD).length} below threshold - -
- )} - - {scoreSummaries.length === 0 ? ( -

- No client scores yet. Clients type kernel.hat to submit. -

- ) : scoresExpanded ? ( - /* All individual scores */ -
- {scoreEntries.map(entry => { - const parsed = parseScoreNotes(entry.notes) - return ( -
-
- {entry.score} -
-
-
{entry.email}
-
- {entry.score_type} · {new Date(entry.created_at).toLocaleDateString()} -
-
- {parsed ? ( -
- ${parsed.total.toLocaleString()} - {parsed.tier} - Mkt ×{parsed.market.multiplier} - Rel ×{parsed.relevance.multiplier} - R&D ×{parsed.rd.multiplier} - Web ×{parsed.webMultiplier} -
- ) : entry.notes ? ( -
{entry.notes}
- ) : null} -
- ) - })} -
- ) : ( - /* Summary cards per client */ -
- {scoreSummaries.map(s => { - const latestEntry = scoreEntries.find(e => e.user_id === s.user_id) - const parsed = latestEntry ? parseScoreNotes(latestEntry.notes) : null - return ( -
-
- {s.email} - - {s.avg_score ?? '—'} - -
-
-
- PROJECT - - {s.avg_project ?? '—'} - -
-
- SESSION - - {s.avg_session ?? '—'} - -
-
- WORK - - {s.avg_work ?? '—'} - -
-
- {/* Full pricing breakdown from latest score */} - {parsed && ( -
-
- ${parsed.total.toLocaleString()} - {parsed.tier} -
-
-
- Market - {parsed.market.label} - ×{parsed.market.multiplier} -
-
- Relevance - {parsed.relevance.label} - ×{parsed.relevance.multiplier} -
-
- R&D - {parsed.rd.complexity} - ×{parsed.rd.multiplier} -
-
- Web Rate - Live check - ×{parsed.webMultiplier} -
-
- {parsed.tax > 0 && ( -
- Tax - ${parsed.tax.toLocaleString()} - Stripe - ${parsed.stripeFee.toLocaleString()} - Subtotal - ${parsed.subtotal.toLocaleString()} -
- )} -
- E{parsed.categories.e} - RD{parsed.categories.rd} - QA{parsed.categories.qa} - P{parsed.categories.p} - D{parsed.categories.d} - L{parsed.categories.l} -
-
- )} -
- {s.total_submissions} submissions · last {relativeTime(s.latest_score_at)} - {(s.avg_score ?? 0) >= INVOICE_THRESHOLD ? ( - <> - BILLABLE - - - ) : ( - BELOW THRESHOLD - )} -
-
- ) - })} -
- )} -
- - )} -
- ) - - function exportScoresCSV() { - // Summary sheet with invoicing status + pricing - const summaryHeader = 'email,avg_score,avg_project,avg_session,avg_work,submissions,billable,status,latest_cost,tier,market,market_mult,relevance,rel_mult,rd_complexity,rd_mult,web_mult' - const summaryRows = scoreSummaries.map(s => { - const billable = (s.avg_score ?? 0) >= INVOICE_THRESHOLD - const latestEntry = scoreEntries.find(e => e.user_id === s.user_id) - const parsed = latestEntry ? parseScoreNotes(latestEntry.notes) : null - const p = parsed - return `${s.email},${s.avg_score ?? ''},${s.avg_project ?? ''},${s.avg_session ?? ''},${s.avg_work ?? ''},${s.total_submissions},${billable},${billable ? 'INVOICE' : 'BELOW_THRESHOLD'},${p ? p.total : ''},"${p?.tier || ''}","${p?.market.label || ''}",${p?.market.multiplier || ''},"${p?.relevance.label || ''}",${p?.relevance.multiplier || ''},"${p?.rd.complexity || ''}",${p?.rd.multiplier || ''},${p?.webMultiplier || ''}` - }) - // Detail sheet with parsed pricing - const detailHeader = '\n\nemail,score_type,score,cost,tier,market,market_mult,relevance,rel_mult,rd,rd_mult,web_mult,date' - const detailRows = scoreEntries.map(e => { - const p = parseScoreNotes(e.notes) - return `${e.email},${e.score_type},${e.score},${p ? p.total : ''},"${p?.tier || ''}","${p?.market.label || ''}",${p?.market.multiplier || ''},"${p?.relevance.label || ''}",${p?.relevance.multiplier || ''},"${p?.rd.complexity || ''}",${p?.rd.multiplier || ''},${p?.webMultiplier || ''},${e.created_at}` - }) - const csv = ['INVOICE SUMMARY', summaryHeader, ...summaryRows, detailHeader, ...detailRows].join('\n') - const blob = new Blob([csv], { type: 'text/csv' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url; a.download = `kernel-invoice-scores-${new Date().toISOString().slice(0, 10)}.csv` - a.click() - URL.revokeObjectURL(url) - } -} - -function getScoreColor(score: number): string { - if (score >= 80) return '#5a7a5a' - if (score >= 60) return '#8B7355' - if (score >= 40) return '#B8875C' - return '#a05050' -} - -// ─── Helpers ──────────────────────────────────────── -function relativeTime(dateStr: string): string { - const now = Date.now() - const then = new Date(dateStr).getTime() - const diff = now - then - const mins = Math.floor(diff / 60000) - if (mins < 1) return 'now' - if (mins < 60) return `${mins}m` - const hrs = Math.floor(mins / 60) - if (hrs < 24) return `${hrs}h` - const days = Math.floor(hrs / 24) - return `${days}d` -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` -} - -function MetricCard({ icon, label, value, accent }: { - icon: React.ReactNode - label: string - value: string | number - accent?: boolean -}) { - return ( -
-
{icon}
-
{value}
-
{label}
-
- ) -} diff --git a/src/pages/ApiDocsPage.tsx b/src/pages/ApiDocsPage.tsx deleted file mode 100644 index 6256418c6..000000000 --- a/src/pages/ApiDocsPage.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { useEffect } from 'react' - -export function ApiDocsPage() { - useEffect(() => { - document.body.classList.add('ka-scrollable-page') - return () => { document.body.classList.remove('ka-scrollable-page') } - }, []) - - return ( -
- - -

Kernel API

-

- Access Kernel's 17-agent intelligence layer via a simple REST API. - One call gets specialist routing, multi-agent collaboration, and structured responses. -

- - {/* ── K:BOT Terminal Agent ── */} -
-

K:BOT — Terminal Agent

-

- K:BOT gives you the full power of Kernel's agent system from your terminal. - It runs tools locally (file ops, git, bash) for free — only AI reasoning uses your API quota. -

-
{`# Install
-curl -fsSL https://kernel.chat/install.sh | bash
-
-# Or via npm
-npm install -g @kernel.chat/kbot
-
-# Configure
-kbot auth
-
-# Use
-kbot "fix the TypeScript errors in src/utils"
-kbot "research the latest React Server Components changes"
-kbot                    # interactive REPL`}
- -

Efficiency

-

- K:BOT is designed to conserve your tokens and messages. Simple commands (file reads, git status, directory - listings) are handled locally without any API call. The agent batches context upfront for one-shot accuracy, - reducing round trips. Tool execution always runs on your machine for free. -

-
- - {/* ── Authentication ── */} -
-

Authentication

-

- All API requests require a Bearer token using your API key. - Keys are prefixed with kn_live_ and can be managed from your account dashboard. -

-
{`curl -X POST https://eoxxpyixdieprsxlpwcs.supabase.co/functions/v1/kernel-api/chat \\
-  -H "Authorization: Bearer kn_live_your_key_here" \\
-  -H "Content-Type: application/json" \\
-  -d '{"message": "Hello"}'`}
-
- - {/* ── POST /chat ── */} -
-

POST /chat

-

Send a message and get an agent-routed response. If no agent is specified, Kernel auto-classifies your intent and routes to the best specialist.

- -

Request

-
{`{
-  "message": "Explain quantum computing",
-  "agent": "researcher",           // optional — auto-routes if omitted
-  "mode": "json",                  // "json" (default) or "stream" (SSE)
-  "system": "Be concise.",         // enterprise only — custom system prompt
-  "max_tokens": 4096,              // optional, max 8192
-  "previous_messages": [           // optional conversation context
-    { "role": "user", "content": "..." },
-    { "role": "assistant", "content": "..." }
-  ],
-  "tools": [                       // optional — tool definitions for tool-use
-    { "name": "web_search", "description": "Search the web" }
-  ],
-  "tool_results": [                // optional — results from previous tool calls
-    { "tool_call_id": "tc_1", "result": "..." }
-  ]
-}`}
- -

Response (text)

-
{`{
-  "id": "msg_uuid",
-  "agent": "researcher",
-  "type": "text",
-  "content": "Quantum computing is...",
-  "model": "claude-sonnet-4-6",
-  "usage": {
-    "input_tokens": 234,
-    "output_tokens": 1023,
-    "cost_usd": 0.018
-  },
-  "classification": {              // present when auto-routed
-    "agent_id": "researcher",
-    "confidence": 0.92,
-    "complexity": 0.45
-  }
-}`}
- -

Response (tool calls)

-

When the agent wants to use tools, the response contains tool_calls instead of content:

-
{`{
-  "id": "msg_uuid",
-  "agent": "researcher",
-  "type": "tool_calls",
-  "tool_calls": [
-    { "id": "tc_1", "name": "web_search", "arguments": { "query": "quantum computing 2026" } }
-  ],
-  "model": "claude-sonnet-4-6",
-  "usage": { ... }
-}`}
-

Execute the tool locally, then send results back via tool_results in the next request.

-
- - {/* ── POST /classify ── */} -
-

POST /classify

-

Classify intent without generating a full response. Uses collective intelligence patterns when available — may skip the classifier entirely for high-confidence matches.

- -

Request

-
{`{
-  "message": "Write a Python script to sort a list"
-}`}
- -

Response

-
{`{
-  "agent_id": "coder",
-  "confidence": 0.95,
-  "complexity": 0.3,
-  "reasoning": "Code generation request — Python scripting task"
-}`}
-
- - {/* ── POST /swarm ── */} -
-

POST /swarm

-

Multi-agent collaboration. Multiple agents contribute in parallel, then a synthesis model combines their perspectives. Requires Pro tier.

- -

Request

-
{`{
-  "message": "Design a microservices architecture for a real-time trading platform",
-  "agents": ["architect", "coder", "guardian"],
-  "synthesis_model": "sonnet"       // optional, default: sonnet
-}`}
- -

Response

-
{`{
-  "id": "msg_uuid",
-  "agents": ["architect", "coder", "guardian"],
-  "contributions": [
-    { "agent": "architect", "content": "For a real-time trading platform..." },
-    { "agent": "coder", "content": "The implementation should use..." },
-    { "agent": "guardian", "content": "Security considerations include..." }
-  ],
-  "synthesis": "Combined analysis: The optimal architecture...",
-  "model": "claude-sonnet-4-6"
-}`}
-
- - {/* ── GET /knowledge ── */} -
-

GET /knowledge

-

Query the collective intelligence — learned patterns from all API interactions. The Kernel Matrix gets smarter with every request across all users.

- -

Request

-
{`GET /knowledge?category=coder`}
- -

Response

-
{`{
-  "patterns": [
-    {
-      "type": "routing_rule",
-      "pattern": {
-        "category": "coder",
-        "agent": "coder",
-        "accuracy": 0.94,
-        "avg_confidence": 0.91
-      },
-      "confidence": 0.94,
-      "sample_count": 1247
-    }
-  ]
-}`}
-
- - {/* ── GET /agents ── */} -
-

GET /agents

-

List available agents for your API key tier.

-
{`{
-  "agents": [
-    { "id": "kernel", "name": "Kernel", "role": "General Assistant" },
-    { "id": "researcher", "name": "Researcher", "role": "Research & Analysis" },
-    { "id": "coder", "name": "Coder", "role": "Programming" },
-    { "id": "writer", "name": "Writer", "role": "Content Creation" },
-    { "id": "analyst", "name": "Analyst", "role": "Strategy & Evaluation" }
-  ],
-  "tier": "free"
-}`}
-
- - {/* ── GET /usage ── */} -
-

GET /usage

-

Get monthly usage statistics for your API key, including per-agent breakdown.

-
{`{
-  "tier": "pro",
-  "monthly_messages": { "count": 87, "limit": 200 },
-  "monthly_window_start": "2026-03-01T00:00:00Z",
-  "per_agent": {
-    "coder": { "messages": 43, "input_tokens": 12500, "output_tokens": 89000, "cost_usd": 1.37 },
-    "researcher": { "messages": 31, "input_tokens": 9800, "output_tokens": 45000, "cost_usd": 0.70 }
-  }
-}`}
-
- - {/* ── Rate Limits & Tiers ── */} -
-

Tiers & Limits

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TierPriceMessages / moAgentsSwarmK:BOT ToolsRate Limit
Free$010Core 5NoFiles, Git10/min
Pro$15/mo200All 17YesAll tools30/min
-
- - {/* ── Error Codes ── */} -
-

Error Codes

- - - - - - - - - - - - -
StatusErrorDescription
401Invalid API keyKey is missing, malformed, or revoked
403Monthly limit exceededMonthly message quota exhausted
403Pro requiredFeature requires Pro tier
429Rate limitedPer-minute rate limit exceeded. Check Retry-After header.
400Invalid requestMissing required fields or invalid agent ID
502Upstream errorAI provider returned an error
-

All errors return JSON with an error field:

-
{`{
-  "error": "rate_limited",
-  "retry_after": 12,
-  "limit": 60
-}`}
-
- - {/* ── Quick Start ── */} -
-

Quick Start

- -

K:BOT (recommended)

-
{`# Install and start using in 30 seconds
-npm install -g @kernel.chat/kbot
-kbot auth
-kbot "analyze the code quality of this repo"`}
- -

API (Node.js)

-
{`const response = await fetch(
-  'https://eoxxpyixdieprsxlpwcs.supabase.co/functions/v1/kernel-api/chat',
-  {
-    method: 'POST',
-    headers: {
-      'Authorization': 'Bearer kn_live_your_key_here',
-      'Content-Type': 'application/json',
-    },
-    body: JSON.stringify({
-      message: 'Analyze the competitive landscape for AI coding assistants',
-      agent: 'analyst',
-    }),
-  }
-)
-
-const data = await response.json()
-console.log(data.content)   // The analyst's response
-console.log(data.usage)     // { input_tokens, output_tokens, cost_usd }`}
- -

Streaming

-
{`const response = await fetch(url, {
-  method: 'POST',
-  headers: { 'Authorization': 'Bearer kn_live_...', 'Content-Type': 'application/json' },
-  body: JSON.stringify({ message: 'Write a poem', mode: 'stream' }),
-})
-
-const reader = response.body.getReader()
-const decoder = new TextDecoder()
-
-while (true) {
-  const { done, value } = await reader.read()
-  if (done) break
-  process.stdout.write(decoder.decode(value))
-}`}
- -

Multi-Agent Swarm

-
{`const response = await fetch(url + '/swarm', {
-  method: 'POST',
-  headers: { 'Authorization': 'Bearer kn_live_...', 'Content-Type': 'application/json' },
-  body: JSON.stringify({
-    message: 'Design a real-time notification system',
-    agents: ['architect', 'coder', 'guardian'],
-  }),
-})
-
-const data = await response.json()
-console.log(data.synthesis)        // Combined analysis
-data.contributions.forEach(c =>    // Individual perspectives
-  console.log(c.agent + ':', c.content)
-)`}
-
- - -
- ) -} diff --git a/src/pages/AuthorProfilePage.tsx b/src/pages/AuthorProfilePage.tsx deleted file mode 100644 index b3388e03e..000000000 --- a/src/pages/AuthorProfilePage.tsx +++ /dev/null @@ -1,123 +0,0 @@ -// ─── Author Profile Page ─────────────────────────────────────── -// -// Public author profile at /#/author/:id. Shows profile info, -// published articles, and follow button. - -import { useEffect, useState, useCallback } from 'react' -import { useParams } from 'react-router-dom' -import { useDiscovery } from '../hooks/useDiscovery' -import { AuthorCard } from '../components/discovery/AuthorCard' -import { FeedCard } from '../components/discovery/FeedCard' -import type { AuthorProfile } from '../stores/discoveryStore' -import type { FeedItem } from '../stores/discoveryStore' - -export function AuthorProfilePage() { - const { id } = useParams<{ id: string }>() - const { - getAuthorProfile, getEngagementStatus, - engagement, toggleLike, toggleBookmark, toggleFollow, - } = useDiscovery() - - const [profile, setProfile] = useState(null) - const [articles, setArticles] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [currentUserId, setCurrentUserId] = useState(null) - - // Check current user - useEffect(() => { - import('../engine/SupabaseClient').then(({ supabase }) => { - supabase.auth.getUser().then(({ data }) => { - if (data?.user) setCurrentUserId(data.user.id) - }) - }).catch(() => {}) - }, []) - - // Load profile - useEffect(() => { - if (!id) return - setLoading(true) - setError(null) - - getAuthorProfile(id) - .then(data => { - setProfile(data.profile) - setArticles(data.articles) - }) - .catch(err => setError(err.message || 'Failed to load profile')) - .finally(() => setLoading(false)) - - // Load engagement status - getEngagementStatus(undefined, id) - }, [id, getAuthorProfile, getEngagementStatus]) - - const handleBack = useCallback(() => { - if (window.history.length > 1) { - window.history.back() - } else { - window.location.hash = '#/explore' - } - }, []) - - if (loading) { - return ( -
-
Loading...
-
- ) - } - - if (error || !profile) { - return ( -
-
-

{error || 'Author not found'}

- Back to Explore -
-
- ) - } - - return ( -
- {/* Back nav */} -
- -
- - {/* Profile card */} - - - {/* Articles */} -
-

- Published Articles ({articles.length}) -

- {articles.length === 0 ? ( -

No published articles yet.

- ) : ( -
- {articles.map(item => ( - - ))} -
- )} -
-
- ) -} diff --git a/src/pages/ExplorePage.tsx b/src/pages/ExplorePage.tsx deleted file mode 100644 index ab5fb6680..000000000 --- a/src/pages/ExplorePage.tsx +++ /dev/null @@ -1,144 +0,0 @@ -// ─── Explore Page ────────────────────────────────────────────── -// -// Public discovery feed at /#/explore. Tabs for trending, recent, -// following. Full-text search. Topic filtering. - -import { useEffect, useState, useCallback } from 'react' -import { useDiscovery } from '../hooks/useDiscovery' -import { FeedCard } from '../components/discovery/FeedCard' -import { SearchBar } from '../components/discovery/SearchBar' -import { FeedTabs } from '../components/discovery/FeedTabs' -import { TopicGrid } from '../components/discovery/TopicGrid' -import type { FeedMode } from '../stores/discoveryStore' - -export function ExplorePage() { - const { - feed, feedMode, feedTopic, feedSearch, feedLoading, feedHasMore, topics, - loadFeed, loadMore, switchMode, searchContent, filterByTopic, - engagement, toggleLike, toggleBookmark, - } = useDiscovery() - - const [isAuthenticated, setIsAuthenticated] = useState(false) - - // Check auth state - useEffect(() => { - import('../engine/SupabaseClient').then(({ getAccessToken }) => { - getAccessToken().then(t => setIsAuthenticated(!!t)) - }).catch(() => {}) - }, []) - - // Load initial feed - useEffect(() => { - if (feed.length === 0 && !feedLoading) { - loadFeed('trending', { reset: true }) - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - const handleModeSwitch = useCallback((mode: FeedMode) => { - switchMode(mode) - }, [switchMode]) - - const handleSearch = useCallback((query: string) => { - if (query) { - searchContent(query) - } else { - switchMode('trending') - } - }, [searchContent, switchMode]) - - const handleTopicSelect = useCallback((topic: string) => { - if (feedTopic === topic) { - switchMode('trending') - } else { - filterByTopic(topic) - } - }, [feedTopic, filterByTopic, switchMode]) - - return ( -
- {/* Header */} -
- Kernel - -
- - {/* Tabs */} - - - {/* Topics (shown on trending mode) */} - {(feedMode === 'trending' || feedMode === 'topic') && topics.length > 0 && ( - - )} - - {/* Search result label */} - {feedMode === 'search' && feedSearch && ( -
- Results for {feedSearch} -
- )} - - {/* Feed */} -
- {feed.map(item => ( - - ))} - - {/* Empty state */} - {!feedLoading && feed.length === 0 && ( -
-

- {feedMode === 'search' - ? 'No results found' - : feedMode === 'personalized' - ? 'Follow authors to see their posts here' - : 'No published content yet' - } -

-

- {feedMode === 'search' - ? 'Try a different search term' - : 'Be the first to publish something' - } -

-
- )} - - {/* Loading */} - {feedLoading && ( -
- -
- )} - - {/* Load more */} - {feedHasMore && !feedLoading && ( - - )} -
-
- ) -} diff --git a/src/pages/LandingPage.css b/src/pages/LandingPage.css index 79dfecc5f..2bc4596c5 100644 --- a/src/pages/LandingPage.css +++ b/src/pages/LandingPage.css @@ -594,12 +594,17 @@ display: inline-flex; align-items: center; gap: 6px; - margin-top: 4px; appearance: none; -webkit-appearance: none; background: none; border: 0; - padding: 0; + /* 44px touch target; padding + negative margin keep the optical + position of the mark unchanged. */ + min-height: 44px; + padding: 14px 12px; + margin: -10px -12px; + margin-top: -6px; + white-space: nowrap; cursor: pointer; opacity: 0.6; transition: color 0.2s, opacity 0.2s; diff --git a/src/pages/LeaderboardPage.css b/src/pages/LeaderboardPage.css deleted file mode 100644 index 7c2cfa0c1..000000000 --- a/src/pages/LeaderboardPage.css +++ /dev/null @@ -1,578 +0,0 @@ -/* Buddy Leaderboard */ - -.ka-leaderboard { - min-height: 100vh; - background: #0d0d0d; - color: #e8e6e3; - font-family: 'EB Garamond', Georgia, serif; - overflow-x: hidden; -} - -/* Hero */ -.ka-lb-hero { - text-align: center; - padding: 100px 24px 64px; - max-width: 800px; - margin: 0 auto; -} - -.ka-lb-badge { - display: inline-block; - font-family: 'Courier Prime', monospace; - font-size: 12px; - letter-spacing: 1.5px; - text-transform: uppercase; - color: #6B5B95; - border: 1px solid #6B5B9540; - border-radius: 20px; - padding: 4px 16px; - margin-bottom: 24px; -} - -.ka-lb-title { - font-family: 'Courier Prime', monospace; - font-size: clamp(32px, 7vw, 56px); - font-weight: 700; - color: #FAF9F6; - margin: 0 0 12px; - letter-spacing: -1px; - line-height: 1.1; -} - -.ka-lb-subtitle { - font-size: clamp(16px, 3vw, 20px); - color: #aaa; - margin: 0 0 40px; - line-height: 1.5; -} - -/* Summary stats */ -.ka-lb-summary { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; - max-width: 520px; - margin: 0 auto 16px; -} - -.ka-lb-summary-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 20px 12px; - background: #141414; - border: 1px solid #222; - border-radius: 10px; - transition: border-color 0.2s; -} - -.ka-lb-summary-card:hover { - border-color: #6B5B9540; -} - -.ka-lb-summary-value { - font-family: 'Courier Prime', monospace; - font-size: 28px; - font-weight: 700; - color: #6B5B95; - line-height: 1; -} - -.ka-lb-summary-label { - font-family: 'Courier Prime', monospace; - font-size: 11px; - color: #666; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* Refresh */ -.ka-lb-refresh { - display: inline-flex; - align-items: center; - gap: 8px; - background: transparent; - border: 1px solid #333; - border-radius: 6px; - padding: 6px 16px; - font-family: 'Courier Prime', monospace; - font-size: 12px; - color: #888; - cursor: pointer; - transition: all 0.2s; - margin-top: 16px; -} - -.ka-lb-refresh:hover { - border-color: #6B5B95; - color: #FAF9F6; -} - -.ka-lb-refresh:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.ka-lb-refresh-icon { - display: inline-block; - transition: transform 0.4s ease; -} - -.ka-lb-refresh:hover .ka-lb-refresh-icon { - transform: rotate(180deg); -} - -/* Divider */ -.ka-lb-divider { - height: 1px; - background: linear-gradient(90deg, transparent, #333, transparent); - max-width: 400px; - margin: 0 auto; -} - -/* Species filter tabs */ -.ka-lb-filters { - display: flex; - gap: 8px; - justify-content: center; - flex-wrap: wrap; - max-width: 720px; - margin: 0 auto 32px; - padding: 0 24px; -} - -.ka-lb-filter { - display: inline-flex; - align-items: center; - gap: 6px; - background: #141414; - border: 1px solid #222; - border-radius: 20px; - padding: 6px 16px; - font-family: 'Courier Prime', monospace; - font-size: 13px; - color: #888; - cursor: pointer; - transition: all 0.2s; - white-space: nowrap; -} - -.ka-lb-filter:hover { - border-color: #6B5B9560; - color: #ccc; -} - -.ka-lb-filter--active { - background: #6B5B9520; - border-color: #6B5B95; - color: #FAF9F6; -} - -.ka-lb-filter-emoji { - font-size: 16px; - line-height: 1; -} - -/* Main table section */ -.ka-lb-section { - max-width: 900px; - margin: 0 auto; - padding: 0 24px 60px; -} - -/* Table */ -.ka-lb-table-wrap { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - border: 1px solid #222; - border-radius: 10px; - background: #111; -} - -.ka-lb-table { - width: 100%; - border-collapse: collapse; - min-width: 640px; -} - -.ka-lb-table thead { - background: #161616; - border-bottom: 1px solid #222; -} - -.ka-lb-table th { - text-align: left; - padding: 12px 16px; - font-family: 'Courier Prime', monospace; - font-size: 11px; - color: #666; - font-weight: 400; - text-transform: uppercase; - letter-spacing: 0.5px; - white-space: nowrap; -} - -.ka-lb-table th:first-child { - width: 48px; - text-align: center; -} - -.ka-lb-table tbody tr { - border-bottom: 1px solid #1a1a1a; - transition: background 0.15s; -} - -.ka-lb-table tbody tr:last-child { - border-bottom: none; -} - -.ka-lb-table tbody tr:hover { - background: #1a1a1a; -} - -.ka-lb-table td { - padding: 12px 16px; - font-family: 'Courier Prime', monospace; - font-size: 14px; - color: #ccc; - white-space: nowrap; -} - -/* Rank column */ -.ka-lb-rank { - text-align: center; - font-weight: 700; - font-size: 16px; - color: #888; -} - -.ka-lb-rank--gold { - color: #FFD700; -} - -.ka-lb-rank--silver { - color: #C0C0C0; -} - -.ka-lb-rank--bronze { - color: #CD7F32; -} - -/* Species cell */ -.ka-lb-species { - display: flex; - align-items: center; - gap: 10px; -} - -.ka-lb-species-emoji { - font-size: 24px; - line-height: 1; - width: 32px; - text-align: center; -} - -.ka-lb-species-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.ka-lb-species-name { - font-family: 'Courier Prime', monospace; - font-size: 14px; - color: #FAF9F6; - font-weight: 700; - text-transform: capitalize; -} - -.ka-lb-species-level { - font-family: 'Courier Prime', monospace; - font-size: 11px; - color: #6B5B95; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* XP bar */ -.ka-lb-xp { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 140px; -} - -.ka-lb-xp-label { - font-family: 'Courier Prime', monospace; - font-size: 12px; - color: #888; -} - -.ka-lb-xp-track { - height: 8px; - background: #1a1a1a; - border-radius: 4px; - overflow: hidden; - border: 1px solid #222; -} - -.ka-lb-xp-bar { - height: 100%; - border-radius: 4px; - background: linear-gradient(90deg, #6B5B95, #8B7BB5); - transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); -} - -/* Achievement */ -.ka-lb-achievements { - display: flex; - align-items: center; - gap: 6px; - font-family: 'Courier Prime', monospace; - font-size: 14px; - color: #ccc; -} - -.ka-lb-trophy { - font-size: 14px; - color: #FFD700; -} - -/* Sessions */ -.ka-lb-sessions { - font-family: 'Courier Prime', monospace; - font-size: 14px; - color: #888; -} - -/* Empty state */ -.ka-lb-empty { - text-align: center; - padding: 80px 24px; - max-width: 400px; - margin: 0 auto; -} - -.ka-lb-empty-icon { - font-size: 48px; - margin-bottom: 16px; - display: block; -} - -.ka-lb-empty-title { - font-family: 'Courier Prime', monospace; - font-size: 20px; - color: #FAF9F6; - margin: 0 0 8px; -} - -.ka-lb-empty-desc { - font-size: 15px; - color: #888; - margin: 0; - line-height: 1.5; -} - -/* Loading skeleton */ -.ka-lb-skeleton-row { - display: grid; - grid-template-columns: 48px 200px 1fr 80px 80px; - gap: 16px; - align-items: center; - padding: 14px 16px; - border-bottom: 1px solid #1a1a1a; -} - -.ka-lb-skeleton-cell { - height: 14px; - background: #1a1a1a; - border-radius: 4px; - animation: ka-lb-pulse 1.5s ease-in-out infinite; -} - -.ka-lb-skeleton-cell--short { width: 32px; } -.ka-lb-skeleton-cell--medium { width: 120px; } -.ka-lb-skeleton-cell--wide { width: 100%; } - -@keyframes ka-lb-pulse { - 0%, 100% { opacity: 0.4; } - 50% { opacity: 0.8; } -} - -/* Error state */ -.ka-lb-error { - text-align: center; - padding: 60px 24px; -} - -.ka-lb-error-title { - font-family: 'Courier Prime', monospace; - font-size: 18px; - color: #FAF9F6; - margin: 0 0 8px; -} - -.ka-lb-error-desc { - font-size: 14px; - color: #888; - margin: 0 0 16px; - line-height: 1.5; -} - -.ka-lb-retry { - display: inline-flex; - align-items: center; - gap: 8px; - background: #6B5B95; - border: none; - border-radius: 6px; - padding: 8px 20px; - font-family: 'Courier Prime', monospace; - font-size: 13px; - color: #FAF9F6; - cursor: pointer; - transition: background 0.2s; -} - -.ka-lb-retry:hover { - background: #7B6BA5; -} - -/* CTA */ -.ka-lb-cta { - text-align: center; - padding: 48px 24px 80px; - max-width: 600px; - margin: 0 auto; -} - -.ka-lb-cta-text { - font-size: 16px; - color: #888; - margin: 0 0 20px; - line-height: 1.5; -} - -.ka-lb-install { - display: inline-flex; - align-items: center; - gap: 16px; - background: #1a1a1a; - border: 1px solid #333; - border-radius: 8px; - padding: 12px 20px; - cursor: pointer; - transition: border-color 0.2s; -} - -.ka-lb-install:hover { - border-color: #6B5B95; -} - -.ka-lb-install code { - font-family: 'Courier Prime', monospace; - font-size: 15px; - color: #6B5B95; -} - -.ka-lb-copy { - font-family: 'Courier Prime', monospace; - font-size: 12px; - color: #666; - border-left: 1px solid #333; - padding-left: 16px; -} - -/* Back link */ -.ka-lb-back { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: 'Courier Prime', monospace; - font-size: 13px; - color: #666; - text-decoration: none; - position: fixed; - top: 24px; - left: 24px; - z-index: 10; - transition: color 0.2s; -} - -.ka-lb-back:hover { - color: #6B5B95; -} - -/* ── Mobile ── */ -@media (max-width: 768px) { - .ka-lb-hero { - padding: 80px 20px 48px; - } - - .ka-lb-summary { - grid-template-columns: 1fr; - gap: 10px; - } - - .ka-lb-summary-card { - flex-direction: row; - justify-content: center; - gap: 12px; - padding: 14px 16px; - } - - .ka-lb-summary-value { - font-size: 22px; - } - - .ka-lb-filters { - gap: 6px; - padding: 0 16px; - } - - .ka-lb-filter { - padding: 5px 12px; - font-size: 12px; - } - - .ka-lb-section { - padding: 0 16px 40px; - } - - .ka-lb-install { - flex-direction: column; - gap: 8px; - } - - .ka-lb-copy { - border-left: none; - padding-left: 0; - } - - .ka-lb-back { - position: static; - margin: 20px 0 0 20px; - display: block; - } - - .ka-lb-skeleton-row { - grid-template-columns: 32px 1fr 80px; - } -} - -@media (max-width: 480px) { - .ka-lb-table { - font-size: 12px; - } - - .ka-lb-table th, - .ka-lb-table td { - padding: 8px 10px; - } - - .ka-lb-species-emoji { - font-size: 18px; - width: 24px; - } -} diff --git a/src/pages/LeaderboardPage.tsx b/src/pages/LeaderboardPage.tsx deleted file mode 100644 index 2a32612ce..000000000 --- a/src/pages/LeaderboardPage.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { useEffect, useState, useCallback, useMemo } from 'react' -import { Link } from 'react-router-dom' -import { motion, AnimatePresence } from 'motion/react' -import { supabase } from '../engine/SupabaseClient' -import './LeaderboardPage.css' - -// ── Types ── - -interface LeaderboardEntry { - rank: number - species: string - level: number - xp: number - achievement_count: number - sessions: number -} - -type SpeciesKey = 'fox' | 'owl' | 'cat' | 'robot' | 'ghost' | 'mushroom' | 'octopus' | 'dragon' - -// ── Species Data ── - -const SPECIES_EMOJI: Record = { - fox: '\uD83E\uDD8A', - owl: '\uD83E\uDD89', - cat: '\uD83D\uDC31', - robot: '\uD83E\uDD16', - ghost: '\uD83D\uDC7B', - mushroom: '\uD83C\uDF44', - octopus: '\uD83D\uDC19', - dragon: '\uD83D\uDC09', -} - -const SPECIES_LEVELS: Record = { - fox: ['Kit', 'Scout', 'Tracker', 'Phantom'], - owl: ['Owlet', 'Watcher', 'Sage', 'Oracle'], - cat: ['Kitten', 'Hunter', 'Shadow', 'Phantom'], - robot: ['Spark', 'Circuit', 'Core', 'Singularity'], - ghost: ['Wisp', 'Shade', 'Specter', 'Phantom'], - mushroom: ['Spore', 'Sprout', 'Mycelium', 'Ancient'], - octopus: ['Hatchling', 'Swimmer', 'Depths', 'Kraken'], - dragon: ['Ember', 'Drake', 'Wyrm', 'Elder'], -} - -const ALL_SPECIES: SpeciesKey[] = ['fox', 'owl', 'cat', 'robot', 'ghost', 'mushroom', 'octopus', 'dragon'] - -const FILTER_TABS: Array<{ key: string; label: string; emoji?: string }> = [ - { key: 'all', label: 'All' }, - ...ALL_SPECIES.map(s => ({ key: s, label: s.charAt(0).toUpperCase() + s.slice(1), emoji: SPECIES_EMOJI[s] })), -] - -// ── Helpers ── - -function getLevelTitle(species: string, level: number): string { - const key = species as SpeciesKey - const titles = SPECIES_LEVELS[key] - if (!titles) return `L${level}` - const idx = Math.min(Math.max(level, 0), 3) - return titles[idx] -} - -/** XP required for next level. Rough estimate — 250 XP per level. */ -function xpProgress(xp: number): number { - const levelSize = 250 - const progress = (xp % levelSize) / levelSize - return Math.min(Math.max(progress * 100, 2), 100) -} - -function formatNumber(n: number): string { - if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' - if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' - return n.toLocaleString() -} - -// ── Component ── - -export function LeaderboardPage() { - const [entries, setEntries] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [filter, setFilter] = useState('all') - const [copied, setCopied] = useState(false) - - const fetchLeaderboard = useCallback(async () => { - setLoading(true) - setError(null) - try { - const { data, error: rpcError } = await supabase.rpc('get_buddy_leaderboard', { - p_limit: 100, - }) - if (rpcError) throw rpcError - // The RPC returns ranked entries. Add rank field if not present. - const ranked: LeaderboardEntry[] = (data || []).map((row: any, i: number) => ({ - rank: row.rank ?? i + 1, - species: row.species, - level: row.level ?? 0, - xp: row.xp ?? 0, - achievement_count: row.achievement_count ?? 0, - sessions: row.sessions ?? 0, - })) - setEntries(ranked) - } catch (err: any) { - console.error('[Leaderboard] fetch error:', err) - setError(err?.message || 'Failed to load leaderboard') - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - document.body.classList.add('ka-scrollable-page') - fetchLeaderboard() - return () => { - document.body.classList.remove('ka-scrollable-page') - } - }, [fetchLeaderboard]) - - // Filtered entries - const filtered = useMemo(() => { - if (filter === 'all') return entries - return entries - .filter(e => e.species === filter) - .map((e, i) => ({ ...e, rank: i + 1 })) - }, [entries, filter]) - - // Summary stats - const stats = useMemo(() => { - if (!entries.length) return { total: 0, topSpecies: '-', maxLevel: 0 } - const speciesCount: Record = {} - let maxLevel = 0 - for (const e of entries) { - speciesCount[e.species] = (speciesCount[e.species] || 0) + 1 - if (e.level > maxLevel) maxLevel = e.level - } - const topSpecies = Object.entries(speciesCount).sort((a, b) => b[1] - a[1])[0]?.[0] || '-' - return { - total: entries.length, - topSpecies: topSpecies.charAt(0).toUpperCase() + topSpecies.slice(1), - maxLevel, - } - }, [entries]) - - const copyInstall = () => { - navigator.clipboard.writeText('npm i -g @kernel.chat/kbot') - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - const rankClass = (rank: number): string => { - if (rank === 1) return 'ka-lb-rank ka-lb-rank--gold' - if (rank === 2) return 'ka-lb-rank ka-lb-rank--silver' - if (rank === 3) return 'ka-lb-rank ka-lb-rank--bronze' - return 'ka-lb-rank' - } - - return ( -
- ← kernel.chat - - {/* Hero */} -
-
Buddy Leaderboard
-

kbot buddies

-

- Every kbot session grows your companion. See who's climbed the ranks. -

- - {/* Summary */} - {!loading && !error && entries.length > 0 && ( - -
- {formatNumber(stats.total)} - Total buddies -
-
- - {SPECIES_EMOJI[stats.topSpecies.toLowerCase() as SpeciesKey] || ''} {stats.topSpecies} - - Most popular -
-
- L{stats.maxLevel} - Highest level -
-
- )} - - -
- -
- - {/* Species filter tabs */} -
-
- {FILTER_TABS.map(tab => ( - - ))} -
-
- - {/* Main content */} -
- {/* Loading */} - {loading && ( -
-
- {Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
-
-
- ))} -
-
- )} - - {/* Error */} - {!loading && error && ( -
-

Connection lost

-

{error}

- -
- )} - - {/* Empty */} - {!loading && !error && filtered.length === 0 && ( -
- - {filter !== 'all' ? SPECIES_EMOJI[filter as SpeciesKey] || '\uD83D\uDC3E' : '\uD83D\uDC3E'} - -

No buddies yet

-

- {filter !== 'all' - ? `No ${filter} buddies on the leaderboard yet. Be the first!` - : 'The leaderboard is empty. Install kbot to get your buddy.'} -

-
- )} - - {/* Table */} - {!loading && !error && filtered.length > 0 && ( - -
- - - - - - - - - - - - - {filtered.map((entry) => ( - - - - - - - - ))} - - -
#BuddyXPAchievementsSessions
- {entry.rank <= 3 ? ['', '\uD83E\uDD47', '\uD83E\uDD48', '\uD83E\uDD49'][entry.rank] : entry.rank} - -
- - {SPECIES_EMOJI[entry.species as SpeciesKey] || '\uD83D\uDC3E'} - -
- {entry.species} - - L{entry.level} {getLevelTitle(entry.species, entry.level)} - -
-
-
-
- {formatNumber(entry.xp)} XP -
- -
-
-
- - {'\uD83C\uDFC6'} - {entry.achievement_count} - - - {formatNumber(entry.sessions)} -
-
-
- )} -
- - {/* CTA */} -
-
-

- Get your own buddy companion. It grows with every session. -

-
- npm i -g @kernel.chat/kbot - {copied ? 'Copied!' : 'Copy'} -
-
-
- ) -} diff --git a/src/pages/MotionSheetPage.tsx b/src/pages/MotionSheetPage.tsx index b541ae454..060de951a 100644 --- a/src/pages/MotionSheetPage.tsx +++ b/src/pages/MotionSheetPage.tsx @@ -402,7 +402,7 @@ export function MotionSheetPage() {
- kernel.chat + kernel.chat INTERACTION STUDIES · VOL. I JUL 2026 · BYOK
@@ -439,7 +439,7 @@ export function MotionSheetPage() { ) diff --git a/src/pages/PalmierSuitePage.tsx b/src/pages/PalmierSuitePage.tsx index 0f22f3063..e62e97037 100644 --- a/src/pages/PalmierSuitePage.tsx +++ b/src/pages/PalmierSuitePage.tsx @@ -49,7 +49,7 @@ export function PalmierSuitePage() { return (
- PALMIER PRO + PALMIER PRO
Production system32 tools18 engine adapters
diff --git a/src/pages/PlayPage.css b/src/pages/PlayPage.css deleted file mode 100644 index b57ab4626..000000000 --- a/src/pages/PlayPage.css +++ /dev/null @@ -1,637 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - SYNTHESIS DASHBOARD — kernel.chat/#/play - Real-time visualization of kbot's closed-loop intelligence - ═══════════════════════════════════════════════════════════════ */ - -.ka-synth { - min-height: 100vh; - background: #0a0a0a; - color: #e8e6e3; - font-family: 'Courier Prime', monospace; - padding: 24px; - position: relative; - overflow-x: hidden; -} - -/* ── Scan line effect ──────────────────────────────────────── */ - -.ka-synth-scanline { - position: fixed; - top: 0; left: 0; right: 0; - height: 2px; - background: linear-gradient(90deg, transparent, rgba(107, 91, 149, 0.4), transparent); - animation: ka-synth-scan 8s linear infinite; - pointer-events: none; - z-index: 10; -} - -@keyframes ka-synth-scan { - 0% { top: 0; } - 100% { top: 100vh; } -} - -/* ── Loading state ─────────────────────────────────────────── */ - -.ka-synth--loading { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 100vh; - gap: 16px; -} - -.ka-synth-loading-ring { - width: 48px; height: 48px; - border: 2px solid rgba(107, 91, 149, 0.2); - border-top-color: #6B5B95; - border-radius: 50%; - animation: ka-synth-spin 1s linear infinite; -} - -@keyframes ka-synth-spin { - to { transform: rotate(360deg); } -} - -.ka-synth-loading-text { - font-size: 16px; - opacity: 0.8; -} - -.ka-synth-loading-sub { - font-size: 13px; - opacity: 0.4; -} - -/* ── Header ────────────────────────────────────────────────── */ - -.ka-synth-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 32px; - flex-wrap: wrap; - gap: 16px; -} - -.ka-synth-back { - background: none; - border: 1px solid rgba(255, 255, 255, 0.1); - color: #e8e6e3; - font-family: 'Courier Prime', monospace; - font-size: 13px; - padding: 6px 14px; - cursor: pointer; - border-radius: 4px; - transition: border-color 0.2s; -} - -.ka-synth-back:hover { - border-color: rgba(107, 91, 149, 0.5); -} - -.ka-synth-header-text { - text-align: center; - flex: 1; -} - -.ka-synth-title { - font-family: 'Courier Prime', monospace; - font-size: 28px; - letter-spacing: 8px; - margin: 0; - background: linear-gradient(135deg, #e8e6e3, #6B5B95); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.ka-synth-subtitle { - font-family: 'EB Garamond', serif; - font-size: 14px; - opacity: 0.5; - margin: 4px 0 0; -} - -.ka-synth-pulse { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - opacity: 0.6; -} - -.ka-synth-pulse-dot { - width: 8px; height: 8px; - background: #22C55E; - border-radius: 50%; - animation: ka-synth-pulse-anim 2s ease-in-out infinite; -} - -@keyframes ka-synth-pulse-anim { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -/* ── Sections ──────────────────────────────────────────────── */ - -.ka-synth-section { - margin-bottom: 40px; -} - -.ka-synth-section-title { - font-family: 'Courier Prime', monospace; - font-size: 16px; - letter-spacing: 2px; - text-transform: uppercase; - margin: 0 0 4px; - color: #6B5B95; -} - -.ka-synth-section-desc { - font-family: 'EB Garamond', serif; - font-size: 14px; - opacity: 0.5; - margin: 0 0 16px; -} - -/* ── Cycle Ring ────────────────────────────────────────────── */ - -.ka-synth-cycle { - position: relative; - width: 200px; - height: 200px; - margin: 0 auto 24px; -} - -.ka-synth-cycle-svg { - width: 100%; - height: 100%; - transform: rotate(-90deg); -} - -.ka-synth-cycle-bg { - fill: none; - stroke: rgba(255, 255, 255, 0.05); - stroke-width: 4; -} - -.ka-synth-cycle-fill { - fill: none; - stroke: #6B5B95; - stroke-width: 4; - stroke-linecap: round; - transition: stroke-dashoffset 1.5s ease-out; - filter: drop-shadow(0 0 6px rgba(107, 91, 149, 0.5)); -} - -.ka-synth-cycle-text { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -.ka-synth-cycle-num { - font-size: 36px; - font-weight: 700; - line-height: 1; -} - -.ka-synth-cycle-label { - font-size: 12px; - opacity: 0.4; - margin-top: 4px; -} - -/* ── Vitals Row ────────────────────────────────────────────── */ - -.ka-synth-vitals { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; - margin-bottom: 40px; -} - -.ka-synth-vital { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 8px; - padding: 16px; - text-align: center; -} - -.ka-synth-vital-num { - display: block; - font-size: 24px; - font-weight: 700; - color: #e8e6e3; -} - -.ka-synth-vital-label { - display: block; - font-size: 11px; - opacity: 0.4; - margin-top: 4px; - text-transform: uppercase; - letter-spacing: 1px; -} - -/* ── Skill Map Grid ────────────────────────────────────────── */ - -.ka-synth-skillmap { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; -} - -.ka-synth-agent { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 8px; - padding: 14px; - transition: border-color 0.3s; -} - -.ka-synth-agent--proven { - border-color: rgba(34, 197, 94, 0.3); -} - -.ka-synth-agent--proven:hover { - border-color: rgba(34, 197, 94, 0.6); - box-shadow: 0 0 12px rgba(34, 197, 94, 0.1); -} - -.ka-synth-agent--developing { - border-color: rgba(245, 158, 11, 0.2); -} - -.ka-synth-agent--untested { - opacity: 0.5; -} - -.ka-synth-agent-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.ka-synth-agent-name { - font-size: 14px; - font-weight: 700; -} - -.ka-synth-agent-badge { - font-size: 10px; - padding: 2px 6px; - border-radius: 3px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.ka-synth-agent-badge--proven { background: rgba(34, 197, 94, 0.15); color: #22C55E; } -.ka-synth-agent-badge--developing { background: rgba(245, 158, 11, 0.15); color: #F59E0B; } -.ka-synth-agent-badge--untested { background: rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.3); } - -.ka-synth-agent-bar-track { - height: 6px; - background: rgba(255, 255, 255, 0.05); - border-radius: 3px; - position: relative; - margin-bottom: 6px; -} - -.ka-synth-agent-bar-fill { - height: 100%; - border-radius: 3px; - background: linear-gradient(90deg, #6B5B95, #22C55E); - transition: width 1.5s ease-out; -} - -.ka-synth-agent--untested .ka-synth-agent-bar-fill { - background: rgba(255, 255, 255, 0.1); -} - -.ka-synth-agent-mu { - position: absolute; - right: 0; - top: -16px; - font-size: 11px; - opacity: 0.6; -} - -.ka-synth-agent-sigma { - font-size: 11px; - opacity: 0.4; -} - -.ka-synth-agent-cats { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 6px; -} - -.ka-synth-agent-cat { - font-size: 10px; - background: rgba(107, 91, 149, 0.15); - padding: 1px 6px; - border-radius: 3px; - color: rgba(107, 91, 149, 0.8); -} - -/* Status dots in section desc */ -.ka-synth-proven-dot, -.ka-synth-developing-dot, -.ka-synth-untested-dot { - display: inline-block; - width: 8px; height: 8px; - border-radius: 50%; - margin: 0 2px 0 8px; - vertical-align: middle; -} -.ka-synth-proven-dot { background: #22C55E; } -.ka-synth-developing-dot { background: #F59E0B; } -.ka-synth-untested-dot { background: rgba(255, 255, 255, 0.3); } - -/* ── Corrections ───────────────────────────────────────────── */ - -.ka-synth-corrections-grid { - display: grid; - gap: 12px; -} - -.ka-synth-correction { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 8px; - padding: 14px; - border-left: 3px solid transparent; -} - -.ka-synth-correction--high { - border-left-color: #EF4444; -} - -.ka-synth-correction--high .ka-synth-correction-header { - animation: ka-synth-pulse-anim 3s ease-in-out infinite; -} - -.ka-synth-correction--medium { - border-left-color: #F59E0B; -} - -.ka-synth-correction--low { - border-left-color: rgba(255, 255, 255, 0.2); -} - -.ka-synth-correction-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 6px; -} - -.ka-synth-correction-severity { - font-weight: 700; - color: #EF4444; - font-size: 14px; -} - -.ka-synth-correction-source { - font-size: 10px; - background: rgba(107, 91, 149, 0.15); - padding: 1px 6px; - border-radius: 3px; - text-transform: uppercase; -} - -.ka-synth-correction-count { - font-size: 11px; - opacity: 0.5; - margin-left: auto; -} - -.ka-synth-correction-rule { - font-family: 'EB Garamond', serif; - font-size: 14px; - line-height: 1.5; - margin: 0; - opacity: 0.8; -} - -/* ── Tools ─────────────────────────────────────────────────── */ - -.ka-synth-tools-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; -} - -.ka-synth-tool { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 8px; - padding: 14px; -} - -.ka-synth-tool--adopted { - border-color: rgba(34, 197, 94, 0.3); - box-shadow: 0 0 8px rgba(34, 197, 94, 0.05); -} - -.ka-synth-tool--rejected { - opacity: 0.5; -} - -.ka-synth-tool-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 6px; -} - -.ka-synth-tool-name { - font-size: 13px; - font-weight: 700; - color: #e8e6e3; - text-decoration: none; -} - -.ka-synth-tool-name:hover { - color: #6B5B95; -} - -.ka-synth-tool-stars { - font-size: 12px; - opacity: 0.5; -} - -.ka-synth-tool-badge { - display: inline-block; - font-size: 10px; - padding: 1px 6px; - border-radius: 3px; - text-transform: uppercase; - margin-bottom: 6px; -} - -.ka-synth-tool-badge--adopted { background: rgba(34, 197, 94, 0.15); color: #22C55E; } -.ka-synth-tool-badge--rejected { background: rgba(239, 68, 68, 0.15); color: #EF4444; } -.ka-synth-tool-badge--evaluated { background: rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.4); } - -.ka-synth-tool-reason { - font-family: 'EB Garamond', serif; - font-size: 12px; - opacity: 0.5; - margin: 0; - line-height: 1.4; -} - -/* ── Papers ────────────────────────────────────────────────── */ - -.ka-synth-papers-grid { - display: grid; - gap: 12px; -} - -.ka-synth-paper { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(107, 91, 149, 0.2); - border-radius: 8px; - padding: 14px; -} - -.ka-synth-paper-technique { - font-size: 15px; - font-weight: 700; - color: #6B5B95; - margin-bottom: 4px; -} - -.ka-synth-paper-title { - font-family: 'EB Garamond', serif; - font-size: 13px; - opacity: 0.6; - font-style: italic; - margin-bottom: 6px; -} - -.ka-synth-paper-applies { - font-size: 12px; - opacity: 0.5; - margin-bottom: 6px; -} - -.ka-synth-paper-badge { - font-size: 10px; - padding: 1px 6px; - border-radius: 3px; - text-transform: uppercase; -} - -.ka-synth-paper-badge--proposed { background: rgba(245, 158, 11, 0.15); color: #F59E0B; } -.ka-synth-paper-badge--implemented { background: rgba(34, 197, 94, 0.15); color: #22C55E; } -.ka-synth-paper-badge--rejected { background: rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.3); } - -/* ── Learning Stores ───────────────────────────────────────── */ - -.ka-synth-stores { - display: grid; - gap: 12px; -} - -.ka-synth-stat-header { - display: flex; - justify-content: space-between; - margin-bottom: 4px; -} - -.ka-synth-stat-label { - font-size: 13px; - opacity: 0.7; -} - -.ka-synth-stat-value { - font-size: 13px; - font-weight: 700; -} - -.ka-synth-stat-track { - height: 6px; - background: rgba(255, 255, 255, 0.05); - border-radius: 3px; - overflow: hidden; -} - -.ka-synth-stat-fill { - height: 100%; - background: linear-gradient(90deg, #6B5B95, rgba(107, 91, 149, 0.4)); - border-radius: 3px; - transition: width 1.5s ease-out; -} - -/* ── Footer ────────────────────────────────────────────────── */ - -.ka-synth-footer { - text-align: center; - padding: 32px 0 16px; - opacity: 0.3; - font-size: 12px; -} - -.ka-synth-footer p { - margin: 4px 0; -} - -/* ── Mobile ────────────────────────────────────────────────── */ - -@media (max-width: 768px) { - .ka-synth { - padding: 16px; - } - - .ka-synth-title { - font-size: 20px; - letter-spacing: 4px; - } - - .ka-synth-vitals { - grid-template-columns: repeat(2, 1fr); - } - - .ka-synth-skillmap { - grid-template-columns: 1fr; - } - - .ka-synth-tools-grid { - grid-template-columns: 1fr; - } - - .ka-synth-header { - flex-direction: column; - align-items: stretch; - text-align: center; - } - - .ka-synth-back { - align-self: flex-start; - } - - .ka-synth-pulse { - justify-content: center; - } -} - -@media (min-width: 769px) and (max-width: 1024px) { - .ka-synth-skillmap { - grid-template-columns: repeat(2, 1fr); - } - - .ka-synth-tools-grid { - grid-template-columns: repeat(2, 1fr); - } -} diff --git a/src/pages/PlayPage.tsx b/src/pages/PlayPage.tsx deleted file mode 100644 index 72d5fcf62..000000000 --- a/src/pages/PlayPage.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { useSynthesisState, type SkillMapEntry, type ActiveCorrection, type ToolAdoption, type PaperInsight } from '../hooks/useSynthesisState' -import './PlayPage.css' - -function timeAgo(iso: string): string { - if (!iso) return 'never' - const diff = Date.now() - new Date(iso).getTime() - const min = Math.floor(diff / 60_000) - if (min < 1) return 'just now' - if (min < 60) return `${min}m ago` - const hr = Math.floor(min / 60) - if (hr < 24) return `${hr}h ago` - return `${Math.floor(hr / 24)}d ago` -} - -function CycleRing({ current, target }: { current: number; target: number }) { - const pct = Math.min(current / target, 1) - const r = 90 - const circ = 2 * Math.PI * r - const offset = circ * (1 - pct) - - return ( -
- - - - -
- {current.toLocaleString()} - / {target.toLocaleString()} cycles -
-
- ) -} - -function AgentCard({ agent }: { agent: SkillMapEntry }) { - const barWidth = Math.min((agent.overall.mu / 50) * 100, 100) - const cats = Object.entries(agent.categories) - .sort((a, b) => b[1].mu - a[1].mu) - .slice(0, 3) - - return ( -
-
- {agent.agent} - - {agent.status === 'proven' ? '\u2605' : agent.status === 'developing' ? '\u25C6' : '\u25CB'} {agent.status} - -
-
-
- {agent.overall.mu.toFixed(1)} -
-
σ {agent.overall.sigma.toFixed(1)} · {agent.overall.confidence}
- {cats.length > 0 && ( -
- {cats.map(([cat, r]) => ( - {cat}: {r.mu.toFixed(1)} - ))} -
- )} -
- ) -} - -function CorrectionCard({ c }: { c: ActiveCorrection }) { - return ( -
-
- {c.severity === 'high' ? '!!' : c.severity === 'medium' ? '!' : '-'} - {c.source} - {c.occurrences}x -
-

{c.rule}

-
- ) -} - -function ToolCard({ t }: { t: ToolAdoption }) { - return ( -
-
- {t.name} - {t.stars.toLocaleString()} ★ -
- {t.status} -

{t.reason}

-
- ) -} - -function PaperCard({ p }: { p: PaperInsight }) { - return ( -
-
{p.technique}
-
{p.title}
-
Applies to: {p.applicableTo}
- {p.status} -
- ) -} - -function StatBar({ label, value, max }: { label: string; value: number; max: number }) { - const pct = Math.min((value / max) * 100, 100) - return ( -
-
- {label} - {value.toLocaleString()} -
-
-
-
-
- ) -} - -export function PlayPage() { - const { data, loading } = useSynthesisState(30_000) - - if (loading || !data) { - return ( -
-
-

Waiting for synthesis data...

-

The daemon pushes every 15 minutes

-
- ) - } - - const proven = data.skillMap.filter(a => a.status === 'proven').length - const developing = data.skillMap.filter(a => a.status === 'developing').length - const untested = data.skillMap.filter(a => a.status === 'untested').length - const ls = data.learningSummary - - return ( -
- {/* Scan line effect */} -
- - {/* Header */} -
- -
-

SYNTHESIS

-

kbot's closed-loop intelligence compounding

-
-
-
- {timeAgo(data.lastCycleAt)} -
-
- - {/* Cycle Progress */} -
- -
- - {/* Vitals Row */} -
-
- {data.discoveryState.knownStars ?? '?'} - GitHub Stars -
-
- {data.discoveryState.knownDownloads?.toLocaleString() ?? '?'} - npm Downloads -
-
- {ls.sessions ?? 0} - Sessions -
-
- {ls.observer_total ?? 0} - Tool Calls Observed -
-
- - {/* Active Corrections */} - {data.activeCorrections.length > 0 && ( -
-

Active Corrections

-

Injected into every kbot prompt — learned from failures

-
- {data.activeCorrections.map((c, i) => )} -
-
- )} - - {/* Skill Map */} -
-

Agent Skill Map

-

- Bayesian ratings (Bradley-Terry) · - {proven} proven · - {developing} developing · - {untested} untested -

-
- {data.skillMap.map(a => )} -
-
- - {/* Tool Evaluations */} - {data.toolAdoptions.length > 0 && ( -
-

Discovered Tools

-

- Evaluated against failure patterns · - {data.stats.toolsAdopted ?? 0} adopted, {data.stats.toolsRejected ?? 0} rejected -

-
- {data.toolAdoptions.map((t, i) => )} -
-
- )} - - {/* Paper Insights */} - {data.paperInsights.length > 0 && ( -
-

Paper Insights

-

Academic techniques matched to kbot patterns

-
- {data.paperInsights.map((p, i) => )} -
-
- )} - - {/* Learning Stores */} -
-

Learning Stores

-

What kbot knows — accumulated across {ls.sessions ?? 0} sessions

-
- - - - - -
-
- - {/* Footer */} -
-

This page updates every 30 seconds. The daemon pushes every 15 minutes.

-

All analysis runs on local models (Ollama/MLX). Zero API cost.

-
-
- ) -} diff --git a/src/pages/PricingPage.tsx b/src/pages/PricingPage.tsx deleted file mode 100644 index 0e5ccc00b..000000000 --- a/src/pages/PricingPage.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useEffect } from 'react' - -export function PricingPage() { - useEffect(() => { - document.body.classList.add('ka-scrollable-page') - return () => { document.body.classList.remove('ka-scrollable-page') } - }, []) - - return ( -
- - -
-

Kernel is free to use

-

- Chat 20 times a day. No credit card. No catch. -

-
- -
-
-
-
-

Free

-
- $0 -
-

Everything you need to get started

-
-
    -
  • 20 messages every day
  • -
  • 35 expert AI helpers
  • -
  • Search the web for answers
  • -
  • Remembers who you are
  • -
  • All your chats saved
  • -
- - Start chatting for free - -
-
-
- -
-

Got questions? Send us an email at hello@kernel.chat

-
-
- ) -} diff --git a/src/pages/TerminalPage.tsx b/src/pages/TerminalPage.tsx deleted file mode 100644 index fcd9f2771..000000000 --- a/src/pages/TerminalPage.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// TerminalPage — removed (route disabled) -export function TerminalPage() { - return null -} diff --git a/src/pages/TermsPage.tsx b/src/pages/TermsPage.tsx index 472491477..d0c8ef254 100644 --- a/src/pages/TermsPage.tsx +++ b/src/pages/TermsPage.tsx @@ -75,7 +75,7 @@ export function TermsPage() {

Kernel stores your conversations to provide continuity across sessions. It analyzes your messages to build a memory profile, knowledge graph, and multi-agent perception model - (see our Privacy Policy for details on the Convergence system). + (see our Privacy Policy for details on the Convergence system). This analysis is solely to improve your experience with Kernel.

@@ -317,7 +317,7 @@ export function TermsPage() {

20. Entire Agreement

- These terms, together with the Privacy Policy, constitute the + These terms, together with the Privacy Policy, constitute the entire agreement between you and Isaac Hernandez regarding your use of Kernel. These terms supersede any prior agreements or understandings, whether written or oral, relating to the service. diff --git a/src/pages/WorkspaceAdminPage.tsx b/src/pages/WorkspaceAdminPage.tsx deleted file mode 100644 index 91084c982..000000000 --- a/src/pages/WorkspaceAdminPage.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState } from 'react' -import { useAuthContext } from '../providers/AuthProvider' -import { useWorkspace } from '../hooks/useWorkspace' -import { IconTrash, IconPlus, IconCheck } from '../components/KernelIcons' - -export function WorkspaceAdminPage() { - const { user } = useAuthContext() - const workspace = useWorkspace(user?.id ?? null) - const [inviteEmail, setInviteEmail] = useState('') - const [inviteRole, setInviteRole] = useState('member') - const [inviting, setInviting] = useState(false) - const [inviteSuccess, setInviteSuccess] = useState(false) - - if (!workspace.activeWorkspace) { - return ( -

-

No workspace selected

-

Select a workspace from the switcher to manage it.

-
- ) - } - - const isOwner = workspace.activeWorkspace.owner_id === user?.id - const isAdmin = workspace.members.some(m => m.user_id === user?.id && ['owner', 'admin'].includes(m.role)) - - const handleInvite = async (e: React.FormEvent) => { - e.preventDefault() - if (!inviteEmail.trim() || inviting) return - setInviting(true) - const result = await workspace.inviteMember(inviteEmail.trim(), inviteRole) - if (result) { - setInviteEmail('') - setInviteSuccess(true) - setTimeout(() => setInviteSuccess(false), 3000) - } - setInviting(false) - } - - return ( -
-

{workspace.activeWorkspace.name}

-

- {workspace.members.length} / {workspace.activeWorkspace.max_members} members -

- - {/* Members list */} -
-

Members

-
- {workspace.members.map(m => ( -
- {m.user_id.slice(0, 8)}... - {m.role} - {isAdmin && m.role !== 'owner' && ( - - )} -
- ))} -
-
- - {/* Invite form */} - {isAdmin && ( -
-

Invite Member

-
- setInviteEmail(e.target.value)} - placeholder="email@example.com" - className="ka-workspace-invite-input" - /> - - -
-
- )} - - {/* Pending invitations */} - {isAdmin && workspace.invitations.length > 0 && ( -
-

Pending Invitations

-
- {workspace.invitations.filter(i => !i.accepted_at).map(inv => ( -
- {inv.email} - {inv.role} - - Expires {new Date(inv.expires_at).toLocaleDateString()} - - -
- ))} -
-
- )} - - {/* Billing portal */} - {isOwner && ( -
-

Billing

-

- Manage your team subscription and payment method. -

- -
- )} -
- ) -} diff --git a/src/router.tsx b/src/router.tsx index 1c75724f9..e0855425e 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,9 +1,10 @@ import { Suspense } from 'react' -import { createHashRouter, Navigate, useRouteError } from 'react-router-dom' +import { createBrowserRouter, Navigate, useRouteError } from 'react-router-dom' import { Layout } from './components/Layout' import { ErrorBoundary } from './components/ErrorBoundary' import { KernelLoading } from './components/KernelLoading' import { lazyRetry } from './utils/lazyRetry' +import { resolveLegacyHash } from './utils/legacyHashRedirect' // Lazy-load pages const LandingPage = lazyRetry(() => import('./pages/LandingPage').then(m => ({ default: m.LandingPage }))) @@ -35,14 +36,23 @@ function RootErrorPage() {

Something went wrong

{message}
{error?.stack || JSON.stringify(error, null, 2)}
-
) } -export const router = createHashRouter([ +// Legacy hash citations (/#/issues/421) must resolve BEFORE the +// router captures location — module scope runs at import time, ahead +// of main.tsx's boot sequence. Auth hashes (#access_token=…) are +// ignored by the resolver and left for the OAuth interceptor. +const legacyPath = resolveLegacyHash(window.location) +if (legacyPath) { + window.history.replaceState({}, '', legacyPath) +} + +export const router = createBrowserRouter([ { path: '/palmier-suite', element: withErrorBoundary( diff --git a/src/utils/legacyHashRedirect.test.ts b/src/utils/legacyHashRedirect.test.ts new file mode 100644 index 000000000..e0c770808 --- /dev/null +++ b/src/utils/legacyHashRedirect.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { resolveLegacyHash } from './legacyHashRedirect' + +const at = (hash: string, pathname = '/', search = '') => ({ pathname, search, hash }) + +describe('resolveLegacyHash', () => { + it('maps a legacy issue citation to its real path', () => { + expect(resolveLegacyHash(at('#/issues/421'))).toBe('/issues/421') + }) + + it('maps the bare legacy root hash to /', () => { + expect(resolveLegacyHash(at('#/'))).toBe('/') + }) + + it('preserves a query carried inside the hash (Stripe return URLs)', () => { + expect(resolveLegacyHash(at('#/?checkout=complete'))).toBe('/?checkout=complete') + expect(resolveLegacyHash(at('#/issues/400?spread=2'))).toBe('/issues/400?spread=2') + }) + + it('merges an outer query ahead of the hash query', () => { + expect(resolveLegacyHash(at('#/issues/400?b=2', '/', '?a=1'))).toBe('/issues/400?a=1&b=2') + }) + + it('ignores Supabase auth token hashes', () => { + expect(resolveLegacyHash(at('#access_token=abc&refresh_token=def'))).toBeNull() + expect(resolveLegacyHash(at('#/login?access_token=abc'))).toBeNull() + }) + + it('ignores non-route hashes and real paths', () => { + expect(resolveLegacyHash(at(''))).toBeNull() + expect(resolveLegacyHash(at('#feature-well'))).toBeNull() + expect(resolveLegacyHash(at('#/issues/421', '/issues/421'))).toBeNull() + }) +}) diff --git a/src/utils/legacyHashRedirect.ts b/src/utils/legacyHashRedirect.ts new file mode 100644 index 000000000..f8c8a86be --- /dev/null +++ b/src/utils/legacyHashRedirect.ts @@ -0,0 +1,34 @@ +/** + * Legacy hash-route resolver. + * + * The magazine published /#/issues/421-style URLs for years (hash + * router + GitHub Pages). After the move to real paths, this maps a + * legacy hash onto the path it cited so old links keep landing. + * + * Returns the path to replaceState to, or null when no redirect + * applies. Never touches Supabase auth hashes (#access_token=…), + * which the OAuth interceptor consumes before this runs. + */ +export function resolveLegacyHash(loc: { + pathname: string + search: string + hash: string +}): string | null { + const { pathname, search, hash } = loc + if (!hash.startsWith('#/')) return null + if (hash.includes('access_token=')) return null + // Legacy URLs always carried the route in the hash on the root + // path. A real path with a stray #/ fragment is left alone. + if (pathname !== '/') return null + + // '#/issues/421?x=1' → '/issues/421?x=1' (query inside the hash, + // e.g. Stripe's '#/?checkout=complete', is preserved). + let target = hash.slice(1) + if (target === '/') target = '' + // Outer query (rare: '/?a=1#/path') is kept ahead of the hash's own. + if (search) { + const [hashPath, hashQuery] = target.split('?') + target = hashPath + search + (hashQuery ? '&' + hashQuery : '') + } + return target || '/' +}