From 36346157cafd74f8e86306b38b12af48be3bfc92 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Jul 2026 15:58:17 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(routing):=20real=20URLs=20=E2=80=94=20?= =?UTF-8?q?createBrowserRouter=20+=20Cloudflare=20Pages=20press?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every deep link previously served HTTP 404 and bounced to a hash route; a publication arguing provenance had uncitable addresses. - router.tsx: createHashRouter → createBrowserRouter. Legacy /#/… citations resolve at module scope (before the router captures location) via new utils/legacyHashRedirect.ts — 6 tests; auth hashes are ignored and left to the OAuth interceptor. - main.tsx: post-auth URL cleanup writes plain paths, PostHog capture_pageview → 'history_change' so pushState pageviews count. - 20 hash hrefs across the routed surface rewritten to real paths; MagazineFrame's goHome now uses useNavigate. - public/_redirects: /* /index.html 200 (real files served first, so artifacts and plates stay static 200s). - scripts/build-sitemap.mjs replaces the stale 4-URL sitemap (it listed /#/engine) with 9 routes + 68 issues, regenerated in CI. - deploy.yml now presses to Cloudflare Pages (wrangler-action v3, project kernel-chat); all law gates kept. deploy-gh-pages.yml is the workflow_dispatch rollback; 404.html + CNAME retained for it. - e2e specs updated to path URLs. PUBLISHING.md §VII rewritten. Gates: tsc clean, 794/794 tests, build 236.27KB gzip JS (<300KB). Verified in vite preview: /issues/421 direct 200, /#/issues/415 resolves to the 415 spread, artifacts/index.html serves static. Co-Authored-By: Claude Fable 5 --- .claude/launch.json | 6 + .github/workflows/deploy-gh-pages.yml | 66 +++++ .github/workflows/deploy.yml | 19 +- e2e/tests/auth.spec.ts | 2 +- e2e/tests/issue-415-close.spec.ts | 6 +- package.json | 1 + public/_redirects | 4 + public/sitemap.xml | 375 +++++++++++++++++++++++++- src/components/IssueArchiveNav.tsx | 2 +- src/components/IssueColophon.tsx | 14 +- src/components/LoginGate.tsx | 4 +- src/components/MagazineFrame.tsx | 10 +- src/components/PreviouslyStrip.tsx | 2 +- src/content/issues/PUBLISHING.md | 34 ++- src/main.tsx | 8 +- src/pages/MotionSheetPage.tsx | 4 +- src/pages/PalmierSuitePage.tsx | 2 +- src/pages/TermsPage.tsx | 4 +- src/router.tsx | 16 +- src/utils/legacyHashRedirect.test.ts | 34 +++ src/utils/legacyHashRedirect.ts | 34 +++ 21 files changed, 591 insertions(+), 56 deletions(-) create mode 100644 .github/workflows/deploy-gh-pages.yml create mode 100644 public/_redirects create mode 100644 src/utils/legacyHashRedirect.test.ts create mode 100644 src/utils/legacyHashRedirect.ts 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/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/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/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/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/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/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/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/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 || '/' +} From 9545c4f40710e98669c1b9f1c2242987ad678b4b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Jul 2026 15:59:22 -0700 Subject: [PATCH 2/5] chore(pages): cut nine dead surfaces; the engine stays parked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Count what gets read; cut what doesn't. None of these nine pages is reachable from router.tsx and none is imported outside its own file (grep-audited; the sole 'AdminPage' hit was the substring inside WorkspaceAdminPage, itself cut): LeaderboardPage, PlayPage, TerminalPage, PricingPage (billing died 2026-04-16; a price page contradicts the ¥0 masthead), AdminPage, WorkspaceAdminPage, ApiDocsPage, AuthorProfilePage, ExplorePage. Exclusive dependencies go with them: useSynthesisState, discovery/SearchBar, TopicGrid. Kept deliberately: the EnginePage chat tree (parked product, not junk) and FeedCard/AuthorCard/useDiscovery/discoveryStore, which the parked tree still imports. public/play and synthesis-state stay — the build:synth pipeline owns them. Gates: tsc clean, 794/794 tests, build 236.27KB gzip JS. Co-Authored-By: Claude Fable 5 --- src/components/discovery/SearchBar.tsx | 73 -- src/components/discovery/TopicGrid.tsx | 28 - src/hooks/useSynthesisState.ts | 102 -- src/pages/AdminPage.tsx | 1228 ------------------------ src/pages/ApiDocsPage.tsx | 353 ------- src/pages/AuthorProfilePage.tsx | 123 --- src/pages/ExplorePage.tsx | 144 --- src/pages/LeaderboardPage.css | 578 ----------- src/pages/LeaderboardPage.tsx | 354 ------- src/pages/PlayPage.css | 637 ------------ src/pages/PlayPage.tsx | 255 ----- src/pages/PricingPage.tsx | 51 - src/pages/TerminalPage.tsx | 4 - src/pages/WorkspaceAdminPage.tsx | 138 --- 14 files changed, 4068 deletions(-) delete mode 100644 src/components/discovery/SearchBar.tsx delete mode 100644 src/components/discovery/TopicGrid.tsx delete mode 100644 src/hooks/useSynthesisState.ts delete mode 100644 src/pages/AdminPage.tsx delete mode 100644 src/pages/ApiDocsPage.tsx delete mode 100644 src/pages/AuthorProfilePage.tsx delete mode 100644 src/pages/ExplorePage.tsx delete mode 100644 src/pages/LeaderboardPage.css delete mode 100644 src/pages/LeaderboardPage.tsx delete mode 100644 src/pages/PlayPage.css delete mode 100644 src/pages/PlayPage.tsx delete mode 100644 src/pages/PricingPage.tsx delete mode 100644 src/pages/TerminalPage.tsx delete mode 100644 src/pages/WorkspaceAdminPage.tsx 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/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/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/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/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/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. -

- -
- )} -
- ) -} From 2752318e42cff538a41cd4e0bcbff040e673161d Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Jul 2026 16:03:10 -0700 Subject: [PATCH 3/5] feat(a11y): 44px back-to-top target and a skip link past the masthead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colophon's back-to-top control measured 98×13 — half the WCAG 2.5.8 minimum. Padding + negative margin grow the hit area to 122×44 while the printed mark stays optically where it was (white-space: nowrap keeps it on one line inside the 40px-wide colophon column; the earlier declaration order let the block's own padding: 0 win, now removed). Layout gains the keyboard reader's shortcut: a skip link ahead of the masthead, off-canvas until focused, folio grammar, targeting main#feature-well (tabIndex -1). Styled with house tokens only. Gates: adherence + editorial clean, tsc clean, 794/794 tests. Verified in vite preview: target measures 122×44; link focusable with the :focus reveal rule in the sheet. Co-Authored-By: Claude Fable 5 --- src/components/Layout.tsx | 15 ++++++++++----- src/index.css | 24 ++++++++++++++++++++++++ src/pages/LandingPage.css | 9 +++++++-- 3 files changed, 41 insertions(+), 7 deletions(-) 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/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/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; From e57e7bfaa07f37c0f3afbe0f70e9f27362ff99b7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Jul 2026 16:04:01 -0700 Subject: [PATCH 4/5] chore: file the real-URLs session in SCRATCHPAD Co-Authored-By: Claude Fable 5 --- SCRATCHPAD.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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 From 758813af6867d59f3ca5101a4235d02f31c232ba Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Jul 2026 16:46:06 -0700 Subject: [PATCH 5/5] =?UTF-8?q?fix(ci):=20commit=20the=20sitemap=20generat?= =?UTF-8?q?or=20script=20=E2=80=94=20it=20was=20never=20tracked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.yml and package.json both reference 'npm run sitemap' → scripts/build-sitemap.mjs, but the file itself sat untracked on disk and was never git-added in the routing commit. CI on this PR would have failed at the sitemap step. Caught during THE STACKS Task 4 review while checking out a sibling branch and finding the script missing. Co-Authored-By: Claude Fable 5 --- scripts/build-sitemap.mjs | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 scripts/build-sitemap.mjs 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`)