diff --git a/.gitignore b/.gitignore index 6b0aa94..b20d6e5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,10 @@ web/env.local # Editor .vscode-test/ + +# Scratch PDF/slide builds (canonical copies live in docs/investors/) +tmp/ +output/ +*.inspect.ndjson +app/.tools-bin/ +web/.vercel/ diff --git a/README.md b/README.md index ba6765b..37fafea 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ cd marketing npm install npm run dev # http://localhost:3000 — waitlist + product storytelling ``` -See [`marketing/README.md`](marketing/README.md) for env vars, Supabase waitlist migration, -and Vercel deploy steps. This site is separate from the product backend in `web/`. +See [`marketing/README.md`](marketing/README.md) for the Vercel Blob waitlist configuration, +email notifications, and deploy steps. This site is separate from the product backend in `web/`. ## Run the desktop app (dev) ``` @@ -39,6 +39,8 @@ npm run dev # http://localhost:8787 — dashboard + AI + sync API ``` - No keys needed for dev: a **mock AI provider** and an **in-memory dev store** are used. - `ANTHROPIC_API_KEY` → real explanations. `SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY` → real DB. +- Free/Pro/Teams billing is server-authoritative and remains disabled until Stripe test-mode + values are configured. See [`docs/billing.md`](docs/billing.md). - The extension talks to this origin (`uncode.backendUrl`, default `http://localhost:8787`). Run **Uncode: Sign In** in the editor to connect and sync your learning to the dashboard. diff --git a/app/.gitignore b/app/.gitignore index 1f5e083..837d386 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +*.dmg dist-test/ release/ .env diff --git a/app/PRIVACY.md b/app/PRIVACY.md index ce402c2..79648a7 100644 --- a/app/PRIVACY.md +++ b/app/PRIVACY.md @@ -48,4 +48,4 @@ You can delete your account and all associated data at any time from Settings ## Contact -Questions about this policy: **hello@unvibe.app** +Questions about this policy: **support@unvibe.site** or **preston@unvibe.site** diff --git a/app/build/icon.icns b/app/build/icon.icns index 309880d..2ca8092 100644 Binary files a/app/build/icon.icns and b/app/build/icon.icns differ diff --git a/app/build/icon.png b/app/build/icon.png index e680af0..732f44c 100644 Binary files a/app/build/icon.png and b/app/build/icon.png differ diff --git a/app/package.json b/app/package.json index 0bc57be..15dee31 100644 --- a/app/package.json +++ b/app/package.json @@ -33,13 +33,26 @@ "appId": "com.unvibe.app", "productName": "Unvibe", "copyright": "Copyright © 2026 Unvibe", - "files": ["dist/**/*", "package.json"], - "directories": { "output": "release", "buildResources": "build" }, + "files": [ + "dist/**/*", + "package.json" + ], + "directories": { + "output": "release", + "buildResources": "build" + }, "icon": "build/icon.icns", "mac": { "icon": "build/icon.icns", "category": "public.app-category.developer-tools", - "target": [{ "target": "dir", "arch": ["arm64"] }], + "target": [ + { + "target": "dir", + "arch": [ + "arm64" + ] + } + ], "hardenedRuntime": true, "gatekeeperAssess": false, "identity": null, @@ -52,6 +65,9 @@ "LSUIElement": false } }, - "dmg": { "sign": false, "artifactName": "Unvibe-${version}-${arch}-unsigned.${ext}" } + "dmg": { + "sign": false, + "artifactName": "Unvibe-${version}-${arch}-unsigned.${ext}" + } } } diff --git a/app/scripts/package-local.mjs b/app/scripts/package-local.mjs index c25ba7b..f183468 100644 --- a/app/scripts/package-local.mjs +++ b/app/scripts/package-local.mjs @@ -32,7 +32,7 @@ chmodSync(pythonShim, 0o700); try { for (const [command, args] of [ [process.execPath, ['scripts/build.mjs']], - [join('node_modules', '.bin', 'electron-builder'), ['--mac', 'dmg', '--arm64']], + [join('node_modules', '.bin', 'electron-builder'), ['--mac', 'dir', '--arm64']], ]) { const result = spawnSync(command, args, { stdio: 'inherit', @@ -46,6 +46,28 @@ try { if (result.status !== 0) process.exitCode = result.status ?? 1; if (process.exitCode) break; } + + // electron-builder intentionally skips signing when identity is null. Re-sign the + // local tester bundle ad hoc so macOS can verify its sealed resources. This is + // not a substitute for Developer ID signing or Apple notarization. + if (!process.exitCode) { + const appPath = join('release', 'mac-arm64', 'Unvibe.app'); + const sign = spawnSync('codesign', ['--force', '--deep', '--sign', '-', appPath], { stdio: 'inherit' }); + if (sign.status !== 0) process.exitCode = sign.status ?? 1; + } + + if (!process.exitCode) { + const result = spawnSync(join('node_modules', '.bin', 'electron-builder'), ['--mac', 'dmg', '--arm64', '--prepackaged', join('release', 'mac-arm64')], { + stdio: 'inherit', + env: { + ...process.env, + PATH: `${toolDirectory}:${process.env.PATH ?? ''}`, + UNVIBE_BACKEND: backend, + CSC_IDENTITY_AUTO_DISCOVERY: 'false', + }, + }); + if (result.status !== 0) process.exitCode = result.status ?? 1; + } } finally { rmSync(toolDirectory, { recursive: true, force: true }); } @@ -56,5 +78,5 @@ if (!artifact) fail('electron-builder completed without a DMG artifact.'); const checksum = spawnSync('shasum', ['-a', '256', join('release', artifact)], { encoding: 'utf8' }); if (checksum.status !== 0) fail('could not generate SHA-256 checksum.'); writeFileSync(join('release', `${artifact}.sha256`), checksum.stdout, { mode: 0o600 }); -console.log(`Unsigned staging package: release/${artifact}`); +console.log(`Ad-hoc-signed staging package: release/${artifact}`); console.log(`Checksum: release/${artifact}.sha256`); diff --git a/app/scripts/verify-package.mjs b/app/scripts/verify-package.mjs index 11c4693..54abe51 100644 --- a/app/scripts/verify-package.mjs +++ b/app/scripts/verify-package.mjs @@ -28,6 +28,8 @@ if (backendUrl.protocol !== 'https:' || ['localhost', '127.0.0.1', '::1'].includ } const plist = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(appPath, 'Contents', 'Info.plist')], { encoding: 'utf8' }); if (plist.stdout.trim() !== 'com.unvibe.app') fail(`unexpected bundle id: ${plist.stdout.trim() || 'missing'}.`); +const signature = spawnSync('codesign', ['--verify', '--deep', '--strict', appPath], { encoding: 'utf8' }); +if (signature.status !== 0) fail(signature.stderr || signature.stdout || 'app bundle fails macOS code-integrity verification.'); console.log(checksum.stdout.trim()); console.log(`Package checks passed: artifact, checksum, app bundle, bundle id, and HTTPS backend ${backendUrl.origin}.`); -console.log('Signing and notarization are intentionally not asserted by this unsigned-package check.'); +console.log('The local package has an ad-hoc integrity signature only; Developer ID signing and Apple notarization are still required for public distribution.'); diff --git a/app/src/core/gitDiff.ts b/app/src/core/gitDiff.ts new file mode 100644 index 0000000..268b4d9 --- /dev/null +++ b/app/src/core/gitDiff.ts @@ -0,0 +1,135 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import type { DiffHunk } from './protocol'; + +const pexecFile = promisify(execFile); +const MAX_BUFFER = 12 * 1024 * 1024; +const MAX_UNTRACKED_FILES = 20; +const MAX_UNTRACKED_LINES = 200; +const MAX_UNTRACKED_BYTES = 80_000; + +/** Parse unified `git diff` output into structured hunks. */ +export function parseUnifiedDiff(diff: string): DiffHunk[] { + const hunks: DiffHunk[] = []; + let currentFile = ''; + let current: DiffHunk | undefined; + + for (const line of diff.split(/\r?\n/)) { + if (line.startsWith('+++ ')) { + currentFile = stripDiffPrefix(line.slice(4).trim()); + current = undefined; + continue; + } + if (line.startsWith('--- ') || line.startsWith('diff --git') || line.startsWith('index ')) { + current = undefined; + continue; + } + if (line.startsWith('@@')) { + const m = /@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (m) { + current = { + file: currentFile, + oldStart: Number(m[1]), + oldLines: m[2] ? Number(m[2]) : 1, + newStart: Number(m[3]), + newLines: m[4] ? Number(m[4]) : 1, + lines: [], + }; + hunks.push(current); + } + continue; + } + if (current && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) { + current.lines.push(line); + } + } + return hunks; +} + +function stripDiffPrefix(pathName: string): string { + if (pathName === '/dev/null') return pathName; + if (pathName.startsWith('a/') || pathName.startsWith('b/')) return pathName.slice(2); + return pathName; +} + +async function untrackedAsHunks(repoRoot: string): Promise { + try { + const { stdout } = await pexecFile('git', ['ls-files', '--others', '--exclude-standard'], { + cwd: repoRoot, + maxBuffer: MAX_BUFFER, + }); + const files = stdout + .split(/\r?\n/) + .map((f) => f.trim()) + .filter(Boolean) + .slice(0, MAX_UNTRACKED_FILES); + + const hunks: DiffHunk[] = []; + for (const file of files) { + try { + const abs = path.join(repoRoot, file); + const text = await readFile(abs, 'utf8'); + if (text.length > MAX_UNTRACKED_BYTES) continue; + const body = text.split(/\r?\n/).slice(0, MAX_UNTRACKED_LINES); + hunks.push({ + file, + oldStart: 0, + oldLines: 0, + newStart: 1, + newLines: body.length, + lines: body.map((line) => `+${line}`), + }); + } catch { + /* skip unreadable */ + } + } + return hunks; + } catch { + return []; + } +} + +/** Working-tree diff relative to HEAD (staged + unstaged + capped untracked). */ +export async function getWorkingTreeDiff(repoRoot: string, files?: string[]): Promise { + const fileArgs = files && files.length ? ['--', ...files] : []; + let tracked: DiffHunk[] = []; + try { + const { stdout } = await pexecFile('git', ['diff', '--unified=3', '--no-color', 'HEAD', ...fileArgs], { + cwd: repoRoot, + maxBuffer: MAX_BUFFER, + }); + tracked = parseUnifiedDiff(stdout); + } catch { + const { stdout } = await pexecFile('git', ['diff', '--unified=3', '--no-color', ...fileArgs], { + cwd: repoRoot, + maxBuffer: MAX_BUFFER, + }); + tracked = parseUnifiedDiff(stdout); + } + + // Untracked files are common after agent runs; include them when scanning the whole tree. + if (!files?.length) { + const untracked = await untrackedAsHunks(repoRoot); + const seen = new Set(tracked.map((h) => h.file)); + for (const h of untracked) { + if (!seen.has(h.file)) tracked.push(h); + } + } + return tracked; +} + +export function capHunks(hunks: DiffHunk[], maxHunks = 40, maxLinesPerHunk = 200): DiffHunk[] { + return hunks.slice(0, maxHunks).map((h) => ({ ...h, lines: h.lines.slice(0, maxLinesPerHunk) })); +} + +export async function findGitRoot(startDir: string): Promise { + try { + const { stdout } = await pexecFile('git', ['rev-parse', '--show-toplevel'], { cwd: startDir }); + const root = stdout.trim(); + return root || null; + } catch { + return null; + } +} diff --git a/app/src/core/learning.ts b/app/src/core/learning.ts index 3fc5390..3c9773f 100644 --- a/app/src/core/learning.ts +++ b/app/src/core/learning.ts @@ -24,6 +24,10 @@ export interface LocalEvent { project?: string; concept?: string; conceptLabel?: string; + /** Local-only teaching content — never synced to the cloud. */ + code?: string; + /** Local-only explanation text — never synced to the cloud. */ + explanation?: string; } export interface Usage { @@ -58,6 +62,26 @@ export interface FeedItem { outcome: Outcome; } +/** Learning view item. Code/explanation stay on-device when present. */ +export interface LearningItem extends FeedItem { + concept?: string; + level: string; + lines: number; + file?: string; + project?: string; + scope?: string; + dueLabel?: string; + language?: string; + code?: string; + explanation?: string; +} + +/** Strip local-only lesson bodies before any remote sync. */ +export function forSync(event: LocalEvent): LocalEvent { + const { code: _code, explanation: _explanation, ...rest } = event; + return rest; +} + export const HEAT_DAYS = 182; function dayKey(iso: string): string { @@ -186,14 +210,68 @@ const OUTCOME_LABEL: Record = { }; export function computeFeed(events: LocalEvent[], limit: number): FeedItem[] { + return computeLearningItems(events, limit).map(({ id, ts, title, meta, outcome }) => ({ id, ts, title, meta, outcome })); +} + +export function computeLearningItems(events: LocalEvent[], limit: number): LearningItem[] { return [...events] - .reverse() + .sort((a, b) => b.ts.localeCompare(a.ts)) .slice(0, limit) - .map((e) => ({ - id: e.id, - ts: e.ts, - title: e.conceptLabel ?? `${e.lines} lines of ${e.language ?? 'code'}`, - meta: [e.sourceApp, OUTCOME_LABEL[e.outcome]].filter(Boolean).join(' · '), - outcome: e.outcome, - })); + .map((e) => toLearningItem(e)); +} + +function toLearningItem(e: LocalEvent, dueLabel?: string): LearningItem { + return { + id: e.id, + ts: e.ts, + title: e.conceptLabel ?? (e.file ? pathTitle(e.file) : `${e.lines} lines of ${e.language ?? 'code'}`), + meta: [e.scope && e.scope !== 'selection' ? e.scope : undefined, e.sourceApp, OUTCOME_LABEL[e.outcome]].filter(Boolean).join(' · '), + outcome: e.outcome, + concept: e.conceptLabel ?? e.concept, + level: e.level, + lines: e.lines, + file: e.file, + project: e.project, + scope: e.scope, + language: e.language, + code: e.code, + explanation: e.explanation, + ...(dueLabel ? { dueLabel } : {}), + }; +} + +function pathTitle(file: string): string { + const parts = file.split(/[/\\]/); + return parts[parts.length - 1] || file; +} + +/** + * Spaced review queue: needs_review first, then understood items past 1/3/7 day intervals. + */ +export function computeReviewQueue(events: LocalEvent[], now = new Date(), limit = 20): LearningItem[] { + const nowMs = now.getTime(); + const intervalsDays = [1, 3, 7, 14]; + + const needs = events + .filter((e) => e.outcome === 'needs_review') + .sort((a, b) => b.ts.localeCompare(a.ts)); + + const understood = events + .filter((e) => e.outcome === 'understood') + .sort((a, b) => a.ts.localeCompare(b.ts)); + + const dueUnderstood: Array = []; + for (const e of understood) { + const ageDays = Math.floor((nowMs - new Date(e.ts).getTime()) / 86_400_000); + const hits = intervalsDays.filter((d) => ageDays >= d); + if (hits.length === 0) continue; + dueUnderstood.push({ ...e, dueLabel: `${hits[hits.length - 1]}d revisit` }); + } + dueUnderstood.sort((a, b) => a.ts.localeCompare(b.ts)); + + const merged = [...needs.map((e) => ({ ...e, dueLabel: 'Needs review' as string })), ...dueUnderstood] + .slice(0, limit); + + // Preserve queue order (needs_review first); do not re-sort by recency. + return merged.map((e) => toLearningItem(e, e.dueLabel)); } diff --git a/app/src/core/parse.ts b/app/src/core/parse.ts new file mode 100644 index 0000000..f7de2ec --- /dev/null +++ b/app/src/core/parse.ts @@ -0,0 +1,41 @@ +/** Pure parsing helpers for nearby-file context. */ + +const IMPORT_PATTERNS = [ + /^\s*import\s.+/, + /^\s*export\s+.*\sfrom\s+['"].+['"]/, + /^\s*const\s+.+=\s*require\(['"].+['"]\)/, + /^\s*from\s+\S+\s+import\s.+/, + /^\s*import\s+\S+/, + /^\s*#include\s+[<"].+[>"]/, + /^\s*using\s+[\w.]+;/, + /^\s*use\s+[\w:]+;/, +]; + +export function extractImports(source: string, max = 40): string[] { + const out: string[] = []; + const seen = new Set(); + for (const raw of source.split(/\r?\n/)) { + const line = raw.trimEnd(); + if (IMPORT_PATTERNS.some((re) => re.test(line))) { + const key = line.trim(); + if (!seen.has(key)) { + seen.add(key); + out.push(key); + if (out.length >= max) break; + } + } + } + return out; +} + +/** Pull relative module paths from common JS/TS import lines. */ +export function relativeImportPaths(importLines: string[], max = 6): string[] { + const out: string[] = []; + for (const line of importLines) { + const m = /['"](\.[^'"]+)['"]/.exec(line); + if (!m) continue; + out.push(m[1]!); + if (out.length >= max) break; + } + return out; +} diff --git a/app/src/core/syncModel.ts b/app/src/core/syncModel.ts index 20b1be3..6a1e8d9 100644 --- a/app/src/core/syncModel.ts +++ b/app/src/core/syncModel.ts @@ -19,7 +19,13 @@ export function mergeRemoteEvents( events.push(event); merged += 1; } else if (!pending.has(event.id)) { - events[idx] = event; + const previous = events[idx]!; + // Keep on-device lesson bodies when the cloud mirror has none. + events[idx] = { + ...event, + code: previous.code ?? event.code, + explanation: previous.explanation ?? event.explanation, + }; merged += 1; } } diff --git a/app/src/main/aiKey.ts b/app/src/main/aiKey.ts new file mode 100644 index 0000000..908d71b --- /dev/null +++ b/app/src/main/aiKey.ts @@ -0,0 +1,50 @@ +/** + * Optional user-supplied AI API key. Stored encrypted with Electron safeStorage. + * Never sent to the Unvibe backend — only used for direct provider calls from main. + */ +import { app, safeStorage } from 'electron'; +import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { openToken, sealToken } from '../core/tokenVault'; + +function keyFile(): string { + return path.join(app.getPath('userData'), 'unvibe-ai-key.bin'); +} + +export function hasAiKey(): boolean { + return existsSync(keyFile()); +} + +export function readAiKey(): string | null { + try { + if (!existsSync(keyFile())) return null; + const encoded = readFileSync(keyFile(), 'utf8').trim(); + if (!encoded) return null; + return openToken(encoded, safeStorage); + } catch { + return null; + } +} + +export function writeAiKey(key: string): void { + const trimmed = key.trim(); + if (!trimmed) throw new Error('Paste a non-empty API key.'); + const encoded = sealToken(trimmed, safeStorage); + mkdirSync(path.dirname(keyFile()), { recursive: true }); + writeFileSync(keyFile(), encoded, { mode: 0o600 }); +} + +export function clearAiKey(): void { + try { + if (existsSync(keyFile())) unlinkSync(keyFile()); + } catch { + /* best-effort */ + } +} + +export function aiKeyStatus(): { present: boolean; hint: string | null } { + const key = readAiKey(); + if (!key) return { present: false, hint: null }; + const hint = key.length <= 8 ? '••••' : `${key.slice(0, 4)}…${key.slice(-4)}`; + return { present: true, hint }; +} diff --git a/app/src/main/backend.ts b/app/src/main/backend.ts index 78b2e83..0a86337 100644 --- a/app/src/main/backend.ts +++ b/app/src/main/backend.ts @@ -2,7 +2,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import type { LocalEvent } from '../core/learning'; +import { forSync, type LocalEvent } from '../core/learning'; import type { ComprehensionQuestion, ReviewRequestPayload } from '../core/protocol'; /** Load .env into process.env when present (dev + packaged convenience). Never overrides existing vars. */ @@ -38,7 +38,16 @@ loadAppEnv(); const BAKED_RELEASE_BACKEND = process.env.UNVIBE_RELEASE_BACKEND || ''; export function resolveBackendUrl(env: NodeJS.ProcessEnv = process.env, baked = BAKED_RELEASE_BACKEND): string { - return baked || env.UNVIBE_BACKEND || 'http://localhost:8787'; + // Packaged builds bake the API host (never the marketing site). Runtime UNVIBE_BACKEND + // still wins for local development. Ignore stale localhost overrides in userData when a + // release bake is present so old Application Support .env files cannot break demos. + const explicit = env.UNVIBE_BACKEND?.trim(); + const bakedUrl = baked?.trim(); + if (explicit) { + const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?\/?$/i.test(explicit); + if (!(bakedUrl && isLocal)) return explicit; + } + return bakedUrl || 'http://localhost:8787'; } export const BACKEND = resolveBackendUrl(); @@ -64,8 +73,9 @@ async function request(url: string, init: RequestInit): Promise { async function json(res: Response): Promise { if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { message?: string }; if (res.status === 401) throw new Error('Your session has expired. Please sign in again.'); - if (res.status === 429) throw new Error('Too many requests. Please wait a moment and try again.'); + if (res.status === 429) throw new Error(body.message ?? 'Your plan limit has been reached. Open Plan & usage to review options.'); if (res.status === 501) throw new Error('Cloud sign-in is not configured for this build. You can keep learning locally.'); if (res.status >= 500) throw new Error('The service is temporarily unavailable. Please try again shortly.'); throw new Error(`The service could not complete that request (${res.status}).`); @@ -73,6 +83,39 @@ async function json(res: Response): Promise { return (await res.json()) as T; } +export interface BillingUsageLine { kind: string; used: number; limit: number; remaining: number; resetsAt: string } +export interface BillingOverview { + workspace: { id: string; name: string; type: 'personal' | 'team'; role: 'owner' | 'admin' | 'member' }; + subscription: { plan: 'free' | 'pro' | 'teams'; interval: 'monthly' | 'annual' | null; status: string; seats: number; currentPeriodEnd?: string; cancelAtPeriodEnd: boolean }; + usage: BillingUsageLine[]; + occupiedSeats: number; + pendingInvitations: number; + minimumBillableSeats: number; + canManageBilling: boolean; + hasBillingAccount: boolean; +} + +export async function billingOverview(token: string): Promise<{ overview: BillingOverview; checkoutAvailable: boolean }> { + return json(await request(`${BACKEND}/api/v1/billing/overview`, { headers: { authorization: `Bearer ${token}` } })); +} + +export async function startBillingCheckout(token: string, input: { plan: 'pro' | 'teams'; interval: 'monthly' | 'annual'; seats: number; workspaceId?: string; workspaceName?: string }): Promise { + if (input.plan === 'teams') { + throw new Error('Teams is not available right now. Choose Pro for a personal plan.'); + } + const result = await json<{ url: string }>(await request(`${BACKEND}/api/v1/billing/checkout`, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(input), + })); + return result.url; +} + +export async function startBillingPortal(token: string, workspaceId: string): Promise { + const result = await json<{ url: string }>(await request(`${BACKEND}/api/v1/billing/portal`, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ workspaceId }), + })); + return result.url; +} + export interface Account { token: string; userId: string; @@ -138,7 +181,7 @@ export async function pushEvents(token: string, events: LocalEvent[]): Promise(res); return events.map((e) => e.id); diff --git a/app/src/main/contextBuilder.ts b/app/src/main/contextBuilder.ts new file mode 100644 index 0000000..bbc12c8 --- /dev/null +++ b/app/src/main/contextBuilder.ts @@ -0,0 +1,278 @@ +/** + * Builds filtered review payloads in the main process (selection, diff, nearby files, compare). + */ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { capHunks, findGitRoot, getWorkingTreeDiff } from '../core/gitDiff'; +import { extractImports, relativeImportPaths } from '../core/parse'; +import { guessLanguage } from '../core/language'; +import type { DiffHunk, ExplanationLevel, ReviewRequestPayload, ReviewScope } from '../core/protocol'; +import { store } from './store'; +import { settings } from './settings'; + +const MAX_NEARBY_CHARS = 8_000; +const MAX_SNAPSHOT_CHARS = 12_000; + +export type ReviewMode = 'selection' | 'diff' | 'brief' | 'compare'; + +export interface BuiltReview { + payload: ReviewRequestPayload; + displayCode: string; + file?: string; + project?: string; + /** Absolute path to the source file when known (for reopen / nearby). */ + sourceFilePath?: string; + /** Raw file text for "since last understood" snapshots — never diff/compare display blobs. */ + snapshotText?: string; + lines: number; + language: string; + mode: ReviewMode; +} + +function isInsideRepo(repoRoot: string, candidate: string): boolean { + const rel = path.relative(path.resolve(repoRoot), path.resolve(candidate)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +function projectName(repoRoot: string): string { + return path.basename(repoRoot); +} + +async function readNearbyFiles(repoRoot: string, fromFile: string, code: string): Promise<{ imports: string[]; enclosing?: string }> { + const importLines = extractImports(code); + const rels = relativeImportPaths(importLines, 4); + const snippets: string[] = []; + const resolved: string[] = []; + const baseDir = path.dirname(path.join(repoRoot, fromFile)); + + for (const rel of rels) { + if (resolved.length >= 2) break; + const candidates = [ + path.resolve(baseDir, rel), + path.resolve(baseDir, `${rel}.ts`), + path.resolve(baseDir, `${rel}.tsx`), + path.resolve(baseDir, `${rel}.js`), + path.resolve(baseDir, `${rel}/index.ts`), + ]; + for (const candidate of candidates) { + if (!isInsideRepo(repoRoot, candidate)) continue; + try { + const text = await readFile(candidate, 'utf8'); + const clipped = text.slice(0, Math.floor(MAX_NEARBY_CHARS / 2)); + const relPath = path.relative(repoRoot, candidate); + snippets.push(`### ${relPath}\n\`\`\`\n${clipped}\n\`\`\``); + resolved.push(relPath); + break; + } catch { + /* try next */ + } + } + } + + return { + imports: [...importLines.slice(0, 30), ...resolved.map((p) => `nearby:${p}`)], + enclosing: snippets.length ? snippets.join('\n\n') : undefined, + }; +} + +export async function resolveRepoRoot(preferred?: string): Promise { + if (preferred) { + const root = await findGitRoot(preferred); + if (root) { + settings().set({ lastProjectRoot: root }); + return root; + } + } + const remembered = settings().all().lastProjectRoot; + if (remembered) { + const root = await findGitRoot(remembered); + if (root) return root; + } + return null; +} + +export async function buildSelectionPayload(opts: { + code: string; + level: ExplanationLevel; + filePath?: string; + repoRoot?: string | null; + withNearby: boolean; + question?: string; + variant?: 'default' | 'different'; +}): Promise { + const repoRoot = opts.repoRoot ?? (opts.filePath ? await resolveRepoRoot(path.dirname(opts.filePath)) : await resolveRepoRoot()); + const relFile = opts.filePath && repoRoot + ? path.relative(repoRoot, opts.filePath) + : opts.filePath ? path.basename(opts.filePath) : undefined; + + let imports: string[] = extractImports(opts.code); + let enclosing: string | undefined; + if (opts.withNearby && repoRoot && relFile) { + const nearby = await readNearbyFiles(repoRoot, relFile, opts.code); + imports = nearby.imports; + enclosing = nearby.enclosing; + } + + const language = opts.filePath + ? guessLanguage(opts.code) // keep simple; path-aware later + : guessLanguage(opts.code); + + const payload: ReviewRequestPayload = { + scope: 'selection', + level: opts.level, + question: opts.question, + variant: opts.variant ?? 'default', + context: { + language, + primaryFile: relFile, + projectStructure: repoRoot ? [projectName(repoRoot)] : [], + imports, + code: opts.code, + enclosing, + selection: relFile ? { file: relFile, startLine: 1, endLine: opts.code.split('\n').length } : undefined, + }, + }; + + return { + payload, + displayCode: opts.code, + file: relFile, + project: repoRoot ? projectName(repoRoot) : undefined, + sourceFilePath: opts.filePath, + snapshotText: opts.filePath ? opts.code.slice(0, MAX_SNAPSHOT_CHARS) : undefined, + lines: opts.code.split('\n').length, + language, + mode: 'selection', + }; +} + +function diffAsDisplay(hunks: DiffHunk[]): string { + return hunks + .slice(0, 12) + .map((h) => `// ${h.file}\n${h.lines.slice(0, 80).join('\n')}`) + .join('\n\n') + .slice(0, 40_000); +} + +export async function buildDiffPayload(opts: { + repoRoot: string; + level: ExplanationLevel; + mode: 'diff' | 'brief'; + question?: string; +}): Promise { + const hunks = capHunks(await getWorkingTreeDiff(opts.repoRoot)); + if (hunks.length === 0) { + throw new Error('No uncommitted changes in this repository.'); + } + const displayCode = diffAsDisplay(hunks); + const primary = hunks[0]!.file; + const briefQuestion = opts.mode === 'brief' + ? 'Agent change brief: Summarize what an AI agent or developer changed, why it matters, risks, and the top concepts to learn. Keep it skimmable.' + : opts.question; + + const payload: ReviewRequestPayload = { + scope: 'diff', + level: opts.level, + question: briefQuestion, + context: { + language: guessLanguage(displayCode), + primaryFile: primary, + projectStructure: [projectName(opts.repoRoot)], + imports: [], + code: displayCode, + diffHunks: hunks, + }, + }; + + return { + payload, + displayCode, + file: primary, + project: projectName(opts.repoRoot), + lines: displayCode.split('\n').length, + language: payload.context.language, + mode: opts.mode, + }; +} + +/** Compare current file to the last locally understood snapshot (Pro). */ +export async function buildComparePayload(opts: { + filePath: string; + level: ExplanationLevel; + repoRoot?: string | null; +}): Promise { + const current = await readFile(opts.filePath, 'utf8'); + const repoRoot = opts.repoRoot ?? await resolveRepoRoot(path.dirname(opts.filePath)); + const relFile = repoRoot ? path.relative(repoRoot, opts.filePath) : path.basename(opts.filePath); + const project = repoRoot ? projectName(repoRoot) : undefined; + + const prior = store().events() + .filter((e) => e.outcome === 'understood' && e.file === relFile && (!project || e.project === project)) + .sort((a, b) => b.ts.localeCompare(a.ts))[0]; + + const snapshot = store().fileSnapshot(relFile, project); + const previous = snapshot?.text; + + if (!previous) { + // Do not write a snapshot yet — "Got it" / quiz-correct establishes the baseline. + const first = await buildSelectionPayload({ + code: current.slice(0, MAX_SNAPSHOT_CHARS), + level: opts.level, + filePath: opts.filePath, + repoRoot, + withNearby: true, + question: prior + ? 'You marked this file understood before, but no local snapshot was kept. Explain the current file and note what to re-check.' + : 'First pass on this file. Explain it clearly, then tap Got it so future compares have a baseline.', + }); + return { ...first, mode: 'compare', snapshotText: current.slice(0, MAX_SNAPSHOT_CHARS) }; + } + + const combined = [ + '## Previously understood version (local snapshot)', + '```', + previous.slice(0, MAX_SNAPSHOT_CHARS / 2), + '```', + '', + '## Current version', + '```', + current.slice(0, MAX_SNAPSHOT_CHARS / 2), + '```', + ].join('\n'); + + const payload: ReviewRequestPayload = { + scope: 'file', + level: opts.level, + question: 'What changed since I last understood this file? Focus on behavior, risks, and what I should re-learn.', + context: { + language: guessLanguage(current), + primaryFile: relFile, + projectStructure: project ? [project] : [], + imports: extractImports(current), + code: combined, + }, + }; + + return { + payload, + displayCode: combined, + file: relFile, + project, + sourceFilePath: opts.filePath, + snapshotText: current.slice(0, MAX_SNAPSHOT_CHARS), + lines: current.split('\n').length, + language: payload.context.language, + mode: 'compare', + }; +} + +export function isProPlan(plan: string): boolean { + return plan === 'pro' || plan === 'teams'; +} + +export function scopeLabel(scope: ReviewScope | ReviewMode): string { + if (scope === 'brief') return 'Agent brief'; + if (scope === 'compare') return 'Since last understood'; + if (scope === 'diff') return 'Git diff'; + return 'Selection'; +} diff --git a/app/src/main/localAi.ts b/app/src/main/localAi.ts new file mode 100644 index 0000000..a0eb9b1 --- /dev/null +++ b/app/src/main/localAi.ts @@ -0,0 +1,420 @@ +/** + * Local (BYOK) AI streaming. Key stays on this Mac; never forwarded to Unvibe. + * Cheap default models per provider — Anthropic / OpenAI / Gemini / Grok / DeepSeek / Kimi. + */ +import type { ExplanationLevel, ReviewRequestPayload } from '../core/protocol'; + +export type LocalAiProviderId = + | 'gemini' + | 'anthropic' + | 'openai' + | 'grok' + | 'deepseek' + | 'kimi'; + +export interface LocalAiProviderInfo { + id: LocalAiProviderId; + label: string; + model: string; + blurb: string; + inputPerM: number; + outputPerM: number; + maxOut: number; + /** OpenAI-compatible base URL when applicable. */ + baseUrl?: string; +} + +const PROVIDERS: Record = { + gemini: { + id: 'gemini', + label: 'Gemini', + model: 'gemini-2.5-flash-lite', + blurb: 'Google Flash-Lite — usually the cheapest daily pick.', + inputPerM: 0.10, + outputPerM: 0.40, + maxOut: 2048, + }, + anthropic: { + id: 'anthropic', + label: 'Anthropic', + model: 'claude-haiku-4-5', + blurb: 'Claude Haiku — fast and cheaper than Sonnet/Opus.', + inputPerM: 1, + outputPerM: 5, + maxOut: 1024, + }, + openai: { + id: 'openai', + label: 'OpenAI', + model: 'gpt-4o-mini', + blurb: 'GPT-4o mini — solid and inexpensive for explanations.', + inputPerM: 0.15, + outputPerM: 0.60, + maxOut: 1024, + baseUrl: 'https://api.openai.com/v1', + }, + grok: { + id: 'grok', + label: 'Grok', + model: 'grok-3-mini', + blurb: 'xAI Grok mini — OpenAI-compatible, keep costs low.', + inputPerM: 0.30, + outputPerM: 0.50, + maxOut: 1024, + baseUrl: 'https://api.x.ai/v1', + }, + deepseek: { + id: 'deepseek', + label: 'DeepSeek', + model: 'deepseek-chat', + blurb: 'DeepSeek chat — strong value for code comprehension.', + inputPerM: 0.28, + outputPerM: 0.42, + maxOut: 1024, + baseUrl: 'https://api.deepseek.com', + }, + kimi: { + id: 'kimi', + label: 'Kimi', + model: 'moonshot-v1-8k', + blurb: 'Moonshot Kimi — OpenAI-compatible Moonshot API.', + inputPerM: 0.20, + outputPerM: 2.0, + maxOut: 1024, + baseUrl: 'https://api.moonshot.ai/v1', + }, +}; + +export const DEFAULT_LOCAL_AI_PROVIDER: LocalAiProviderId = 'gemini'; + +/** @deprecated Prefer LocalAiProviderId — kept for older settings migration. */ +export type LocalAiModelId = string; + +export function listLocalAiProviders(): LocalAiProviderInfo[] { + return [ + PROVIDERS.gemini, + PROVIDERS.openai, + PROVIDERS.anthropic, + PROVIDERS.deepseek, + PROVIDERS.grok, + PROVIDERS.kimi, + ]; +} + +/** Alias used by IPC that previously returned models. */ +export function listLocalAiModels(): Array { + return listLocalAiProviders(); +} + +export function normalizeLocalAiProvider(value: unknown): LocalAiProviderId { + if ( + value === 'gemini' + || value === 'anthropic' + || value === 'openai' + || value === 'grok' + || value === 'deepseek' + || value === 'kimi' + ) return value; + if (value === 'gemini-2.5-flash' || value === 'gemini-2.5-flash-lite') return 'gemini'; + return DEFAULT_LOCAL_AI_PROVIDER; +} + +/** @deprecated */ +export function normalizeLocalAiModel(value: unknown): LocalAiProviderId { + return normalizeLocalAiProvider(value); +} + +export function guessProviderFromKey(key: string): LocalAiProviderId | null { + const k = key.trim(); + if (!k) return null; + const lower = k.toLowerCase(); + if (k.startsWith('AIza') || /^AI[a-zA-Z0-9_-]{20,}/.test(k)) return 'gemini'; + if (k.startsWith('sk-ant-')) return 'anthropic'; + if (k.startsWith('xai-')) return 'grok'; + if (lower.includes('moonshot') || lower.includes('kimi')) return 'kimi'; + if (lower.includes('deepseek')) return 'deepseek'; + if (k.startsWith('sk-')) return 'openai'; + return null; +} + +const LEVEL_GUIDANCE: Record = { + new: 'The reader may never have programmed. Plain language, everyday analogies, zero jargon.', + beginner: 'The reader is new to this. Avoid jargon or define it inline. Short sentences.', + intermediate: 'The reader is a working developer but new to THIS code. Be concise.', + advanced: 'The reader is experienced. Be dense and precise. Emphasise design implications.', + expert: 'The reader is a senior engineer. Skip basics. Lead with intent, tradeoffs, and failure modes.', +}; + +const LEVEL_OUT_TOKENS: Record = { + new: 450, + beginner: 500, + intermediate: 650, + advanced: 850, + expert: 1100, +}; + +export function buildLocalSystemPrompt(payload: ReviewRequestPayload): string { + return [ + 'You are Unvibe, a code-comprehension tutor. Help the developer UNDERSTAND code — do not rewrite it unless asked.', + 'Use ONLY the provided context. Separate what the code SHOWS, what you INFER, and what is UNCERTAIN.', + 'Write clean readable prose. Short paragraphs or simple markdown bullets (* item) are fine.', + 'Do NOT use [[cite:...]] markers, HTML, XML, curly-brace templates, or raw markup like {}<>?.', + 'When referring to code, say "line 12" or name the function in plain words.', + 'Be direct. No preamble.', + `Audience level — ${payload.level}: ${LEVEL_GUIDANCE[payload.level]}`, + payload.variant === 'different' ? 'Give a DIFFERENT angle than a first explanation would.' : '', + ].filter(Boolean).join('\n'); +} + +export function buildLocalUserPrompt(payload: ReviewRequestPayload): string { + const ctx = payload.context; + const parts = [ + `# Review: ${payload.scope}`, + `Language: ${ctx.language}`, + ]; + if (ctx.primaryFile) parts.push(`Primary file: ${ctx.primaryFile}`); + if (ctx.projectStructure.length) parts.push(`Project: ${ctx.projectStructure.join(', ')}`); + if (ctx.imports.length) parts.push(`\n## Imports / nearby refs\n${ctx.imports.slice(0, 40).join('\n')}`); + if (ctx.diffHunks?.length) { + parts.push('\n## Git diff'); + for (const h of ctx.diffHunks.slice(0, 20)) { + parts.push(`\n### ${h.file}\n\`\`\`diff\n${h.lines.slice(0, 120).join('\n')}\n\`\`\``); + } + } + if (ctx.enclosing) parts.push(`\n## Nearby files\n${ctx.enclosing}`); + if (ctx.code) parts.push(`\n## Code\n\`\`\`\n${ctx.code}\n\`\`\``); + if (payload.question) parts.push(`\n## Task\n${payload.question}`); + else if (payload.scope === 'diff') { + parts.push('\n## Task\nExplain what changed and why, how data flows, and what would break if reverted.'); + } else { + parts.push('\n## Task\nExplain what this selection does, why it is written this way, and what to watch for.'); + } + return parts.join('\n'); +} + +export interface CostEstimate { + provider: LocalAiProviderId; + model: string; + modelLabel: string; + level: ExplanationLevel; + lines: number; + inputTokens: number; + outputTokens: number; + usd: number; + label: string; +} + +export function estimateCost(providerId: LocalAiProviderId | string, level: ExplanationLevel, lines: number, charsPerLine = 40): CostEstimate { + const provider = PROVIDERS[normalizeLocalAiProvider(providerId)]; + const inputTokens = Math.max(80, Math.round((lines * charsPerLine) / 4) + 220); + const outputTokens = Math.min(provider.maxOut, LEVEL_OUT_TOKENS[level]); + const usd = (inputTokens / 1_000_000) * provider.inputPerM + (outputTokens / 1_000_000) * provider.outputPerM; + return { + provider: provider.id, + model: provider.model, + modelLabel: provider.label, + level, + lines, + inputTokens, + outputTokens, + usd, + label: `~$${usd < 0.01 ? usd.toFixed(4) : usd.toFixed(3)}`, + }; +} + +export function costOverview(providerId: LocalAiProviderId | string): Array<{ + level: ExplanationLevel; + samples: Array<{ lines: number; label: string; usd: number }>; +}> { + const provider = normalizeLocalAiProvider(providerId); + const levels: ExplanationLevel[] = ['beginner', 'intermediate', 'advanced', 'expert']; + const lineSamples = [50, 200, 500]; + return levels.map((level) => ({ + level, + samples: lineSamples.map((lines) => { + const e = estimateCost(provider, level, lines); + return { lines, label: e.label, usd: e.usd }; + }), + })); +} + +export async function streamLocalAi(opts: { + model?: LocalAiProviderId | string; + provider?: LocalAiProviderId | string; + apiKey: string; + system: string; + user: string; + onToken: (text: string) => void; + signal?: AbortSignal; +}): Promise { + const provider = normalizeLocalAiProvider(opts.provider ?? opts.model); + if (provider === 'gemini') { + return streamGemini(PROVIDERS.gemini, opts.apiKey, opts.system, opts.user, opts.onToken, opts.signal); + } + if (provider === 'anthropic') { + return streamAnthropic(PROVIDERS.anthropic, opts.apiKey, opts.system, opts.user, opts.onToken, opts.signal); + } + return streamOpenAiCompatible(PROVIDERS[provider], opts.apiKey, opts.system, opts.user, opts.onToken, opts.signal); +} + +async function streamGemini( + info: LocalAiProviderInfo, + apiKey: string, + system: string, + user: string, + onToken: (text: string) => void, + signal?: AbortSignal, +): Promise { + const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${info.model}:streamGenerateContent`); + url.searchParams.set('alt', 'sse'); + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey }, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: system }] }, + contents: [{ role: 'user', parts: [{ text: user }] }], + generationConfig: { + maxOutputTokens: info.maxOut, + temperature: 0.3, + thinkingConfig: { thinkingBudget: 0 }, + }, + }), + signal, + }); + if (!res.ok) throw new Error(`Gemini API ${res.status}: ${await res.text().catch(() => '')}`); + const raw = await res.text(); + let emitted = 0; + for (const event of raw.split('\n\n')) { + const dataLine = event.split('\n').find((l) => l.startsWith('data: ')); + if (!dataLine) continue; + const data = dataLine.slice(6).trim(); + if (!data) continue; + try { + const json = JSON.parse(data) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string; thought?: boolean }> } }> }; + for (const part of json.candidates?.[0]?.content?.parts ?? []) { + if (part.thought || !part.text) continue; + onToken(part.text); + emitted += part.text.length; + } + } catch { + /* skip */ + } + } + if (emitted === 0) throw new Error('Gemini returned an empty response. Check the key and try again.'); + return `gemini:${info.model}`; +} + +async function streamAnthropic( + info: LocalAiProviderInfo, + apiKey: string, + system: string, + user: string, + onToken: (text: string) => void, + signal?: AbortSignal, +): Promise { + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: info.model, + max_tokens: info.maxOut, + system, + stream: true, + messages: [{ role: 'user', content: user }], + }), + signal, + }); + if (!res.ok || !res.body) throw new Error(`Anthropic API ${res.status}: ${await res.text().catch(() => '')}`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let emitted = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let sep: number; + while ((sep = buffer.indexOf('\n\n')) >= 0) { + const rawEvent = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + const dataLine = rawEvent.split('\n').find((l) => l.startsWith('data: ')); + if (!dataLine) continue; + const data = dataLine.slice(6).trim(); + if (!data || data === '[DONE]') continue; + try { + const json = JSON.parse(data) as { type?: string; delta?: { text?: string } }; + if (json.type === 'content_block_delta' && json.delta?.text) { + onToken(json.delta.text); + emitted += json.delta.text.length; + } + } catch { + /* skip */ + } + } + } + if (emitted === 0) throw new Error('Anthropic returned an empty response. Check the key and try again.'); + return `anthropic:${info.model}`; +} + +async function streamOpenAiCompatible( + info: LocalAiProviderInfo, + apiKey: string, + system: string, + user: string, + onToken: (text: string) => void, + signal?: AbortSignal, +): Promise { + const base = (info.baseUrl ?? 'https://api.openai.com/v1').replace(/\/$/, ''); + const res = await fetch(`${base}/chat/completions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: info.model, + max_tokens: info.maxOut, + temperature: 0.3, + stream: true, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + }), + signal, + }); + if (!res.ok || !res.body) throw new Error(`${info.label} API ${res.status}: ${await res.text().catch(() => '')}`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let emitted = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let nl: number; + while ((nl = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + if (!data || data === '[DONE]') continue; + try { + const json = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> }; + const text = json.choices?.[0]?.delta?.content; + if (text) { + onToken(text); + emitted += text.length; + } + } catch { + /* skip */ + } + } + } + if (emitted === 0) throw new Error(`${info.label} returned an empty response. Check the key and try again.`); + return `${info.id}:${info.model}`; +} diff --git a/app/src/main/main.ts b/app/src/main/main.ts index 564b380..ab06d53 100644 --- a/app/src/main/main.ts +++ b/app/src/main/main.ts @@ -10,16 +10,29 @@ import { Menu, Tray, clipboard, + dialog, globalShortcut, ipcMain, nativeImage, + screen, shell, systemPreferences, } from 'electron'; import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { createBar, createCompanion, createWidget, positionBar } from './windows'; +import { + applyWidgetResize, + createBar, + createCompanion, + currentWidget, + getOrCreateWidget, + hideBar, + positionBar, + showBar, + type WidgetResizeEdge, +} from './windows'; import { captureSelection, frontmostApp, startFrontmostWatch } from './selection'; import type { ExplanationLevel } from '../core/protocol'; import { @@ -27,6 +40,8 @@ import { runReview, startComprehension, gradeComprehension, + markUnderstood, + applyBuiltReview, type ReviewSession, type RequestOpts, } from './review'; @@ -41,10 +56,30 @@ import { startDeviceAuth, redeemDeviceAuth, accountInfo, + billingOverview, + startBillingCheckout, + startBillingPortal, type Account as BackendAccount, } from './backend'; import { setBar, notify } from './notify'; -import { computeProfile, computeFeed, localDayKey } from '../core/learning'; +import { computeProfile, computeFeed, computeLearningItems, computeReviewQueue, localDayKey } from '../core/learning'; +import { resolveAppUsage } from './usage'; +import { aiKeyStatus, clearAiKey, writeAiKey } from './aiKey'; +import { + costOverview, + guessProviderFromKey, + listLocalAiProviders, + normalizeLocalAiProvider, + type LocalAiProviderId, +} from './localAi'; +import { + buildComparePayload, + buildDiffPayload, + buildSelectionPayload, + isProPlan, + resolveRepoRoot, +} from './contextBuilder'; +import { answerQuizCard, askStudyAssistant, quizCardStatus, startQuizCard, studyAskStatus } from './studyQuiz'; function firstName(): Promise { return new Promise((resolve) => { @@ -62,9 +97,116 @@ let tray: Tray | null = null; let bar: BrowserWindow | null = null; let companion: BrowserWindow | null = null; let devicePoll: NodeJS.Timeout | null = null; -const sessions = new Map(); +/** Per-tab review sessions inside the single shared panel. */ +const tabSessions = new Map(); +let activeTabId = '1'; +let panelReady = false; const normalBounds = new Map(); +function makeSession( + tabId: string, + code: string | null, + sourceApp: string | null, + level: ExplanationLevel = 'intermediate', +): ReviewSession { + return { + reviewId: randomUUID(), + tabId, + code, + sourceApp, + abort: null, + recorded: false, + level, + onRecorded: () => notify('Added to your learning history'), + onUnderstood: () => notify('Nice — concept understood'), + }; +} + +function sessionFor(tabId = activeTabId): ReviewSession | undefined { + return tabSessions.get(tabId); +} + +function clearPanelSessions(): void { + for (const session of tabSessions.values()) session.abort?.abort(); + tabSessions.clear(); + activeTabId = '1'; + panelReady = false; +} + +function beginCaptureOnActiveTab( + win: BrowserWindow, + code: string | null, + sourceApp: string | null, +): void { + const prev = sessionFor(activeTabId); + prev?.abort?.abort(); + const session = makeSession(activeTabId, code, sourceApp, prev?.level ?? 'intermediate'); + tabSessions.set(activeTabId, session); + if (!panelReady) return; + // No selection → calm picker UI (never auto-error / auto-run). + initWidget(win, session, { autoStart: Boolean(code) }); + if (code) void runReview(win, session, { level: session.level }); +} + +const MAX_PICK_BYTES = 200_000; + +function loadCodeIntoSession( + win: BrowserWindow, + session: ReviewSession, + code: string, + sourceLabel: string | null, + level?: ExplanationLevel, + meta?: { file?: string; project?: string; sourceFilePath?: string }, +): void { + session.abort?.abort(); + session.code = code; + session.sourceApp = sourceLabel; + session.recorded = false; + session.reviewId = randomUUID(); + session.payload = undefined; + session.mode = 'selection'; + session.file = meta?.file; + session.project = meta?.project; + session.sourceFilePath = meta?.sourceFilePath; + session.snapshotText = meta?.sourceFilePath ? code.slice(0, 12_000) : undefined; + if (level) session.level = level; + initWidget(win, session, { autoStart: true }); + void runReview(win, session, { level: session.level }); +} + +async function requireProOrError(win: BrowserWindow, session: ReviewSession, feature: string): Promise { + const usage = await resolveAppUsage(); + if (isProPlan(usage.plan)) return true; + win.webContents.send('review:event', { + type: 'error', + tabId: session.tabId, + code: 'pro_required', + upgradePath: '/plan', + message: `${feature} is included with Unvibe Pro. Pro connects explanations across diffs, nearby files, and what you already understood.`, + }); + return false; +} + +async function startBuiltReview( + win: BrowserWindow, + session: ReviewSession, + builder: () => Promise[1]>, + sourceLabel: string, +): Promise<{ ok: boolean; error?: string }> { + try { + const built = await builder(); + session.abort?.abort(); + session.sourceApp = sourceLabel; + session.reviewId = randomUUID(); + applyBuiltReview(session, built); + initWidget(win, session, { autoStart: true }); + void runReview(win, session, { level: session.level }); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Could not start that review.' }; + } +} + onSyncStatus((status) => { if (companion && !companion.isDestroyed()) companion.webContents.send('sync:status', status); }); @@ -88,6 +230,9 @@ function accessibilityGranted(prompt = false): boolean { return systemPreferences.isTrustedAccessibilityClient(prompt); } +/** At most one Accessibility Settings open per session from the shortcut path. */ +let accessibilitySettingsOpenedThisSession = false; + function openCompanion(): void { if (companion && !companion.isDestroyed()) { companion.show(); @@ -108,32 +253,32 @@ function asset(...parts: string[]): string { async function startReview(): Promise { broadcastShortcut(); - if (isMac && !accessibilityGranted(false)) { - accessibilityGranted(true); - if (!accessibilityGranted(false)) { - void shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'); - openCompanion(); - notify('Turn on Accessibility for Unvibe so selection works'); - } + // ⌘U must only raise the aisle + explanation panel — never System Settings. + // Never call isTrustedAccessibilityClient(true) here: prompt=true re-opens the + // macOS permission UI even when the user already toggled Accessibility on. + if (isMac && !accessibilityGranted(false) && !accessibilitySettingsOpenedThisSession) { + accessibilitySettingsOpenedThisSession = true; + notify('Enable Accessibility for Unvibe in System Settings, then quit and reopen Unvibe'); } + // Aisle + review panel only appear when you invoke a review (⌘U / start). + showBar(bar); const [code, sourceApp] = await Promise.all([captureSelection(), frontmostApp()]); - const win = createWidget(); - const session: ReviewSession = { - reviewId: randomUUID(), - code, - sourceApp, - abort: null, - recorded: false, - level: 'intermediate', - onRecorded: () => notify('Added to your learning history'), - onUnderstood: () => notify('Nice — concept understood'), - }; - sessions.set(win.webContents.id, session); - win.on('closed', () => { - session.abort?.abort(); - sessions.delete(win.webContents.id); - normalBounds.delete(win.id); - }); + const existing = currentWidget(); + const win = getOrCreateWidget(); + const windowId = win.id; + if (!existing) { + // Fresh panel — seed the default tab; auto-start runs once the renderer is ready. + clearPanelSessions(); + activeTabId = '1'; + tabSessions.set(activeTabId, makeSession(activeTabId, code, sourceApp)); + win.on('closed', () => { + clearPanelSessions(); + normalBounds.delete(windowId); + hideBar(bar); + }); + return; + } + beginCaptureOnActiveTab(win, code, sourceApp); } function registerShortcut(accel: string): boolean { @@ -152,14 +297,23 @@ function widgetOf(e: Electron.IpcMainEvent | Electron.IpcMainInvokeEvent): Brows app.setName('Unvibe'); app.whenReady().then(() => { + const fresh = settings().takeFreshStart(); store(); + if (fresh) { + // New DMG / revision cohort: empty shelf, signed out, onboarding, full Free allotment (50). + store().wipeEverything(); + } const s = settings().all(); if (isMac) app.setLoginItemSettings({ openAtLogin: s.launchAtLogin }); void flush(); startFrontmostWatch(); const dockIcon = nativeImage.createFromPath(asset('icon.png')); - if (isMac && !dockIcon.isEmpty()) app.dock?.setIcon(dockIcon); + if (isMac) { + // Keep Unvibe in the Dock / Cmd-Tab list (not a menu-bar-only accessory). + app.dock?.show(); + if (!dockIcon.isEmpty()) app.dock?.setIcon(dockIcon); + } let trayImage = nativeImage.createFromPath(asset('trayTemplate.png')); if (trayImage.isEmpty()) trayImage = dockIcon.resize({ width: 18, height: 18 }); @@ -175,15 +329,15 @@ app.whenReady().then(() => { { label: 'Quit Unvibe', role: 'quit' }, ]), ); + tray.on('click', () => openCompanion()); bar = createBar(); setBar(bar); - if (bar && !bar.isDestroyed()) { - bar.showInactive(); - positionBar(bar); - } + positionBar(bar); + // Keep the aisle hidden until ⌘U / Review — less distraction. + hideBar(bar); registerShortcut(s.shortcut); - // Always open the companion home window and keep the bottom bar visible on launch. + // Companion home keeps the app visible in Dock / Cmd-Tab on launch. openCompanion(); // --- bar / companion --- @@ -191,45 +345,156 @@ app.whenReady().then(() => { ipcMain.on('bar:openCompanion', () => openCompanion()); ipcMain.on('companion:review', () => void startReview()); - // --- widget lifecycle --- + // --- widget lifecycle (single panel + tabs) --- ipcMain.on('widget:ready', (e) => { const win = widgetOf(e); - const session = sessions.get(e.sender.id); - if (!win || !session) return; - initWidget(win, session); + if (!win) return; + panelReady = true; + const session = sessionFor(activeTabId); + if (!session) return; + initWidget(win, session, { autoStart: Boolean(session.code) }); + if (session.code) void runReview(win, session, { level: session.level }); + }); + ipcMain.on('widget:setActiveTab', (_e, tabId: string) => { + if (typeof tabId !== 'string' || !tabId) return; + activeTabId = tabId; + if (!tabSessions.has(tabId)) tabSessions.set(tabId, makeSession(tabId, null, null)); + }); + ipcMain.on('widget:addTab', (e, tabId: string) => { + const win = widgetOf(e); + if (!win || typeof tabId !== 'string' || !tabId) return; + activeTabId = tabId; + const session = makeSession(tabId, null, null); + tabSessions.set(tabId, session); + initWidget(win, session, { autoStart: false }); + }); + ipcMain.on('widget:closeTab', (e, tabId: string) => { + const win = widgetOf(e); + if (!win || typeof tabId !== 'string') return; + const closing = tabSessions.get(tabId); + closing?.abort?.abort(); + tabSessions.delete(tabId); + if (tabSessions.size === 0) { + win.close(); + return; + } + if (activeTabId === tabId) { + activeTabId = [...tabSessions.keys()][0]!; + const next = sessionFor(activeTabId); + if (next) initWidget(win, next, { autoStart: false }); + } }); ipcMain.on('widget:request', (e, opts: RequestOpts) => { const win = widgetOf(e); - const session = sessions.get(e.sender.id); + const session = sessionFor(activeTabId); if (win && session) void runReview(win, session, opts); }); ipcMain.on('widget:useClipboard', (e, opts: { level?: ExplanationLevel }) => { const win = widgetOf(e); - const session = sessions.get(e.sender.id); + const session = sessionFor(activeTabId); if (!win || !session) return; - const text = clipboard.readText(); - session.code = text.length > 0 ? text : null; - session.recorded = false; - if (opts?.level) session.level = opts.level; - initWidget(win, session); + const text = clipboard.readText().trim(); + if (!text) { + // Stay on the calm picker — don't surface an error. + initWidget(win, session, { autoStart: false }); + return; + } + loadCodeIntoSession(win, session, text, 'Clipboard', opts?.level); + }); + ipcMain.handle('widget:pickFile', async (e, opts?: { level?: ExplanationLevel }) => { + const win = widgetOf(e); + const session = sessionFor(activeTabId); + if (!win || !session) return { ok: false as const }; + const picked = await dialog.showOpenDialog(win, { + title: 'Choose a file to explain', + message: 'Pick a source file from your project', + properties: ['openFile'], + filters: [ + { + name: 'Code', + extensions: [ + 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'go', 'rs', 'java', 'kt', 'swift', + 'c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php', 'md', 'json', 'css', 'scss', + 'html', 'sql', 'sh', 'zsh', 'yml', 'yaml', 'toml', + ], + }, + { name: 'All files', extensions: ['*'] }, + ], + }); + if (picked.canceled || !picked.filePaths[0]) return { ok: false as const, cancelled: true as const }; + const filePath = picked.filePaths[0]; + let text: string; + try { + const buf = await readFile(filePath); + if (buf.byteLength > MAX_PICK_BYTES) { + return { ok: false as const, error: 'That file is a bit large — pick a smaller source file (under ~200KB).' }; + } + text = buf.toString('utf8').trim(); + } catch { + return { ok: false as const, error: 'Could not read that file.' }; + } + if (!text) return { ok: false as const, error: 'That file looks empty.' }; + const root = await resolveRepoRoot(path.dirname(filePath)); + const rel = root ? path.relative(root, filePath) : path.basename(filePath); + const usage = await resolveAppUsage(); + try { + const built = await buildSelectionPayload({ + code: text, + level: opts?.level ?? session.level, + filePath, + repoRoot: root, + withNearby: isProPlan(usage.plan), + }); + session.abort?.abort(); + session.sourceApp = path.basename(filePath); + session.reviewId = randomUUID(); + applyBuiltReview(session, built); + session.file = rel; + session.project = built.project; + initWidget(win, session, { autoStart: true }); + void runReview(win, session, { level: session.level }); + } catch { + loadCodeIntoSession(win, session, text, path.basename(filePath), opts?.level, { + file: rel, + project: root ? path.basename(root) : undefined, + sourceFilePath: filePath, + }); + } + return { ok: true as const }; + }); + ipcMain.on('widget:usePaste', (e, payload: { text?: string; level?: ExplanationLevel }) => { + const win = widgetOf(e); + const session = sessionFor(activeTabId); + if (!win || !session) return; + const text = typeof payload?.text === 'string' ? payload.text.trim() : ''; + if (!text) { + initWidget(win, session, { autoStart: false }); + return; + } + loadCodeIntoSession(win, session, text, 'Pasted code', payload?.level); }); ipcMain.on('widget:cancel', (e) => { const win = widgetOf(e); - const session = sessions.get(e.sender.id); + const session = sessionFor(activeTabId); if (!win || !session) return; session.abort?.abort(); - win.webContents.send('review:event', { type: 'cancelled' }); + win.webContents.send('review:event', { type: 'cancelled', tabId: session.tabId }); }); ipcMain.on('widget:testMe', (e) => { const win = widgetOf(e); - const session = sessions.get(e.sender.id); + const session = sessionFor(activeTabId); if (win && session) void startComprehension(win, session); }); ipcMain.on('widget:answer', (e, choice: number) => { const win = widgetOf(e); - const session = sessions.get(e.sender.id); + const session = sessionFor(activeTabId); if (win && session) gradeComprehension(win, session, choice); }); + ipcMain.on('widget:gotIt', (e) => { + const win = widgetOf(e); + const session = sessionFor(activeTabId); + if (win && session) markUnderstood(win, session); + }); ipcMain.on('widget:pin', (e, pinned: boolean) => { const win = widgetOf(e); if (!win) return; @@ -239,19 +504,65 @@ app.whenReady().then(() => { ipcMain.on('widget:collapse', (e, collapsed: boolean) => { const win = widgetOf(e); if (!win) return; + endWidgetResize(); if (collapsed) { normalBounds.set(win.id, win.getBounds()); - win.setResizable(false); win.setBounds({ ...win.getBounds(), height: 52 }); } else { const prev = normalBounds.get(win.id); - win.setResizable(true); if (prev) win.setBounds(prev); } }); ipcMain.on('widget:close', (e) => widgetOf(e)?.close()); ipcMain.on('widget:openStudy', () => openCompanion()); + // Border-aligned resize (grips sit on the visible card edges, not an invisible outer rim). + let resizeTick: ReturnType | null = null; + let resizeSession: { + win: BrowserWindow; + edge: WidgetResizeEdge; + startBounds: Electron.Rectangle; + startCursor: Electron.Point; + } | null = null; + const endWidgetResize = () => { + if (resizeTick) { + clearInterval(resizeTick); + resizeTick = null; + } + if (resizeSession && !resizeSession.win.isDestroyed()) { + settings().set({ lastWidgetBounds: resizeSession.win.getBounds() }); + } + resizeSession = null; + }; + ipcMain.on('widget:resizeStart', (e, edge: WidgetResizeEdge) => { + const win = widgetOf(e); + if (!win || win.isDestroyed()) return; + const ok: WidgetResizeEdge[] = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']; + if (!ok.includes(edge)) return; + endWidgetResize(); + resizeSession = { + win, + edge, + startBounds: win.getBounds(), + startCursor: screen.getCursorScreenPoint(), + }; + resizeTick = setInterval(() => { + if (!resizeSession || resizeSession.win.isDestroyed()) { + endWidgetResize(); + return; + } + const cur = screen.getCursorScreenPoint(); + const next = applyWidgetResize( + resizeSession.startBounds, + resizeSession.edge, + cur.x - resizeSession.startCursor.x, + cur.y - resizeSession.startCursor.y, + ); + resizeSession.win.setBounds(next, false); + }, 16); + }); + ipcMain.on('widget:resizeEnd', () => endWidgetResize()); + // --- app info + learning reads --- ipcMain.handle('app:info', async () => ({ version: app.getVersion(), @@ -260,6 +571,135 @@ app.whenReady().then(() => { })); ipcMain.handle('learning:profile', () => computeProfile(store().events(), todayKey())); ipcMain.handle('learning:feed', (_e, limit: number) => computeFeed(store().events(), limit ?? 8)); + ipcMain.handle('learning:history', (_e, limit: number) => computeLearningItems(store().events(), Math.max(1, Math.min(limit ?? 100, 250)))); + ipcMain.handle('learning:queue', (_e, limit: number) => computeReviewQueue(store().events(), new Date(), Math.max(1, Math.min(limit ?? 20, 50)))); + ipcMain.handle('learning:item', (_e, id: string) => { + const event = store().eventById(id); + if (!event) return null; + return computeLearningItems([event], 1)[0] ?? null; + }); + ipcMain.handle('study:askStatus', () => studyAskStatus()); + ipcMain.handle('study:ask', (_e, input: { eventId: string; question: string }) => askStudyAssistant(input)); + ipcMain.handle('quiz:status', () => quizCardStatus()); + ipcMain.handle('quiz:start', (_e, eventId: string) => startQuizCard(eventId)); + ipcMain.handle('quiz:answer', (_e, input: { eventId: string; choice: number }) => answerQuizCard(input.eventId, input.choice)); + + ipcMain.handle('project:pickRoot', async () => { + const picked = await dialog.showOpenDialog({ properties: ['openDirectory'] }); + if (picked.canceled || !picked.filePaths[0]) return { ok: false, cancelled: true }; + const root = await resolveRepoRoot(picked.filePaths[0]); + if (!root) return { ok: false, error: 'That folder is not a git repository.' }; + return { ok: true, root }; + }); + + ipcMain.handle('review:explainDiff', async (e, opts?: { brief?: boolean; level?: ExplanationLevel }) => { + const win = widgetOf(e) ?? getOrCreateWidget(); + showBar(bar); + const session = sessionFor() ?? makeSession(activeTabId, null, null, opts?.level ?? 'intermediate'); + tabSessions.set(activeTabId, session); + if (opts?.level) session.level = opts.level; + let root = await resolveRepoRoot(); + if (!root) { + const picked = await dialog.showOpenDialog({ properties: ['openDirectory'], title: 'Choose a git project' }); + if (picked.canceled || !picked.filePaths[0]) return { ok: false, cancelled: true }; + root = await resolveRepoRoot(picked.filePaths[0]); + } + if (!root) return { ok: false, error: 'Pick a git repository to explain changes.' }; + const brief = Boolean(opts?.brief); + const feature = brief ? 'Agent change briefs' : 'Git diff explanations'; + if (!(await requireProOrError(win, session, feature))) { + return { ok: false, error: 'Pro required' }; + } + return startBuiltReview( + win, + session, + () => buildDiffPayload({ repoRoot: root!, level: session.level, mode: brief ? 'brief' : 'diff' }), + brief ? 'Agent brief' : 'Git diff', + ); + }); + + ipcMain.handle('review:explainCompare', async (e, opts?: { level?: ExplanationLevel }) => { + const win = widgetOf(e) ?? getOrCreateWidget(); + showBar(bar); + const session = sessionFor() ?? makeSession(activeTabId, null, null, opts?.level ?? 'intermediate'); + tabSessions.set(activeTabId, session); + if (opts?.level) session.level = opts.level; + if (!(await requireProOrError(win, session, 'Since last understood'))) { + return { ok: false, error: 'Pro required' }; + } + const picked = await dialog.showOpenDialog({ + properties: ['openFile'], + title: 'Choose a file to compare with what you last understood', + }); + if (picked.canceled || !picked.filePaths[0]) return { ok: false, cancelled: true }; + return startBuiltReview( + win, + session, + () => buildComparePayload({ filePath: picked.filePaths[0]!, level: session.level }), + 'Compare', + ); + }); + + ipcMain.handle('review:reopenItem', async (_e, item: { id?: string; file?: string; project?: string; level?: string }) => { + const win = getOrCreateWidget(); + showBar(bar); + const level = (item.level as ExplanationLevel) || 'intermediate'; + const session = sessionFor() ?? makeSession(activeTabId, null, null, level); + session.level = level; + tabSessions.set(activeTabId, session); + const saved = item.id ? store().eventById(item.id) : undefined; + const savedCode = saved?.code?.trim(); + + const openFromCode = async (code: string, filePath?: string, repoRoot?: string | null) => { + if (code.length > MAX_PICK_BYTES) return { ok: false as const, error: 'That lesson is too large to reopen here.' }; + const usage = await resolveAppUsage(); + const built = await buildSelectionPayload({ + code, + level: session.level, + filePath, + repoRoot: repoRoot ?? undefined, + withNearby: isProPlan(usage.plan), + }); + applyBuiltReview(session, built); + session.sourceApp = 'Study'; + session.reviewId = randomUUID(); + initWidget(win, session, { autoStart: true }); + void runReview(win, session, { level: session.level }); + return { ok: true as const }; + }; + + if (!item.file && savedCode) { + return openFromCode(savedCode, saved?.file); + } + if (!item.file) { + beginCaptureOnActiveTab(win, null, null); + return { ok: true }; + } + let root = await resolveRepoRoot(); + if (item.project && (!root || path.basename(root) !== item.project)) { + const picked = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: `Locate project “${item.project}”`, + }); + if (picked.canceled || !picked.filePaths[0]) { + if (savedCode) return openFromCode(savedCode, item.file); + return { ok: false, cancelled: true }; + } + root = await resolveRepoRoot(picked.filePaths[0]); + if (!root || (item.project && path.basename(root) !== item.project)) { + if (savedCode) return openFromCode(savedCode, item.file); + return { ok: false, error: `That folder does not look like the “${item.project}” project.` }; + } + } + const abs = root ? path.join(root, item.file) : item.file; + try { + const text = await readFile(abs, 'utf8'); + return openFromCode(text, abs, root); + } catch { + if (savedCode) return openFromCode(savedCode, item.file, root); + return { ok: false, error: 'Could not reopen that file. Open Study again and locate the project folder when prompted.' }; + } + }); ipcMain.handle('sync:status', () => syncStatus()); ipcMain.handle('sync:retry', async () => { await retrySync(); @@ -285,7 +725,7 @@ app.whenReady().then(() => { // --- external links --- ipcMain.handle('app:openPrivacy', () => { - void shell.openExternal('https://unvibe.app/privacy'); + void shell.openExternal('https://unvibe.site/privacy'); return { ok: true }; }); @@ -303,6 +743,46 @@ app.whenReady().then(() => { // --- account --- ipcMain.handle('account:get', () => store().account()); + ipcMain.handle('billing:overview', async () => { + const token = store().token(); + if (!token) return { ok: false, error: 'Sign in to view your plan and cloud usage.' }; + try { return { ok: true, data: await billingOverview(token) }; } + catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'Could not load plan.' }; } + }); + ipcMain.handle('usage:get', async () => { + try { return { ok: true, data: await resolveAppUsage() }; } + catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'Could not load usage.' }; } + }); + ipcMain.handle('ai:keyStatus', () => ({ ok: true, data: aiKeyStatus() })); + ipcMain.handle('ai:setKey', (_e, key: string) => { + try { + const trimmed = String(key ?? ''); + writeAiKey(trimmed); + const guessed = guessProviderFromKey(trimmed); + if (guessed) settings().set({ aiProvider: guessed }); + return { ok: true, data: aiKeyStatus(), provider: settings().all().aiProvider }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Could not save the API key.' }; + } + }); + ipcMain.handle('ai:clearKey', () => { clearAiKey(); return { ok: true, data: aiKeyStatus() }; }); + ipcMain.handle('ai:models', () => ({ ok: true, data: listLocalAiProviders() })); + ipcMain.handle('ai:costOverview', (_e, provider?: LocalAiProviderId) => { + const id = normalizeLocalAiProvider(provider ?? settings().all().aiProvider); + return { ok: true, data: costOverview(id) }; + }); + ipcMain.handle('billing:checkout', async (_e, input: { plan: 'pro' | 'teams'; interval: 'monthly' | 'annual'; seats: number; workspaceId?: string; workspaceName?: string }) => { + const token = store().token(); + if (!token) return { ok: false, error: 'Sign in before starting checkout.' }; + try { const url = await startBillingCheckout(token, input); await shell.openExternal(url); return { ok: true }; } + catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'Checkout could not start.' }; } + }); + ipcMain.handle('billing:portal', async (_e, workspaceId: string) => { + const token = store().token(); + if (!token) return { ok: false, error: 'Sign in before managing billing.' }; + try { const url = await startBillingPortal(token, workspaceId); await shell.openExternal(url); return { ok: true }; } + catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'Billing could not open.' }; } + }); ipcMain.handle('account:signIn', async (_e, email: string) => { try { const acct = await signIn(email); @@ -326,7 +806,8 @@ app.whenReady().then(() => { ipcMain.handle('account:startDevice', async () => { try { const device = await startDeviceAuth(); - void shell.openExternal(`${device.verificationUri}?code=${encodeURIComponent(device.userCode)}`); + // Use user_code — never ?code= (that collides with Google/Supabase OAuth). + void shell.openExternal(`${device.verificationUri}?user_code=${encodeURIComponent(device.userCode)}`); if (devicePoll) clearInterval(devicePoll); const expires = Date.now() + 10 * 60_000; devicePoll = setInterval(() => void (async () => { @@ -380,19 +861,22 @@ app.whenReady().then(() => { error: `The remote account was deleted, but local cleanup failed. ${err instanceof Error ? err.message : ''}`.trim(), }; } - for (const win of BrowserWindow.getAllWindows()) { - if (sessions.has(win.webContents.id) && !win.isDestroyed()) win.close(); - } - sessions.clear(); + const panel = currentWidget(); + if (panel && !panel.isDestroyed()) panel.close(); + clearPanelSessions(); return { ok: true }; }); }); app.on('browser-window-focus', () => void flush()); +app.on('activate', () => { + // Dock / Cmd-Tab selection — bring the home window back. + openCompanion(); +}); app.on('will-quit', () => { stopSync(); globalShortcut.unregisterAll(); }); app.on('window-all-closed', () => { - /* keep the agent alive — this is a menu-bar utility */ + /* keep the agent alive — tray + dock stay until Quit */ }); diff --git a/app/src/main/notify.ts b/app/src/main/notify.ts index e4a1d0a..f7135ba 100644 --- a/app/src/main/notify.ts +++ b/app/src/main/notify.ts @@ -1,6 +1,7 @@ /** Floating-bar notifications with rate limiting + quiet-hours suppression. */ import type { BrowserWindow } from 'electron'; import { settings } from './settings'; +import { showBar } from './windows'; let bar: BrowserWindow | null = null; let lastAt = 0; @@ -16,5 +17,8 @@ export function notify(message: string): void { const now = Date.now(); if (now - lastAt < MIN_GAP_MS) return; // never spammy lastAt = now; - if (bar && !bar.isDestroyed()) bar.webContents.send('bar:notify', message); + if (bar && !bar.isDestroyed()) { + showBar(bar); + bar.webContents.send('bar:notify', message); + } } diff --git a/app/src/main/review.ts b/app/src/main/review.ts index d9bff4b..742611d 100644 --- a/app/src/main/review.ts +++ b/app/src/main/review.ts @@ -1,7 +1,6 @@ /** * Review pipeline (main process — the only place with network access). - * capture → local secret filter → (consent if suspects) → SSE stream → record locally → sync. - * Raw code never enters a renderer; widgets receive only events + metadata. + * capture → build context → local secret filter → (consent) → stream → record → sync. */ import type { BrowserWindow } from 'electron'; import { scanText, hasBlocking, type SecretFinding } from '../core/secretFilter'; @@ -12,26 +11,43 @@ import { localDayKey, type LocalEvent } from '../core/learning'; import { BACKEND, fetchQuestion } from './backend'; import { store } from './store'; import { flush } from './sync'; +import { resolveAppUsage } from './usage'; +import { settings } from './settings'; +import { readAiKey } from './aiKey'; +import { buildLocalSystemPrompt, buildLocalUserPrompt, estimateCost, streamLocalAi } from './localAi'; +import { buildSelectionPayload, isProPlan, type ReviewMode } from './contextBuilder'; export type WidgetEvent = - | { type: 'init'; hasCode: boolean; sourceApp?: string | null; lines?: number; language?: string } - | { type: 'status'; message: string } - | { type: 'consent'; findings: SecretFinding[] } - | { type: 'blocked'; findings: SecretFinding[] } - | { type: 'token'; text: string } - | { type: 'done'; model: string; mock: boolean } - | { type: 'error'; message: string } - | { type: 'cancelled' } - | { type: 'question'; question: string; options: string[]; conceptLabel: string } - | { type: 'graded'; correct: boolean; answerIndex: number; rationale: string }; + | { type: 'init'; tabId: string; hasCode: boolean; sourceApp?: string | null; lines?: number; language?: string; autoStart?: boolean; mode?: string } + | { type: 'status'; tabId: string; message: string } + | { type: 'consent'; tabId: string; findings: SecretFinding[] } + | { type: 'blocked'; tabId: string; findings: SecretFinding[] } + | { type: 'token'; tabId: string; text: string } + | { type: 'done'; tabId: string; model: string; mock: boolean } + | { type: 'error'; tabId: string; message: string; code?: string; upgradePath?: string } + | { type: 'cancelled'; tabId: string } + | { type: 'question'; tabId: string; question: string; options: string[]; conceptLabel: string } + | { type: 'graded'; tabId: string; correct: boolean; answerIndex: number; rationale: string } + | { type: 'usage'; tabId: string; used: number; limit: number; remaining: number; resetsAt: string; plan: string } + | { type: 'understood'; tabId: string }; export interface ReviewSession { reviewId: string; + tabId: string; code: string | null; sourceApp: string | null; abort: AbortController | null; recorded: boolean; level: ExplanationLevel; + /** Accumulated streamed explanation for local history (on-device only). */ + explanationText?: string; + payload?: ReviewRequestPayload; + file?: string; + project?: string; + sourceFilePath?: string; + /** Raw file text for memory baselines — never the display blob for diffs/compares. */ + snapshotText?: string; + mode?: ReviewMode; pendingAnswer?: { answerIndex: number; concept: string; conceptLabel: string; rationale: string }; onRecorded?: () => void; onUnderstood?: () => void; @@ -44,33 +60,137 @@ export interface RequestOpts { consented?: boolean; } -function send(win: BrowserWindow, ev: WidgetEvent): void { - if (!win.isDestroyed()) win.webContents.send('review:event', ev); +function send(win: BrowserWindow, session: ReviewSession, ev: object): void { + if (!win.isDestroyed()) { + win.webContents.send('review:event', { ...ev, tabId: session.tabId } as WidgetEvent); + } } -function payloadFor(code: string, level: ExplanationLevel, opts?: Partial): ReviewRequestPayload { - return { - scope: 'selection', - level, - variant: opts?.variant ?? 'default', - question: opts?.question, - context: { language: guessLanguage(code), projectStructure: [], imports: [], code }, - }; +async function ensurePayload(session: ReviewSession, opts: RequestOpts): Promise { + if (session.payload) { + return { + ...session.payload, + level: opts.level, + question: opts.question ?? session.payload.question, + variant: opts.variant ?? session.payload.variant ?? 'default', + }; + } + const code = session.code; + if (!code) throw new Error('No code captured.'); + const usage = await resolveAppUsage(); + const built = await buildSelectionPayload({ + code, + level: opts.level, + filePath: session.sourceFilePath, + question: opts.question, + variant: opts.variant, + withNearby: isProPlan(usage.plan), + }); + session.payload = built.payload; + session.file = built.file ?? session.file; + session.project = built.project ?? session.project; + session.sourceFilePath = built.sourceFilePath ?? session.sourceFilePath; + session.snapshotText = built.snapshotText ?? session.snapshotText; + session.mode = built.mode; + return built.payload; +} + +function scanPayload(payload: ReviewRequestPayload): SecretFinding[] { + const blocks: Array<{ label: string; text: string }> = []; + if (payload.context.code) blocks.push({ label: 'code', text: payload.context.code }); + if (payload.context.enclosing) blocks.push({ label: 'nearby', text: payload.context.enclosing }); + for (const line of payload.context.imports) blocks.push({ label: 'import', text: line }); + for (const h of payload.context.diffHunks ?? []) { + blocks.push({ label: h.file, text: h.lines.join('\n') }); + } + const findings: SecretFinding[] = []; + for (const b of blocks) findings.push(...scanText(b.text, b.label)); + return findings; +} + +export function applyBuiltReview(session: ReviewSession, built: { + payload: ReviewRequestPayload; + displayCode: string; + file?: string; + project?: string; + sourceFilePath?: string; + snapshotText?: string; + mode: ReviewMode; +}): void { + session.code = built.displayCode; + session.payload = built.payload; + session.file = built.file; + session.project = built.project; + session.sourceFilePath = built.sourceFilePath; + session.snapshotText = built.snapshotText; + session.mode = built.mode; + session.level = built.payload.level; + session.recorded = false; } -export function initWidget(win: BrowserWindow, session: ReviewSession): void { +function persistUnderstoodSnapshot(session: ReviewSession): void { + if (!session.file || !session.snapshotText) return; + if (session.mode === 'diff' || session.mode === 'brief') return; + store().saveFileSnapshot(session.file, session.project, session.snapshotText.slice(0, 12_000)); +} + +/** Mark the current review understood (Got it) and refresh the file memory baseline when safe. */ +export function markUnderstood(win: BrowserWindow, session: ReviewSession): void { + if (!session.recorded) recordReview(win, session); + try { + store().setOutcome(session.reviewId, 'understood'); + persistUnderstoodSnapshot(session); + } catch (error) { + send(win, session, { + type: 'error', + message: error instanceof Error ? error.message : 'Could not save that you understood this.', + }); + return; + } + session.onUnderstood?.(); + void flush(); + send(win, session, { type: 'understood' }); +} + +export function initWidget( + win: BrowserWindow, + session: ReviewSession, + opts?: { autoStart?: boolean }, +): void { const code = session.code; - send(win, { + send(win, session, { type: 'init', hasCode: code !== null, sourceApp: session.sourceApp, lines: code ? code.split('\n').length : 0, language: code ? guessLanguage(code) : undefined, + autoStart: Boolean(opts?.autoStart && code), + mode: session.mode, }); } +function appendExplanation(session: ReviewSession, text: string): void { + session.explanationText = (session.explanationText ?? '') + text; +} + function recordReview(win: BrowserWindow, session: ReviewSession): void { - if (session.recorded || !session.code) return; + if (!session.code) return; + const explanation = session.explanationText?.trim() || undefined; + if (session.recorded) { + try { + store().updateLesson(session.reviewId, { + code: session.code, + explanation, + level: session.level, + }); + } catch (error) { + send(win, session, { + type: 'error', + message: error instanceof Error ? error.message : 'The explanation could not be saved locally.', + }); + } + return; + } session.recorded = true; const now = new Date(); const ev: LocalEvent = { @@ -79,18 +199,25 @@ function recordReview(win: BrowserWindow, session: ReviewSession): void { eventType: 'explanation_completed', localDate: localDayKey(now), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', - scope: 'selection', + scope: session.payload?.scope ?? 'selection', level: session.level, outcome: 'reviewed', lines: session.code.split('\n').length, language: guessLanguage(session.code), sourceApp: session.sourceApp ?? undefined, + file: session.file, + project: session.project, + code: session.code.slice(0, 40_000), + explanation: explanation?.slice(0, 60_000), }; try { store().recordReview(ev); } catch (error) { session.recorded = false; - send(win, { type: 'error', message: error instanceof Error ? error.message : 'The explanation could not be saved locally.' }); + send(win, session, { + type: 'error', + message: error instanceof Error ? error.message : 'The explanation could not be saved locally.', + }); return; } session.onRecorded?.(); @@ -98,20 +225,39 @@ function recordReview(win: BrowserWindow, session: ReviewSession): void { } export async function runReview(win: BrowserWindow, session: ReviewSession, opts: RequestOpts): Promise { - const code = session.code; - if (!code) { - send(win, { type: 'error', message: 'No code captured.' }); + if (!session.code && !session.payload) { + send(win, session, { type: 'error', message: 'No code captured.' }); return; } session.level = opts.level; + session.explanationText = ''; + + const usageEarly = await resolveAppUsage(); + if (opts.level === 'expert' && !isProPlan(usageEarly.plan)) { + send(win, session, { + type: 'error', + code: 'pro_required', + upgradePath: '/plan', + message: 'Expert explanations are included with Unvibe Pro.', + }); + return; + } + + let payload: ReviewRequestPayload; + try { + payload = await ensurePayload(session, opts); + } catch (err) { + send(win, session, { type: 'error', message: err instanceof Error ? err.message : 'Could not build review context.' }); + return; + } - const findings = scanText(code, 'selection'); + const findings = scanPayload(payload); if (hasBlocking(findings)) { - send(win, { type: 'blocked', findings }); + send(win, session, { type: 'blocked', findings }); return; } if (findings.length > 0 && !opts.consented) { - send(win, { type: 'consent', findings }); + send(win, session, { type: 'consent', findings }); return; } @@ -119,26 +265,112 @@ export async function runReview(win: BrowserWindow, session: ReviewSession, opts const abort = new AbortController(); session.abort = abort; let timedOut = false; - const timeout = setTimeout(() => { timedOut = true; abort.abort(); }, 30_000); + const timeout = setTimeout(() => { timedOut = true; abort.abort(); }, 45_000); - send(win, { type: 'status', message: 'thinking' }); + send(win, session, { type: 'status', message: 'thinking' }); try { + const token = store().token(); + const usage = await resolveAppUsage(); + send(win, session, { + type: 'usage', + used: usage.used, + limit: usage.limit, + remaining: usage.remaining, + resetsAt: usage.resetsAt, + plan: usage.plan, + }); + + const prefs = settings().all(); + const localKey = readAiKey(); + const wantLocal = Boolean(localKey) && (prefs.useOwnAi || usage.remaining <= 0); + if (usage.remaining <= 0 && !localKey) { + const resets = new Date(usage.resetsAt).toLocaleDateString(undefined, { month: 'long', day: 'numeric' }); + const planName = usage.plan === 'pro' ? 'Pro' : usage.plan === 'teams' ? 'Teams' : 'Free'; + send(win, session, { + type: 'error', + code: 'plan_limit_reached', + upgradePath: '/plan', + message: `You have reached your monthly ${planName} explanation limit (${usage.limit}). Resets on ${resets}. Add your own API key in Settings → AI, or upgrade.`, + }); + return; + } + + if (wantLocal && localKey) { + const lines = (payload.context.code ?? '').split('\n').length; + const cost = estimateCost(prefs.aiProvider, opts.level, lines); + send(win, session, { type: 'status', message: `your AI · ${cost.label} · ${session.mode ?? 'selection'}` }); + try { + const model = await streamLocalAi({ + provider: prefs.aiProvider, + apiKey: localKey, + system: buildLocalSystemPrompt(payload), + user: buildLocalUserPrompt(payload), + onToken: (text) => { + appendExplanation(session, text); + send(win, session, { type: 'token', text }); + }, + signal: abort.signal, + }); + send(win, session, { type: 'done', model, mock: false }); + recordReview(win, session); + void resolveAppUsage().then((next) => { + send(win, session, { + type: 'usage', + used: next.used, + limit: next.limit, + remaining: next.remaining, + resetsAt: next.resetsAt, + plan: next.plan, + }); + }).catch(() => undefined); + } catch (err) { + if (abort.signal.aborted) { + if (timedOut) send(win, session, { type: 'error', message: 'The explanation took too long. Please try again.' }); + return; + } + send(win, session, { + type: 'error', + message: err instanceof Error ? err.message : 'Your local AI provider could not complete that request.', + }); + } + return; + } + const res = await fetch(`${BACKEND}/api/v1/reviews`, { method: 'POST', headers: { 'content-type': 'application/json', - ...(store().token() ? { authorization: `Bearer ${store().token()}` } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), }, - body: JSON.stringify(payloadFor(code, opts.level, opts)), + body: JSON.stringify(payload), signal: abort.signal, }); if (!res.ok || !res.body) { + const body = (await res.json().catch(() => ({}))) as { + error?: string; message?: string; upgradePath?: string; + usage?: { used: number; limit: number; remaining: number; resetsAt: string }; + }; + if (body.usage) { + send(win, session, { + type: 'usage', + used: body.usage.used, + limit: body.usage.limit, + remaining: body.usage.remaining, + resetsAt: body.usage.resetsAt, + plan: usage.plan, + }); + } const message = res.status === 401 ? 'Sign in to use cloud explanations, or continue with local learning history.' : res.status === 429 - ? 'Too many requests. Wait a moment, then try again.' + ? (body.message ?? 'You have reached your monthly explanation limit.') : `The explanation service could not complete that request (${res.status}).`; - send(win, { type: 'error', message }); + send(win, session, { + type: 'error', + message, + code: body.error === 'plan_limit_reached' ? 'plan_limit_reached' : undefined, + upgradePath: body.upgradePath, + }); return; } const reader = res.body.getReader(); @@ -148,16 +380,32 @@ export async function runReview(win: BrowserWindow, session: ReviewSession, opts const { done, value } = await reader.read(); if (done) break; for (const ev of parser.feed(decoder.decode(value, { stream: true }))) { - send(win, ev as WidgetEvent); - if (ev.type === 'done') recordReview(win, session); + if (ev.type === 'token' && typeof ev.text === 'string') appendExplanation(session, ev.text); + send(win, session, ev); + if (ev.type === 'done') { + recordReview(win, session); + void resolveAppUsage().then((next) => { + send(win, session, { + type: 'usage', + used: next.used, + limit: next.limit, + remaining: next.remaining, + resetsAt: next.resetsAt, + plan: next.plan, + }); + }).catch(() => undefined); + } } } } catch (err) { if (abort.signal.aborted) { - if (timedOut) send(win, { type: 'error', message: 'The explanation took too long. Please try again.' }); + if (timedOut) send(win, session, { type: 'error', message: 'The explanation took too long. Please try again.' }); return; } - send(win, { type: 'error', message: `Could not reach the Unvibe service at ${BACKEND}. Is it running?` }); + send(win, session, { + type: 'error', + message: `Could not reach the Unvibe service at ${BACKEND}. Is it running?`, + }); void err; } finally { clearTimeout(timeout); @@ -166,19 +414,24 @@ export async function runReview(win: BrowserWindow, session: ReviewSession, opts } export async function startComprehension(win: BrowserWindow, session: ReviewSession): Promise { - if (!session.code) return; + if (!session.code && !session.payload) return; try { - const q = await fetchQuestion(payloadFor(session.code, session.level), store().token()); + const payload = await ensurePayload(session, { level: session.level }); + const q = await fetchQuestion(payload, store().token()); session.pendingAnswer = { answerIndex: q.answerIndex, concept: q.concept, conceptLabel: q.conceptLabel, rationale: q.rationale, }; - // Send only what the renderer may see — never the answer. - send(win, { type: 'question', question: q.question, options: q.options, conceptLabel: q.conceptLabel }); + send(win, session, { + type: 'question', + question: q.question, + options: q.options, + conceptLabel: q.conceptLabel, + }); } catch { - send(win, { type: 'error', message: 'Could not build a question for this one. Try again.' }); + send(win, session, { type: 'error', message: 'Could not build a question for this one. Try again.' }); } } @@ -188,11 +441,20 @@ export function gradeComprehension(win: BrowserWindow, session: ReviewSession, c const correct = choice === a.answerIndex; try { store().setOutcome(session.reviewId, correct ? 'understood' : 'needs_review', a.concept, a.conceptLabel); + if (correct) persistUnderstoodSnapshot(session); } catch (error) { - send(win, { type: 'error', message: error instanceof Error ? error.message : 'The check result could not be saved locally.' }); + send(win, session, { + type: 'error', + message: error instanceof Error ? error.message : 'The check result could not be saved locally.', + }); return; } if (correct) session.onUnderstood?.(); void flush(); - send(win, { type: 'graded', correct, answerIndex: a.answerIndex, rationale: a.rationale }); + send(win, session, { + type: 'graded', + correct, + answerIndex: a.answerIndex, + rationale: a.rationale, + }); } diff --git a/app/src/main/selection.ts b/app/src/main/selection.ts index 6dad7c0..5c49d64 100644 --- a/app/src/main/selection.ts +++ b/app/src/main/selection.ts @@ -1,7 +1,7 @@ /** * macOS selection capture: save clipboard → activate the user's editor if needed → * synthesize ⌘C via System Events → read → restore clipboard. Requires Accessibility - * permission. Returns null when nothing was captured — callers offer clipboard fallback. + * permission. Falls back to the prior clipboard so ⌘U can still auto-explain. */ import { clipboard } from 'electron'; import { execFile } from 'node:child_process'; @@ -10,7 +10,7 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); function osascript(script: string): Promise { return new Promise((resolve, reject) => { - execFile('osascript', ['-e', script], { timeout: 4000 }, (err, stdout) => + execFile('osascript', ['-e', script], { timeout: 5000 }, (err, stdout) => err ? reject(err) : resolve(stdout.trim()), ); }); @@ -32,7 +32,7 @@ export function startFrontmostWatch(): void { void frontmostApp().then((name) => { if (name && !isSelfApp(name)) lastForeignApp = name; }); - }, 800); + }, 600); } export async function frontmostApp(): Promise { @@ -48,7 +48,12 @@ export async function frontmostApp(): Promise { async function activateApp(name: string): Promise { const escaped = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); await osascript(`tell application "${escaped}" to activate`); - await delay(100); + await delay(160); +} + +async function syntheticCopy(): Promise { + await osascript('tell application "System Events" to keystroke "c" using command down'); + await delay(420); } export async function captureSelection(): Promise { @@ -58,13 +63,21 @@ export async function captureSelection(): Promise { const front = await frontmostApp(); if (isSelfApp(front) && lastForeignApp) { await activateApp(lastForeignApp); + } else if (!isSelfApp(front) && front) { + lastForeignApp = front; } - await osascript('tell application "System Events" to keystroke "c" using command down'); - await delay(280); - const grabbed = clipboard.readText(); - return grabbed.length > 0 ? grabbed : null; + + await syntheticCopy(); + let grabbed = clipboard.readText(); + if (!grabbed) { + await syntheticCopy(); + grabbed = clipboard.readText(); + } + if (grabbed.length > 0) return grabbed; + // Prior clipboard fallback so ⌘U still starts an explanation when AX copy misses. + return previous.trim().length > 0 ? previous : null; } catch { - return null; + return previous.trim().length > 0 ? previous : null; } finally { clipboard.writeText(previous); } diff --git a/app/src/main/settings.ts b/app/src/main/settings.ts index 9a43721..f582926 100644 --- a/app/src/main/settings.ts +++ b/app/src/main/settings.ts @@ -5,12 +5,18 @@ import { app } from 'electron'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import path from 'node:path'; +import { DEFAULT_LOCAL_AI_PROVIDER, normalizeLocalAiProvider, type LocalAiProviderId } from './localAi'; export type BarPosition = 'top-center' | 'bottom-center' | 'top-right' | 'bottom-right'; export type InactiveBehavior = 'dim' | 'stay' | 'collapse'; export type ThemePreference = 'system' | 'light' | 'dark'; +/** Bump when a release should re-show onboarding for existing installs. */ +const SETTINGS_REVISION = 3; + export interface Settings { + /** Internal — when lower than SETTINGS_REVISION, onboarded is reset once. */ + settingsRevision?: number; onboarded: boolean; /** Electron accelerator. Default is ⌘U. */ shortcut: string; @@ -23,9 +29,18 @@ export interface Settings { notifications: boolean; quietHours: { enabled: boolean; start: string; end: string }; // "HH:MM" lastWidgetBounds?: { x: number; y: number; width: number; height: number }; + /** Prefer the user's own local API key instead of Unvibe cloud AI. */ + useOwnAi: boolean; + /** Provider for local BYOK calls (cheap default model per provider). */ + aiProvider: LocalAiProviderId; + /** @deprecated Legacy field — migrated into aiProvider. */ + aiModel?: string; + /** Last folder used for git-diff / nearby-file Pro features. */ + lastProjectRoot?: string; } const DEFAULTS: Settings = { + settingsRevision: SETTINGS_REVISION, onboarded: false, shortcut: 'CommandOrControl+U', barPosition: 'bottom-center', @@ -35,11 +50,15 @@ const DEFAULTS: Settings = { theme: 'system', notifications: true, quietHours: { enabled: false, start: '22:00', end: '08:00' }, + useOwnAi: false, + aiProvider: DEFAULT_LOCAL_AI_PROVIDER, }; class SettingsStore { private data: Settings; private file: string; + /** True once after a SETTINGS_REVISION bump — caller should wipe local learning for a clean start. */ + private freshStart = false; constructor() { this.file = path.join(app.getPath('userData'), 'unvibe-settings.json'); @@ -49,13 +68,41 @@ class SettingsStore { } catch { /* first run */ } - this.data = { ...DEFAULTS, ...loaded, quietHours: { ...DEFAULTS.quietHours, ...loaded.quietHours } }; + const needsOnboardingReset = (loaded.settingsRevision ?? 0) < SETTINGS_REVISION; + this.freshStart = needsOnboardingReset; + const aiProvider = normalizeLocalAiProvider( + loaded.aiProvider ?? loaded.aiModel ?? DEFAULT_LOCAL_AI_PROVIDER, + ); + this.data = { + ...DEFAULTS, + ...loaded, + quietHours: { ...DEFAULTS.quietHours, ...loaded.quietHours }, + aiProvider, + settingsRevision: SETTINGS_REVISION, + ...(needsOnboardingReset + ? { + onboarded: false, + // Fresh panel size for the new onboarding cohort. + lastWidgetBounds: undefined, + } + : {}), + }; + if (needsOnboardingReset) delete this.data.lastWidgetBounds; + delete this.data.aiModel; + if (needsOnboardingReset || loaded.aiProvider !== aiProvider || loaded.aiModel) this.persist(); } all(): Settings { return this.data; } + /** Consume the one-shot flag set when this install was upgraded past SETTINGS_REVISION. */ + takeFreshStart(): boolean { + const v = this.freshStart; + this.freshStart = false; + return v; + } + private persist(): void { try { mkdirSync(path.dirname(this.file), { recursive: true }); @@ -66,7 +113,18 @@ class SettingsStore { } set(patch: Partial): Settings { - this.data = { ...this.data, ...patch, quietHours: { ...this.data.quietHours, ...patch.quietHours } }; + const nextProvider = patch.aiProvider !== undefined + ? normalizeLocalAiProvider(patch.aiProvider) + : patch.aiModel !== undefined + ? normalizeLocalAiProvider(patch.aiModel) + : undefined; + this.data = { + ...this.data, + ...patch, + quietHours: { ...this.data.quietHours, ...patch.quietHours }, + ...(nextProvider ? { aiProvider: nextProvider } : {}), + }; + delete this.data.aiModel; this.persist(); if (patch.launchAtLogin !== undefined && process.platform === 'darwin') { app.setLoginItemSettings({ openAtLogin: this.data.launchAtLogin }); diff --git a/app/src/main/store.ts b/app/src/main/store.ts index 3bedd2a..49ba948 100644 --- a/app/src/main/store.ts +++ b/app/src/main/store.ts @@ -17,13 +17,27 @@ interface AccountData { tokenEncrypted: boolean; } +interface FileSnapshot { + file: string; + project?: string; + text: string; + savedAt: string; +} + interface Data { events: LocalEvent[]; outbox: string[]; // event ids awaiting sync account?: AccountData; syncOwnerId?: string; // keeps a signed-out outbox bound to the account that created it + /** Local-only code snapshots for Pro "since last understood" — never synced. */ + snapshots?: FileSnapshot[]; + /** Daily counters for companion Study assistant / Quiz cards (local only). */ + dailyUsage?: { day: string; studyAsks: number; quizzes: number }; } +export const STUDY_ASK_DAILY_LIMIT = 20; +export const QUIZ_DAILY_LIMIT = 30; + class Store { private data: Data = { events: [], outbox: [] }; private file: string; @@ -40,6 +54,7 @@ class Store { this.data = loaded; this.data.events ??= []; this.data.outbox ??= []; + this.data.snapshots ??= []; if (this.data.account && !this.data.syncOwnerId) this.data.syncOwnerId = this.data.account.userId; // Versions before 0.1.0 could persist a reversible base64 token when the keychain was // unavailable. Fail closed and require sign-in again instead of retaining that token. @@ -79,6 +94,59 @@ class Store { this.save(); } + /** Update on-device lesson body without forcing a cloud re-upload of code. */ + updateLesson(id: string, patch: { code?: string; explanation?: string; level?: string }): void { + const ev = this.data.events.find((e) => e.id === id); + if (!ev) return; + if (patch.code !== undefined) ev.code = patch.code.slice(0, 40_000); + if (patch.explanation !== undefined) ev.explanation = patch.explanation.slice(0, 60_000); + if (patch.level) ev.level = patch.level; + this.save(); + } + + eventById(id: string): LocalEvent | undefined { + return this.data.events.find((e) => e.id === id); + } + + private todayUsage(): { day: string; studyAsks: number; quizzes: number } { + const day = new Date().toLocaleDateString('en-CA'); + const current = this.data.dailyUsage; + if (!current || current.day !== day) { + this.data.dailyUsage = { day, studyAsks: 0, quizzes: 0 }; + } + return this.data.dailyUsage!; + } + + studyAskUsage(): { used: number; limit: number; remaining: number } { + const u = this.todayUsage(); + return { used: u.studyAsks, limit: STUDY_ASK_DAILY_LIMIT, remaining: Math.max(0, STUDY_ASK_DAILY_LIMIT - u.studyAsks) }; + } + + quizUsage(): { used: number; limit: number; remaining: number } { + const u = this.todayUsage(); + return { used: u.quizzes, limit: QUIZ_DAILY_LIMIT, remaining: Math.max(0, QUIZ_DAILY_LIMIT - u.quizzes) }; + } + + consumeStudyAsk(): { ok: true; remaining: number } | { ok: false; remaining: number; error: string } { + const u = this.todayUsage(); + if (u.studyAsks >= STUDY_ASK_DAILY_LIMIT) { + return { ok: false, remaining: 0, error: `Daily study assistant limit reached (${STUDY_ASK_DAILY_LIMIT}). Resets tomorrow.` }; + } + u.studyAsks += 1; + this.save(); + return { ok: true, remaining: STUDY_ASK_DAILY_LIMIT - u.studyAsks }; + } + + consumeQuiz(): { ok: true; remaining: number } | { ok: false; remaining: number; error: string } { + const u = this.todayUsage(); + if (u.quizzes >= QUIZ_DAILY_LIMIT) { + return { ok: false, remaining: 0, error: `Daily quiz limit reached (${QUIZ_DAILY_LIMIT}). Resets tomorrow.` }; + } + u.quizzes += 1; + this.save(); + return { ok: true, remaining: QUIZ_DAILY_LIMIT - u.quizzes }; + } + private queue(id: string): void { if (!this.data.outbox.includes(id)) this.data.outbox.push(id); } @@ -146,7 +214,22 @@ class Store { } wipeEverything(): void { - this.data = { events: [], outbox: [] }; + this.data = { events: [], outbox: [], snapshots: [] }; + this.save(); + } + + fileSnapshot(file: string, project?: string): FileSnapshot | undefined { + return (this.data.snapshots ?? []).find((s) => s.file === file && s.project === project); + } + + saveFileSnapshot(file: string, project: string | undefined, text: string): void { + const snapshots = this.data.snapshots ?? []; + const next: FileSnapshot = { file, project, text, savedAt: new Date().toISOString() }; + const idx = snapshots.findIndex((s) => s.file === file && s.project === project); + if (idx >= 0) snapshots[idx] = next; + else snapshots.push(next); + // Cap local snapshot cache. + this.data.snapshots = snapshots.slice(-40); this.save(); } } diff --git a/app/src/main/studyQuiz.ts b/app/src/main/studyQuiz.ts new file mode 100644 index 0000000..fa195f5 --- /dev/null +++ b/app/src/main/studyQuiz.ts @@ -0,0 +1,227 @@ +/** + * Companion Study assistant + Quiz cards (main process). + * Network stays here; code/explanations used as context never sync as lesson bodies. + */ +import { SseParser } from '../core/sse'; +import { guessLanguage } from '../core/language'; +import type { ExplanationLevel, ReviewRequestPayload } from '../core/protocol'; +import type { LocalEvent } from '../core/learning'; +import { BACKEND, fetchQuestion } from './backend'; +import { store } from './store'; +import { flush } from './sync'; +import { readAiKey } from './aiKey'; +import { settings } from './settings'; +import { buildLocalSystemPrompt, buildLocalUserPrompt, streamLocalAi } from './localAi'; +import { resolveAppUsage } from './usage'; + +interface PendingQuiz { + eventId: string; + answerIndex: number; + concept: string; + conceptLabel: string; + rationale: string; +} + +const pendingQuizzes = new Map(); + +function lessonPayload( + eventId: string, + level?: ExplanationLevel, +): { payload: ReviewRequestPayload; event: LocalEvent } | { error: string } { + const event = store().eventById(eventId); + if (!event) return { error: 'That lesson is no longer on this Mac.' }; + if (!event.code?.trim()) return { error: 'This lesson has no saved code yet. Open a fresh explanation first.' }; + const payload: ReviewRequestPayload = { + scope: (event.scope as ReviewRequestPayload['scope']) || 'selection', + level: level ?? (event.level as ExplanationLevel) ?? 'intermediate', + context: { + language: event.language ?? guessLanguage(event.code), + primaryFile: event.file, + projectStructure: event.project ? [event.project] : [], + imports: [], + code: event.code.slice(0, 12_000), + }, + }; + return { payload, event }; +} + +async function collectReviewText(payload: ReviewRequestPayload): Promise<{ text: string; model: string; mock: boolean }> { + const token = store().token(); + const prefs = settings().all(); + const localKey = readAiKey(); + const usage = await resolveAppUsage(); + const wantLocal = Boolean(localKey) && (prefs.useOwnAi || usage.remaining <= 0); + + if (wantLocal && localKey) { + let text = ''; + const model = await streamLocalAi({ + provider: prefs.aiProvider, + apiKey: localKey, + system: buildLocalSystemPrompt(payload), + user: buildLocalUserPrompt(payload), + onToken: (chunk) => { text += chunk; }, + }); + return { text: text.trim(), model, mock: false }; + } + + if (usage.remaining <= 0 && !localKey) { + throw new Error('Monthly explanation limit reached. Add your own API key in Settings → AI, or upgrade.'); + } + + const abort = new AbortController(); + const timeout = setTimeout(() => abort.abort(), 45_000); + try { + const res = await fetch(`${BACKEND}/api/v1/reviews`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(payload), + signal: abort.signal, + }); + if (!res.ok || !res.body) { + const body = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(body.message ?? `Could not reach the explanation service (${res.status}).`); + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + let text = ''; + let model = 'unvibe'; + let mock = false; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + for (const ev of parser.feed(decoder.decode(value, { stream: true }))) { + if (ev.type === 'token' && typeof ev.text === 'string') text += ev.text; + if (ev.type === 'done') { + model = ev.model ?? model; + mock = Boolean(ev.mock); + } + if (ev.type === 'error') throw new Error(ev.message || 'The assistant could not answer.'); + } + } + return { text: text.trim(), model, mock }; + } finally { + clearTimeout(timeout); + } +} + +export function studyAskStatus(): { used: number; limit: number; remaining: number } { + return store().studyAskUsage(); +} + +export function quizCardStatus(): { used: number; limit: number; remaining: number } { + return store().quizUsage(); +} + +export async function askStudyAssistant(input: { + eventId: string; + question: string; +}): Promise<{ ok: true; answer: string; remaining: number } | { ok: false; error: string; remaining?: number }> { + const question = input.question?.trim(); + if (!question) return { ok: false, error: 'Ask a short question about this lesson.' }; + if (question.length > 800) return { ok: false, error: 'Keep questions under 800 characters.' }; + + const built = lessonPayload(input.eventId); + if ('error' in built) return { ok: false, error: built.error }; + + const status = store().studyAskUsage(); + if (status.remaining <= 0) { + return { ok: false, error: `Daily study assistant limit reached (${status.limit}). Resets tomorrow.`, remaining: 0 }; + } + + const prior = built.event.explanation?.slice(0, 4_000); + const payload: ReviewRequestPayload = { + ...built.payload, + question: prior + ? `The learner already saw this explanation:\n\n${prior}\n\nTheir follow-up question:\n${question}` + : question, + }; + + try { + const result = await collectReviewText(payload); + if (!result.text) return { ok: false, error: 'The assistant returned an empty answer. Try again.', remaining: status.remaining }; + const quota = store().consumeStudyAsk(); + if (!quota.ok) return { ok: false, error: quota.error, remaining: quota.remaining }; + return { ok: true, answer: result.text, remaining: quota.remaining }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'The study assistant could not answer.', + remaining: status.remaining, + }; + } +} + +export async function startQuizCard(eventId: string): Promise< + | { ok: true; question: string; options: string[]; conceptLabel: string; remaining: number } + | { ok: false; error: string; remaining?: number } +> { + const built = lessonPayload(eventId); + if ('error' in built) return { ok: false, error: built.error }; + + const status = store().quizUsage(); + if (status.remaining <= 0) { + return { ok: false, error: `Daily quiz limit reached (${status.limit}). Resets tomorrow.`, remaining: 0 }; + } + + try { + const q = await fetchQuestion(built.payload, store().token()); + const quota = store().consumeQuiz(); + if (!quota.ok) return { ok: false, error: quota.error, remaining: quota.remaining }; + pendingQuizzes.set(eventId, { + eventId, + answerIndex: q.answerIndex, + concept: q.concept, + conceptLabel: q.conceptLabel, + rationale: q.rationale, + }); + return { + ok: true, + question: q.question, + options: q.options, + conceptLabel: q.conceptLabel, + remaining: quota.remaining, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'Could not build a quiz for this lesson.', + remaining: status.remaining, + }; + } +} + +export function answerQuizCard(eventId: string, choice: number): + | { ok: true; correct: true; answerIndex: number; rationale: string } + | { ok: true; correct: false; rationale: string } + | { ok: false; error: string } { + const pending = pendingQuizzes.get(eventId); + if (!pending) return { ok: false, error: 'Start a quiz card first.' }; + if (!Number.isInteger(choice) || choice < 0) return { ok: false, error: 'Pick one of the options.' }; + + const correct = choice === pending.answerIndex; + // Soft mode: wrong answers stay open so the learner can try again. + if (!correct) { + return { + ok: true, + correct: false, + rationale: 'Sorry — wrong. Try another option; the card stays open.', + }; + } + try { + store().setOutcome(eventId, 'understood', pending.concept, pending.conceptLabel); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Could not save the quiz result.' }; + } + pendingQuizzes.delete(eventId); + void flush(); + return { + ok: true, + correct: true, + answerIndex: pending.answerIndex, + rationale: pending.rationale, + }; +} diff --git a/app/src/main/usage.ts b/app/src/main/usage.ts new file mode 100644 index 0000000..e6cc55f --- /dev/null +++ b/app/src/main/usage.ts @@ -0,0 +1,65 @@ +/** + * Explanation quota for the desktop app. + * Signed-in: prefer server billing overview. + * Local / unsigned: Free allotment (50/month) counted from local review events. + */ +import { billingOverview, type BillingUsageLine } from './backend'; +import { store } from './store'; + +export const LOCAL_FREE_LIMIT = 50; + +export interface AppUsage { + used: number; + limit: number; + remaining: number; + resetsAt: string; + plan: 'free' | 'pro' | 'teams' | 'local'; + source: 'cloud' | 'local'; +} + +function monthWindow(now = new Date()): { startsAt: string; resetsAt: string; prefix: string } { + const starts = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const resets = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); + const prefix = starts.toISOString().slice(0, 7); // YYYY-MM + return { startsAt: starts.toISOString(), resetsAt: resets.toISOString(), prefix }; +} + +export function localExplanationUsage(now = new Date()): AppUsage { + const { resetsAt, prefix } = monthWindow(now); + const used = store().events().filter((ev) => { + if (ev.eventType && ev.eventType !== 'explanation_completed') return false; + return ev.ts.slice(0, 7) === prefix; + }).length; + const limit = LOCAL_FREE_LIMIT; + return { + used, + limit, + remaining: Math.max(0, limit - used), + resetsAt, + plan: 'local', + source: 'local', + }; +} + +export async function resolveAppUsage(): Promise { + const token = store().token(); + if (token) { + try { + const { overview } = await billingOverview(token); + const line = overview.usage.find((item: BillingUsageLine) => item.kind === 'ai_explanation'); + if (line) { + return { + used: line.used, + limit: line.limit, + remaining: line.remaining, + resetsAt: line.resetsAt, + plan: overview.subscription.plan, + source: 'cloud', + }; + } + } catch { + /* fall through to local */ + } + } + return localExplanationUsage(); +} diff --git a/app/src/main/windows.ts b/app/src/main/windows.ts index 7a0634e..b9d763d 100644 --- a/app/src/main/windows.ts +++ b/app/src/main/windows.ts @@ -6,7 +6,11 @@ const preload = () => path.join(__dirname, '../preload/preload.cjs'); const page = (name: string) => path.join(__dirname, `../renderer/${name}/${name}.html`); const SNAP = 18; -let widgetCount = 0; +/** Compact AI Container — small enough not to intimidate on first open. */ +const DEFAULT_WIDGET_W = 300; +const DEFAULT_WIDGET_H = 360; +/** One shared review panel — ⌘U reuses this instead of stacking windows. */ +let panelWin: BrowserWindow | null = null; const secureWebPrefs = () => ({ preload: preload(), @@ -18,7 +22,7 @@ const secureWebPrefs = () => ({ /** Renderers are local files; deny any attempt to open new windows or navigate away. */ function lockNavigation(win: BrowserWindow): void { win.webContents.setWindowOpenHandler(({ url }) => { - if (/^https?:/.test(url)) void shell.openExternal(url); + if (/^https?:/.test(url) || /^mailto:/i.test(url)) void shell.openExternal(url); return { action: 'deny' }; }); win.webContents.on('will-navigate', (e) => e.preventDefault()); @@ -42,13 +46,15 @@ function barBounds(position: BarPosition, w: number, h: number): { x: number; y: } } +/** Compact landscape aisle: play · logo · home. */ +const BAR_W = 168; +const BAR_H = 44; + export function createBar(): BrowserWindow { - const width = 248; - const height = 44; - const { x, y } = barBounds(settings().all().barPosition, width, height); + const { x, y } = barBounds(settings().all().barPosition, BAR_W, BAR_H); const win = new BrowserWindow({ - width, - height, + width: BAR_W, + height: BAR_H, x, y, frame: false, @@ -59,6 +65,7 @@ export function createBar(): BrowserWindow { skipTaskbar: true, hasShadow: false, alwaysOnTop: true, + show: false, webPreferences: secureWebPrefs(), }); win.setAlwaysOnTop(true, 'screen-saver'); @@ -75,6 +82,18 @@ export function positionBar(win: BrowserWindow): void { win.setBounds({ ...b, x, y }); } +/** Quiet aisle — only show while a review is active. */ +export function showBar(win: BrowserWindow | null): void { + if (!win || win.isDestroyed()) return; + positionBar(win); + if (!win.isVisible()) win.showInactive(); +} + +export function hideBar(win: BrowserWindow | null): void { + if (!win || win.isDestroyed()) return; + win.hide(); +} + function snap(win: BrowserWindow): void { const b = win.getBounds(); const { workArea: wa } = screen.getDisplayNearestPoint({ x: b.x, y: b.y }); @@ -103,39 +122,71 @@ function clampToVisibleArea(bounds: Electron.Rectangle): Electron.Rectangle { }; } -export function createWidget(): BrowserWindow { - const s = settings().all(); +function defaultWidgetBounds(): Electron.Rectangle { const cursor = screen.getCursorScreenPoint(); const { workArea } = screen.getDisplayNearestPoint(cursor); - const w = 440; - const h = 560; - - // Restore the last-used size/position; else place near the cursor. - const saved = s.lastWidgetBounds; - const initialBounds = saved - ? { ...saved } - : { - width: w, - height: h, - x: Math.min(Math.max(cursor.x + 24, workArea.x), workArea.x + workArea.width - w - 12), - y: Math.min(Math.max(cursor.y - 40, workArea.y), workArea.y + workArea.height - h - 12), - }; - // Nudge so a second widget doesn't perfectly overlap the first. - const stagger = widgetCount++ * 26; - const bounds = clampToVisibleArea({ - ...initialBounds, - x: initialBounds.x + (saved ? stagger : 0), - y: initialBounds.y + (saved ? stagger : 0), - }); + const w = DEFAULT_WIDGET_W; + const h = DEFAULT_WIDGET_H; + return { + width: w, + height: h, + x: Math.min(Math.max(cursor.x + 24, workArea.x), workArea.x + workArea.width - w - 12), + y: Math.min(Math.max(cursor.y - 40, workArea.y), workArea.y + workArea.height - h - 12), + }; +} + +/** Restore user size/position, but migrate the old stock 440×560 default to the shorter panel. */ +function resolveWidgetBounds(): Electron.Rectangle { + const saved = settings().all().lastWidgetBounds; + if (!saved) return defaultWidgetBounds(); + const stockOld = + (saved.width === 440 && saved.height === 560) || + (saved.width === 360 && saved.height === 480) || + (saved.width === 340 && saved.height === 440); + if (stockOld) { + return { ...saved, width: DEFAULT_WIDGET_W, height: DEFAULT_WIDGET_H }; + } + return { ...saved }; +} +/** Edges for border-aligned custom resize (OS chrome resize is disabled — too far from the visible card). */ +export type WidgetResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; + +const WIDGET_MIN_W = 280; +const WIDGET_MIN_H = 240; + +export function applyWidgetResize( + start: Electron.Rectangle, + edge: WidgetResizeEdge, + dx: number, + dy: number, +): Electron.Rectangle { + let { x, y, width, height } = start; + if (edge.includes('e')) width = Math.max(WIDGET_MIN_W, start.width + dx); + if (edge.includes('s')) height = Math.max(WIDGET_MIN_H, start.height + dy); + if (edge.includes('w')) { + width = Math.max(WIDGET_MIN_W, start.width - dx); + x = start.x + (start.width - width); + } + if (edge.includes('n')) { + height = Math.max(WIDGET_MIN_H, start.height - dy); + y = start.y + (start.height - height); + } + return { x, y, width, height }; +} + +function buildWidgetWindow(bounds: Electron.Rectangle): BrowserWindow { const win = new BrowserWindow({ - ...bounds, - minWidth: 340, - minHeight: 200, + ...clampToVisibleArea(bounds), + minWidth: WIDGET_MIN_W, + minHeight: WIDGET_MIN_H, frame: false, transparent: true, - resizable: true, + // System resize hits a thick invisible rim outside the card. Resize is handled + // from the visible border grips in the renderer instead. + resizable: false, hasShadow: false, + roundedCorners: false, skipTaskbar: true, alwaysOnTop: true, show: false, @@ -157,13 +208,14 @@ export function createWidget(): BrowserWindow { if (win.isDestroyed()) return; const behavior = settings().all().inactiveBehavior; if (behavior === 'dim') win.setOpacity(settings().all().widgetOpacityInactive); - // 'stay' leaves it fully opaque; 'collapse' is handled in the renderer via IPC. if (behavior === 'collapse') win.webContents.send('widget:autocollapse', true); }); win.on('focus', () => { if (win.isDestroyed()) return; win.setOpacity(1); - if (settings().all().inactiveBehavior === 'collapse') win.webContents.send('widget:autocollapse', false); + if (settings().all().inactiveBehavior === 'collapse') { + win.webContents.send('widget:autocollapse', false); + } }); lockNavigation(win); @@ -172,6 +224,30 @@ export function createWidget(): BrowserWindow { return win; } +/** Prefer the existing review panel; create only when needed. */ +export function getOrCreateWidget(): BrowserWindow { + if (panelWin && !panelWin.isDestroyed()) { + if (panelWin.isMinimized()) panelWin.restore(); + panelWin.showInactive(); + panelWin.focus(); + return panelWin; + } + panelWin = buildWidgetWindow(resolveWidgetBounds()); + panelWin.on('closed', () => { + panelWin = null; + }); + return panelWin; +} + +/** @deprecated use getOrCreateWidget — kept for any stray callers */ +export function createWidget(): BrowserWindow { + return getOrCreateWidget(); +} + +export function currentWidget(): BrowserWindow | null { + return panelWin && !panelWin.isDestroyed() ? panelWin : null; +} + export function createCompanion(): BrowserWindow { const win = new BrowserWindow({ width: 1180, @@ -179,7 +255,8 @@ export function createCompanion(): BrowserWindow { minWidth: 980, minHeight: 620, titleBarStyle: 'hiddenInset', - backgroundColor: '#f4f1ec', + backgroundColor: '#faf7f0', + skipTaskbar: false, webPreferences: secureWebPrefs(), }); lockNavigation(win); diff --git a/app/src/preload/preload.ts b/app/src/preload/preload.ts index 8aab028..a909bd5 100644 --- a/app/src/preload/preload.ts +++ b/app/src/preload/preload.ts @@ -9,23 +9,45 @@ const api = { appInfo: (): Promise<{ version: string; user: string; shortcut: string }> => ipcRenderer.invoke('app:info'), onBarNotify: (cb: (msg: string) => void) => ipcRenderer.on('bar:notify', (_e, m) => cb(m)), - // widget review + // widget review (single panel + tabs) widgetReady: () => ipcRenderer.send('widget:ready'), + setActiveTab: (tabId: string) => ipcRenderer.send('widget:setActiveTab', tabId), + addTab: (tabId: string) => ipcRenderer.send('widget:addTab', tabId), + closeTab: (tabId: string) => ipcRenderer.send('widget:closeTab', tabId), request: (opts: unknown) => ipcRenderer.send('widget:request', opts), cancel: () => ipcRenderer.send('widget:cancel'), useClipboard: (opts: unknown) => ipcRenderer.send('widget:useClipboard', opts), + pickFile: (opts?: unknown): Promise<{ ok: boolean; cancelled?: boolean; error?: string }> => + ipcRenderer.invoke('widget:pickFile', opts), + usePaste: (opts: unknown) => ipcRenderer.send('widget:usePaste', opts), + explainDiff: (opts?: unknown) => ipcRenderer.invoke('review:explainDiff', opts), + explainCompare: (opts?: unknown) => ipcRenderer.invoke('review:explainCompare', opts), + pickProjectRoot: () => ipcRenderer.invoke('project:pickRoot'), + reviewQueue: (limit: number) => ipcRenderer.invoke('learning:queue', limit), + reopenLearningItem: (item: unknown) => ipcRenderer.invoke('review:reopenItem', item), testMe: () => ipcRenderer.send('widget:testMe'), answer: (choice: number) => ipcRenderer.send('widget:answer', choice), + gotIt: () => ipcRenderer.send('widget:gotIt'), pin: (pinned: boolean) => ipcRenderer.send('widget:pin', pinned), collapse: (collapsed: boolean) => ipcRenderer.send('widget:collapse', collapsed), closeWidget: () => ipcRenderer.send('widget:close'), openStudy: () => ipcRenderer.send('widget:openStudy'), + widgetResizeStart: (edge: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => + ipcRenderer.send('widget:resizeStart', edge), + widgetResizeEnd: () => ipcRenderer.send('widget:resizeEnd'), onReviewEvent: (cb: (ev: unknown) => void) => ipcRenderer.on('review:event', (_e, ev) => cb(ev)), onAutocollapse: (cb: (v: boolean) => void) => ipcRenderer.on('widget:autocollapse', (_e, v) => cb(v)), // learning (companion) profile: () => ipcRenderer.invoke('learning:profile'), feed: (limit: number) => ipcRenderer.invoke('learning:feed', limit), + history: (limit: number) => ipcRenderer.invoke('learning:history', limit), + learningItem: (id: string) => ipcRenderer.invoke('learning:item', id), + studyAskStatus: () => ipcRenderer.invoke('study:askStatus'), + studyAsk: (input: { eventId: string; question: string }) => ipcRenderer.invoke('study:ask', input), + quizStatus: () => ipcRenderer.invoke('quiz:status'), + quizStart: (eventId: string) => ipcRenderer.invoke('quiz:start', eventId), + quizAnswer: (input: { eventId: string; choice: number }) => ipcRenderer.invoke('quiz:answer', input), syncStatus: () => ipcRenderer.invoke('sync:status'), retrySync: () => ipcRenderer.invoke('sync:retry'), onSyncStatus: (cb: (status: unknown) => void) => ipcRenderer.on('sync:status', (_e, status) => cb(status)), @@ -54,6 +76,17 @@ const api = { onDeviceAuth: (cb: (result: { ok: boolean; email?: string; error?: string }) => void) => ipcRenderer.on('account:device', (_e, r) => cb(r)), signOut: () => ipcRenderer.invoke('account:signOut'), deleteAccount: () => ipcRenderer.invoke('account:delete'), + + // plans + usage (network stays in the main process) + billingOverview: () => ipcRenderer.invoke('billing:overview'), + usageGet: () => ipcRenderer.invoke('usage:get'), + aiKeyStatus: () => ipcRenderer.invoke('ai:keyStatus'), + aiSetKey: (key: string) => ipcRenderer.invoke('ai:setKey', key), + aiClearKey: () => ipcRenderer.invoke('ai:clearKey'), + aiModels: () => ipcRenderer.invoke('ai:models'), + aiCostOverview: (model?: string) => ipcRenderer.invoke('ai:costOverview', model), + startBillingCheckout: (input: unknown) => ipcRenderer.invoke('billing:checkout', input), + openBillingPortal: (workspaceId: string) => ipcRenderer.invoke('billing:portal', workspaceId), }; contextBridge.exposeInMainWorld('unvibe', api); diff --git a/app/src/renderer/bar/bar.css b/app/src/renderer/bar/bar.css index e245a39..4e806e7 100644 --- a/app/src/renderer/bar/bar.css +++ b/app/src/renderer/bar/bar.css @@ -1,53 +1,81 @@ * { box-sizing: border-box; margin: 0; } html, body { background: transparent; overflow: hidden; height: 100%; } body { - font-family: "New York", "Iowan Old Style", Palatino, ui-serif, Georgia, serif; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; user-select: none; } .pill { - height: 36px; - margin: 4px; + height: 34px; + margin: 4px 5px; border-radius: 999px; - background: #0a0a0a; - border: 1px solid rgba(255, 255, 255, 0.14); - box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); - color: #fafafa; + background: #141218; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: none; + color: #f4f0fa; display: flex; align-items: center; + justify-content: space-between; gap: 8px; - padding: 0 8px 0 14px; + padding: 0 6px; -webkit-app-region: drag; - opacity: 0.4; - transition: opacity 0.28s ease, background 0.2s ease; + opacity: 0.94; + transition: opacity 0.25s ease, border-color 0.2s ease, transform 0.18s ease; +} +body:hover .pill { + opacity: 1; + border-color: rgba(167, 139, 250, 0.45); + transform: translateY(-1px); +} +.pill--note { + outline: 1px solid rgba(167, 139, 250, 0.55); } -body:hover .pill { opacity: 1; } -.pill--note { opacity: 1; background: #000; } -.note { color: #fafafa; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.glyph { font-size: 11px; letter-spacing: 0.02em; } -.label { font-size: 12px; opacity: 0.9; flex: 1; white-space: nowrap; overflow: hidden; } -.label .hint { display: none; color: #cfcecb; } -body:hover .label .brand { display: none; } -body:hover .label .hint { display: inline; } -.kbd { - font-size: 10px; - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 4px; - padding: 0 4px; - margin-left: 4px; +.mark { + width: 22px; + height: 22px; + display: grid; + place-items: center; + color: #c4b5fd; + flex: 0 0 auto; + pointer-events: none; } -.pill button { +.chip { -webkit-app-region: no-drag; - background: transparent; + width: 26px; + height: 26px; + border-radius: 999px; border: none; - color: inherit; - font-size: 13px; - line-height: 1; - padding: 6px 7px; - border-radius: 8px; + display: grid; + place-items: center; cursor: pointer; + flex: 0 0 auto; + transition: background 0.15s ease, color 0.15s ease, transform 0.12s ease; +} +.chip:active { transform: scale(0.96); } +.chip:focus-visible { outline: 2px solid #a78bfa; outline-offset: 1px; } + +.chip--play { + background: rgba(255, 255, 255, 0.12); + color: #faf7ff; + padding-left: 1px; +} +.chip--play:hover { + background: #6f45d2; + color: #fffdf8; +} + +.chip--home { + background: #f4f0fa; + color: #17131f; +} +.chip--home:hover { + background: #ffffff; + color: #4f2aa9; +} + +@media (prefers-reduced-motion: reduce) { + .pill, .chip { transition: none; } } -.pill button:hover { background: rgba(255, 255, 255, 0.14); } diff --git a/app/src/renderer/bar/bar.tsx b/app/src/renderer/bar/bar.tsx index 8c17ee0..604ec12 100644 --- a/app/src/renderer/bar/bar.tsx +++ b/app/src/renderer/bar/bar.tsx @@ -2,39 +2,45 @@ import { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { LogoMark } from '../shared/logo'; -function prettyAccel(accel: string): string { - return accel.replace('CommandOrControl', '⌘').replace('Control', '⌃').replace('Shift', '⇧').replace('Alt', '⌥'); +function PlayIcon() { + return ( + + ); +} + +function HomeIcon() { + return ( + + ); } +/** Landscape aisle: play · logo · home. Original Unvibe chrome, Wispr-scale quietness. */ function Bar() { const [note, setNote] = useState(''); - const [shortcut, setShortcut] = useState('⌘U'); useEffect(() => { window.unvibe.onBarNotify((msg) => { setNote(msg); - setTimeout(() => setNote(''), 5000); + setTimeout(() => setNote(''), 4000); }); - window.unvibe.appInfo().then((i) => setShortcut(prettyAccel(i.shortcut))); }, []); return ( -
- - - {note ? ( - {note} - ) : ( - <> - Unvibe - - Review selection{shortcut} - - - )} +
+ + - - +
); } diff --git a/app/src/renderer/companion/companion.css b/app/src/renderer/companion/companion.css index 100b5b6..f905f60 100644 --- a/app/src/renderer/companion/companion.css +++ b/app/src/renderer/companion/companion.css @@ -1,31 +1,41 @@ :root { - --serif: "New York", "Iowan Old Style", Palatino, "Palatino Linotype", ui-serif, Georgia, serif; - - --shell: #f4f1ec; - --panel: #fffdfa; - --paper: #f9f6f1; - --ink: #1a1813; - --muted: #726d64; - --faint: #a49e93; - --line: #e6e1d8; - --line-soft: #efeae2; - --hover: #efeade; - --accent: #5a49b8; - --accent-soft: #eeeafa; + --font-sans: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-display: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "IBM Plex Mono", "SF Mono", SFMono-Regular, Menlo, monospace; + + --shell: #faf7f0; + --panel: #fffdf8; + --paper: #f7f1ff; + --ink: #21172f; + --muted: #6f6578; + --faint: #9b90a5; + --line: #e4d9ea; + --line-soft: #eee7f2; + --hover: #eee5ff; + --accent: #6f45d2; + --accent-strong: #4f2aa9; + --accent-soft: #eee5ff; + --on-accent: #fffdf8; + --focus-ring: rgba(111, 69, 210, .28); + --card-shadow: 4px 4px 0 rgba(111, 69, 210, .10); } :root[data-theme="dark"] { - --shell: #10131d; - --panel: #151925; - --paper: #1b2030; - --ink: #f3f0ea; - --muted: #b8b7c0; - --faint: #828695; - --line: #2c3242; - --line-soft: #222838; - --hover: #27253d; - --accent: #a595ff; - --accent-soft: #272343; + --shell: #0b0911; + --panel: #12101a; + --paper: #1d1729; + --ink: #f2eef9; + --muted: #c2b8ca; + --faint: #8e8299; + --line: #3a2f49; + --line-soft: #2b2238; + --hover: #2a2038; + --accent: #a78bfa; + --accent-strong: #c4b5fd; + --accent-soft: #2a2038; + --on-accent: #150f20; + --focus-ring: rgba(167, 139, 250, .34); + --card-shadow: 4px 4px 0 rgba(167, 139, 250, .10); } * { box-sizing: border-box; margin: 0; } @@ -33,14 +43,17 @@ html, body { height: 100%; } body { background: var(--shell); color: var(--ink); - font-family: var(--serif); + font-family: var(--font-sans); font-size: 15px; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; overflow: hidden; } button, input { font-family: inherit; } button { transition: background-color .16s ease, border-color .16s ease, color .16s ease, transform .16s ease; } button:not(:disabled):active { transform: translateY(1px); } +button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .titlebar { position: fixed; top: 0; left: 0; right: 0; height: 38px; -webkit-app-region: drag; z-index: 10; } .layout { display: flex; height: 100vh; padding-top: 6px; } @@ -54,17 +67,18 @@ button:not(:disabled):active { transform: translateY(1px); } padding: 40px 14px 14px; overflow-y: auto; } -.brand { display: flex; align-items: center; gap: 9px; padding: 0 10px 20px; } -.brand .mark { font-size: 16px; } -.brand .name { font-weight: 600; font-size: 20px; letter-spacing: -0.01em; } +.brand { display: flex; align-items: center; gap: 9px; padding: 0 10px 22px; } +.brand .mark { display: grid; place-items: center; color: var(--accent); } +.brand .name { font-weight: 720; font-size: 20px; letter-spacing: -0.045em; } .brand .badge { - font-family: -apple-system, sans-serif; + font-family: var(--font-mono); font-size: 10px; - letter-spacing: 0.04em; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); background: var(--accent-soft); - border-radius: 6px; + border: 1px solid rgba(111, 69, 210, .18); + border-radius: 5px; padding: 3px 7px; font-weight: 600; } @@ -76,7 +90,7 @@ button:not(:disabled):active { transform: translateY(1px); } gap: 12px; background: transparent; border: none; - border-radius: 9px; + border-radius: 8px; padding: 9px 11px; font-size: 15px; color: var(--ink); @@ -84,7 +98,8 @@ button:not(:disabled):active { transform: translateY(1px); } text-align: left; } .nav button:hover { background: var(--hover); } -.nav button.on { background: #e7e1d5; font-weight: 600; } +.nav button.on { background: var(--accent-soft); color: var(--accent-strong); font-weight: 650; box-shadow: inset 3px 0 0 var(--accent); } +.nav button.on svg { stroke: var(--accent); opacity: 1; } .nav svg { width: 18px; height: 18px; stroke: var(--ink); stroke-width: 1.5; fill: none; opacity: 0.8; flex-shrink: 0; } .side .spacer { flex: 1; min-height: 20px; } @@ -92,7 +107,7 @@ button:not(:disabled):active { transform: translateY(1px); } .sync-state { width: calc(100% - 4px); margin: 0 2px 10px; padding: 9px 11px; display: flex; align-items: center; gap: 8px; text-align: left; - border: 1px solid var(--line); border-radius: 9px; background: var(--paper); + border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--muted); font-size: 12px; cursor: pointer; } .sync-state:disabled { cursor: default; opacity: .85; } @@ -103,35 +118,109 @@ button:not(:disabled):active { transform: translateY(1px); } @keyframes sync-pulse { 50% { opacity: .3; } } @media (prefers-reduced-motion: reduce) { .sync-state--syncing .sync-state__dot { animation: none; } } -.promo { background: var(--accent-soft); border-radius: 13px; padding: 15px; margin: 0 2px 14px; } +.promo { background: var(--accent-soft); border: 1px solid rgba(111, 69, 210, .18); border-radius: 10px; padding: 15px; margin: 0 2px 14px; box-shadow: var(--card-shadow); } .promo .t { font-weight: 600; font-size: 15px; margin-bottom: 5px; } .promo .t em { color: var(--accent); font-style: italic; } -.promo .d { font-family: -apple-system, sans-serif; font-size: 12.5px; color: var(--muted); line-height: 1.5; margin-bottom: 11px; } +.promo .d { font-size: 12.5px; color: var(--muted); line-height: 1.5; margin-bottom: 11px; } .promo button { - background: var(--ink); color: #fff; border: none; border-radius: 9px; + background: var(--accent); color: var(--on-accent); border: 1px solid var(--accent); border-radius: 8px; padding: 8px 14px; font-size: 13px; font-weight: 500; cursor: pointer; } +.promo button:hover, .hero button:hover, .stub__cta:hover { background: var(--accent-strong); border-color: var(--accent-strong); } /* ================= content ================= */ .content { + position: relative; flex: 1; background: var(--panel); - border-radius: 16px; + border-radius: 18px; margin: 34px 14px 14px 0; border: 1px solid var(--line); + box-shadow: 0 1px 0 rgba(255, 255, 255, .45); overflow-y: auto; scroll-behavior: smooth; } -.page { max-width: 1080px; margin: 0 auto; padding: 40px 46px 72px; } - -.topline { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 30px; } -h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } +.content-tools { + position: sticky; + top: 0; + z-index: 5; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + padding: 14px 22px 10px; + background: linear-gradient(to bottom, var(--shell) 72%, transparent); + pointer-events: none; +} +.content-tools > * { pointer-events: auto; } +.theme-toggle { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--paper); + color: var(--ink); + cursor: pointer; + font-size: 16px; + line-height: 1; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent-strong); } +.theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.page { max-width: 1080px; margin: 0 auto; padding: 12px 46px 72px; } + +.topline { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 30px; gap: 16px; } +.topline__right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } +.topline__logo { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line)); +} +h1 { font-family: var(--font-sans); font-size: 32px; font-weight: 680; letter-spacing: -0.045em; } .avatar { width: 32px; height: 32px; border-radius: 50%; - background: var(--ink); color: #fff; + background: var(--accent); color: var(--on-accent); display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; flex-shrink: 0; } +.usage-chip { + display: inline-flex; align-items: baseline; gap: 6px; + border: 1px solid var(--line); background: var(--accent-soft); color: var(--accent-strong); + border-radius: 999px; padding: 6px 11px; font-family: -apple-system, sans-serif; + font-size: 12px; font-weight: 700; cursor: pointer; +} +.usage-chip small { color: var(--muted); font-weight: 500; } +.usage-chip:hover { border-color: var(--accent); } +.usage-chip--low { background: #f6ecd7; color: #8a5a16; border-color: #e4d3a8; } +.usage-chip--out { background: #f7e9e6; color: #8a342b; border-color: #e2c4bf; } +.usage-chip--side { + width: calc(100% - 4px); margin: 0 2px 12px; justify-content: space-between; + border-radius: 10px; padding: 9px 11px; font-size: 13px; +} +.usage-chip--side small { font-size: 11px; } +.limit-banner { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + border: 1px solid var(--line); background: var(--paper); border-radius: 12px; + padding: 14px 16px; margin: -10px 0 22px; +} +.limit-banner strong { display: block; font-size: 14px; margin-bottom: 4px; } +.limit-banner p { margin: 0; font-family: -apple-system, sans-serif; font-size: 13px; color: var(--muted); line-height: 1.45; } +.hero .row button:disabled { opacity: .45; cursor: not-allowed; } +.ai-key-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.ai-key-row .field { flex: 1; min-width: 180px; } +.cost-table { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: var(--panel); } +.cost-row { display: grid; grid-template-columns: 1.1fr 1fr 1fr 1fr; gap: 0; border-top: 1px solid var(--line-soft); font-family: -apple-system, sans-serif; font-size: 12px; } +.cost-row:first-child { border-top: 0; } +.cost-row > span { padding: 8px 10px; color: var(--muted); } +.cost-row.head { background: var(--accent-soft); font-weight: 700; color: var(--accent-strong); text-transform: capitalize; } +.cost-row.head > span { color: var(--accent-strong); } +.cost-row > span:first-child { color: var(--ink); font-weight: 600; text-transform: capitalize; } +.cost-note { margin: 10px 0 0; font-family: -apple-system, sans-serif; font-size: 12px; color: var(--muted); line-height: 1.45; } .cols { display: flex; gap: 30px; align-items: flex-start; } .main-col { flex: 1; min-width: 0; } @@ -139,36 +228,56 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } /* ---- hero (light, no black) ---- */ .hero { - background: var(--paper); + position: relative; + isolation: isolate; + overflow: hidden; + background: var(--accent-soft); border: 1px solid var(--line); - border-radius: 16px; + border-radius: 14px; padding: 38px 40px; margin-bottom: 34px; + box-shadow: var(--card-shadow); +} +.hero::after { + content: ""; + position: absolute; + z-index: -1; + right: 48px; + top: 42px; + width: 7px; + height: 7px; + opacity: .2; + background: var(--accent); + box-shadow: 14px 0 var(--accent), 28px 0 var(--accent), 7px 14px var(--accent), 21px 14px var(--accent), 35px 14px var(--accent), 0 28px var(--accent), 14px 28px var(--accent), 28px 28px var(--accent); } .hero h2 { - font-style: italic; - font-weight: 500; + font-family: var(--font-sans); + font-style: normal; + font-weight: 700; font-size: 34px; - line-height: 1.15; - letter-spacing: 0.01em; + line-height: 1.12; + letter-spacing: -0.04em; + color: var(--accent-strong); margin-bottom: 12px; } -.hero p { font-family: -apple-system, sans-serif; font-size: 14.5px; color: var(--muted); line-height: 1.55; margin-bottom: 22px; max-width: 460px; } +.hero p { font-size: 14.5px; color: var(--muted); line-height: 1.55; margin-bottom: 22px; max-width: 500px; } .hero .row { display: flex; align-items: center; gap: 14px; } .hero button { - background: var(--ink); color: #fff; border: 1px solid var(--ink); - border-radius: 10px; padding: 10px 18px; font-size: 14px; font-weight: 500; cursor: pointer; + background: var(--accent); color: var(--on-accent); border: 1px solid var(--accent); + border-radius: 9px; padding: 11px 19px; font-size: 14px; font-weight: 650; cursor: pointer; + box-shadow: 3px 3px 0 rgba(79, 42, 169, .18); } .hero .kbd { - font-family: -apple-system, sans-serif; + font-family: var(--font-mono); font-size: 12px; color: var(--muted); border: 1px solid var(--line); border-radius: 7px; padding: 4px 9px; } /* ---- how it works ---- */ -.section-head { font-style: italic; font-size: 20px; font-weight: 500; margin: 0 0 16px; } +.section-head { font-family: var(--font-sans); font-style: normal; font-size: 18px; font-weight: 650; margin: 0 0 16px; } .how { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 34px; } -.how-card { border: 1px solid var(--line); border-radius: 13px; padding: 18px; } +.how-card { border: 1px solid var(--line); border-radius: 10px; padding: 18px; background: var(--panel); transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; } +.how-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--card-shadow); } .how-card .n { font-family: -apple-system, sans-serif; font-size: 11px; font-weight: 700; color: var(--accent); letter-spacing: 0.06em; margin-bottom: 9px; @@ -178,32 +287,32 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } /* ---- feed ---- */ .feed-label { font-family: -apple-system, sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.11em; color: var(--faint); margin-bottom: 14px; } -.feed-empty { border: 1.5px dashed var(--line); border-radius: 13px; padding: 40px 24px; text-align: center; } +.feed-empty { border: 1.5px dashed var(--line); border-radius: 10px; padding: 40px 24px; text-align: center; background: var(--paper); } .feed-empty .t { font-weight: 600; font-size: 16px; margin-bottom: 6px; } .feed-empty .d { font-family: -apple-system, sans-serif; font-size: 13px; color: var(--muted); max-width: 340px; margin: 0 auto; line-height: 1.5; } /* ---- rail cards ---- */ -.stats { border: 1px solid var(--line); border-radius: 13px; padding: 18px 20px; display: flex; flex-direction: column; gap: 13px; } +.stats { border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; display: flex; flex-direction: column; gap: 13px; background: var(--accent-soft); } .stat { display: flex; align-items: baseline; gap: 10px; } -.stat .v { font-size: 26px; font-weight: 500; min-width: 40px; } +.stat .v { font-size: 26px; font-weight: 700; min-width: 40px; color: var(--accent-strong); letter-spacing: -.04em; } .stat .l { font-family: -apple-system, sans-serif; font-size: 13px; color: var(--muted); } -.rail-card { border: 1px solid var(--line); border-radius: 13px; padding: 18px 20px; transition: border-color .2s ease, box-shadow .2s ease; } -.rail-card:hover { border-color: var(--muted); box-shadow: 0 2px 8px rgba(0,0,0,.04); } +.rail-card { border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; } +.rail-card:hover { border-color: var(--accent); box-shadow: var(--card-shadow); transform: translateY(-2px); } .rail-card .t { font-weight: 600; font-size: 15px; margin-bottom: 6px; } .rail-card .d { font-family: -apple-system, sans-serif; font-size: 12.5px; color: var(--muted); line-height: 1.55; } /* ================= progress page ================= */ .tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 26px; } -.tile { border: 1px solid var(--line); border-radius: 13px; padding: 20px; transition: border-color .2s ease, transform .2s ease; } -.tile:hover { border-color: var(--muted); transform: translateY(-2px); } -.tile .v { font-size: 34px; font-weight: 500; letter-spacing: -0.01em; } +.tile { border: 1px solid var(--line); border-radius: 10px; padding: 20px; background: var(--panel); transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; } +.tile:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--card-shadow); } +.tile .v { font-size: 34px; font-weight: 700; letter-spacing: -0.05em; color: var(--accent-strong); } .tile .l { font-family: -apple-system, sans-serif; font-size: 12.5px; color: var(--muted); margin-top: 4px; } .tile .note { font-family: -apple-system, sans-serif; font-size: 11px; color: var(--faint); margin-top: 8px; } -.panel-card { border: 1px solid var(--line); border-radius: 14px; padding: 22px 24px; margin-bottom: 20px; } +.panel-card { border: 1px solid var(--line); border-radius: 11px; padding: 22px 24px; margin-bottom: 20px; } .panel-card .ph { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; } -.panel-card .ph .t { font-style: italic; font-size: 18px; font-weight: 500; } +.panel-card .ph .t { font-family: var(--font-sans); font-style: normal; font-size: 17px; font-weight: 650; } .panel-card .ph .m { font-family: -apple-system, sans-serif; font-size: 12px; color: var(--faint); } .heat { display: grid; grid-template-rows: repeat(7, 12px); grid-auto-flow: column; grid-auto-columns: 12px; gap: 3px; } @@ -228,10 +337,227 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } .soft-note { font-family: -apple-system, sans-serif; font-size: 12px; color: var(--faint); margin-top: 4px; } /* ================= explainer pages ================= */ -.lead { font-family: -apple-system, sans-serif; color: var(--muted); font-size: 14.5px; margin: 4px 0 30px; max-width: 560px; line-height: 1.6; } +.lead { font-family: var(--font-sans); color: var(--muted); font-size: 14.5px; margin: 4px 0 30px; max-width: 560px; line-height: 1.6; } +.learning-summary { display: grid; grid-template-columns: auto auto minmax(200px, 1fr); align-items: center; gap: 20px; margin: 0 0 18px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); } +.learning-summary > div { display: grid; gap: 3px; min-width: 92px; }.learning-summary strong { font-size: 24px; letter-spacing: -.04em; }.learning-summary span { color: var(--muted); font-family: var(--font-sans); font-size: 11px; }.learning-summary p { margin: 0; color: var(--muted); font-family: var(--font-sans); font-size: 13px; line-height: 1.5; } +.learning-list { display: grid; gap: 10px; }.learning-card { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 19px 20px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.learning-card h2, .history-row h2, .quiz-callout h2 { margin: 5px 0 4px; font-size: 18px; letter-spacing: -.025em; }.learning-card p, .history-row p, .quiz-callout p { margin: 0; color: var(--muted); font-family: var(--font-sans); font-size: 12.5px; line-height: 1.5; }.learning-kicker { color: var(--accent); font-family: var(--font-mono); font-size: 10px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; } +.learning-empty { display: grid; justify-items: center; max-width: 620px; padding: 48px 28px; border: 1.5px dashed var(--line); border-radius: 12px; background: var(--panel); text-align: center; }.learning-empty .stub__icon { margin-bottom: 14px; }.learning-empty h2 { font-family: var(--font-sans); font-size: 20px; letter-spacing: -.03em; }.learning-empty p { max-width: 430px; margin: 8px 0 20px; color: var(--muted); font-family: var(--font-sans); font-size: 13px; line-height: 1.55; } + +/* History — no box borders; quiet list + open pane */ +.history-list { display: grid; gap: 4px; background: transparent; border: 0; } +.history-row { + display: grid; + grid-template-columns: 122px minmax(0, 1fr) auto; + align-items: center; + gap: 18px; + padding: 14px 12px; + border: 0; + border-radius: 12px; + background: transparent; + transition: background .18s ease, transform .18s ease; +} +.history-row time { color: var(--faint); font-family: var(--font-mono); font-size: 11px; line-height: 1.45; } +.history-row h2 { font-family: var(--font-sans); font-weight: 650; } +.quiz-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 18px; padding: 24px; border: 1px solid var(--accent); border-radius: 12px; background: var(--paper); box-shadow: var(--card-shadow); }.quiz-icon { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 10px; background: var(--accent); color: var(--on-accent); }.quiz-icon svg { width: 23px; height: 23px; fill: none; stroke: currentColor; stroke-width: 1.6; }.quiz-queue { margin-top: 0; border: 0; border-radius: 12px; overflow: hidden; background: color-mix(in srgb, var(--panel) 88%, var(--paper)); }.quiz-queue .ph { display: flex; justify-content: space-between; padding: 15px 18px; border-bottom: 1px solid var(--line-soft); }.quiz-queue .ph .t { font-weight: 650; font-family: var(--font-sans); }.quiz-queue .ph .m { color: var(--muted); font-family: var(--font-mono); font-size: 12px; }.quiz-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 18px; border-bottom: 1px solid transparent; }.quiz-row:last-child { border-bottom: 0; }.quiz-row.on { background: var(--paper); }.quiz-row strong, .quiz-row span { display: block; }.quiz-row strong { font-size: 14px; }.quiz-row span { margin-top: 3px; color: var(--muted); font-family: var(--font-sans); font-size: 12px; } +.history-split, .study-layout, .quiz-split { display: grid; grid-template-columns: minmax(260px, 0.95fr) minmax(320px, 1.2fr); gap: 18px; align-items: start; } +.history-row--btn { width: 100%; text-align: left; appearance: none; color: inherit; cursor: pointer; font: inherit; } +.history-row--btn:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); } +.history-row--btn.on { background: color-mix(in srgb, var(--accent) 12%, var(--paper)); box-shadow: inset 3px 0 0 var(--accent); } +.learning-card--pick.on { border-color: var(--accent); background: var(--paper); } +.history-detail { border: 0; border-radius: 14px; padding: 8px 8px 18px 18px; background: transparent; min-height: 280px; animation: historyPaneIn .28s ease both; } +.study-pane, .quiz-card-pane { border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; background: var(--panel); min-height: 280px; } +.history-detail h2, .study-pane__head h2, .quiz-card h2 { margin: 6px 0 8px; font-size: 20px; letter-spacing: -.03em; font-family: var(--font-sans); font-weight: 650; } +.history-detail--empty { display: grid; place-items: center; } +.lesson-code { margin: 14px 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #0f1115; color: #e8e6e1; } +.lesson-code__bar { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,.08); color: #9a968c; font-family: var(--font-mono); font-size: 10px; letter-spacing: .06em; text-transform: uppercase; } +.lesson-code pre { margin: 0; padding: 12px 14px; overflow: auto; max-height: 280px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; white-space: pre; } +.lesson-explain { margin-top: 14px; } +.lesson-explain__body, .rich-text { + margin-top: 8px; + font-family: var(--font-sans); + font-size: 13.5px; + line-height: 1.55; + color: var(--ink); +} +.rich-text p { margin: 0 0 10px; } +.rich-text p:last-child { margin-bottom: 0; } +.rich-list { + margin: 0 0 12px; + padding-left: 1.15em; + display: grid; + gap: 8px; +} +.rich-list li { padding-left: 2px; } +.rich-text .cite { + display: inline-block; + font-family: var(--font-mono); + font-size: 11px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--accent) 8%, var(--paper)); + border-radius: 5px; + padding: 0 5px; + margin: 0 2px; + color: var(--muted); +} +.rich-text code.inline { + font-family: var(--font-mono); + font-size: 12px; + background: var(--paper); + border-radius: 4px; + padding: 1px 4px; +} +.rich-text .codeblock { + margin: 4px 0 12px; + border-radius: 10px; + overflow: hidden; + background: #21172f; + border: 1px solid #3a2f49; +} +.rich-text .codeblock .cb-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + color: #c2b8ca; + font-family: var(--font-mono); + font-size: 11px; +} +.rich-text .codeblock .cb-head button { + border: 0; + background: transparent; + color: #c2b8ca; + cursor: pointer; + font: inherit; +} +.rich-text .codeblock pre { + margin: 0; + padding: 10px 12px 14px; + overflow: auto; + color: #f2eef9; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.45; + white-space: pre; +} +.rich-text .tk-k { color: #c4b5fd; } +.rich-text .tk-s { color: #86efac; } +.rich-text .tk-n { color: #fcd34d; } +.rich-text .tk-c { color: #8e8299; } +.study-rail { display: grid; gap: 12px; }.study-summary { grid-template-columns: auto auto; }.learning-card--pick { width: 100%; text-align: left; cursor: pointer; appearance: none; color: inherit; font: inherit; } +.study-levels, .study-assistant { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line-soft); } +.level-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 14px; } +.level-row button { border: 1px solid var(--line); background: var(--paper); color: var(--ink); border-radius: 8px; padding: 7px 11px; font-size: 12px; cursor: pointer; } +.level-row button.on { border-color: var(--accent); background: var(--accent); color: var(--on-accent); } +.study-assistant textarea { width: 100%; margin: 10px 0 12px; border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; resize: vertical; background: var(--paper); color: var(--ink); font: inherit; } +.quiz-layout { display: grid; gap: 18px; } +.quiz-card { + animation: quizCardIn .38s cubic-bezier(.22, 1, .36, 1) both; +} +.quiz-card h2 { font-family: var(--font-sans); font-weight: 650; } +.quiz-card .quiz-options { display: grid; gap: 10px; margin-top: 16px; } +.quiz-options button { + text-align: left; + border: 1px solid var(--line); + border-radius: 12px; + padding: 13px 15px; + background: var(--paper); + color: var(--ink); + cursor: pointer; + font: inherit; + opacity: 1; + transform: none; + animation: quizOptionIn .34s cubic-bezier(.22, 1, .36, 1) both; + transition: border-color .18s ease, background .18s ease, transform .18s ease, box-shadow .18s ease; +} +.quiz-options button:nth-child(1) { animation-delay: .04s; } +.quiz-options button:nth-child(2) { animation-delay: .1s; } +.quiz-options button:nth-child(3) { animation-delay: .16s; } +.quiz-options button:nth-child(4) { animation-delay: .22s; } +.quiz-options button:nth-child(5) { animation-delay: .28s; } +.quiz-options button:hover:not(:disabled) { + border-color: var(--accent); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(111, 69, 210, .1); +} +.quiz-options button:disabled { cursor: default; } +.quiz-options button.correct { + opacity: 1; + border-color: #2f9d63; + background: color-mix(in srgb, #2f9d63 14%, var(--paper)); + color: var(--ink); + animation: quizCorrectPulse .55s ease both; +} +.quiz-options button.wrong { + opacity: 1; + border-color: #c45b4a; + background: color-mix(in srgb, #c45b4a 12%, var(--paper)); + animation: quizWrongShake .45s ease both; +} +.quiz-options button.dimmed { opacity: .45; } +.quiz-result { + margin-top: 16px; + padding: 16px 16px 14px; + border: 0; + border-radius: 14px; + background: color-mix(in srgb, var(--accent) 8%, var(--panel)); + animation: quizResultIn .4s cubic-bezier(.22, 1, .36, 1) both; +} +.quiz-result.ok { background: color-mix(in srgb, #2f9d63 12%, var(--panel)); } +.quiz-result.bad { background: color-mix(in srgb, #c45b4a 12%, var(--panel)); } +.quiz-result__eyebrow { + display: block; + margin-bottom: 4px; + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 750; + letter-spacing: .09em; + text-transform: uppercase; +} +.quiz-result.ok .quiz-result__eyebrow { color: #247643; } +.quiz-result.bad .quiz-result__eyebrow { color: #9a3a2c; } +.quiz-result strong { + display: block; + font-family: var(--font-sans); + font-size: 22px; + font-weight: 700; + letter-spacing: -.03em; +} +.quiz-options button.wrong:disabled { opacity: .7; cursor: default; } +.quiz-result p { margin: 8px 0 14px; color: var(--muted); font-family: var(--font-sans); font-size: 13.5px; line-height: 1.55; } +@keyframes quizCardIn { + from { opacity: 0; transform: translateY(12px) scale(.985); } + to { opacity: 1; transform: none; } +} +@keyframes quizOptionIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} +@keyframes quizCorrectPulse { + 0% { transform: scale(1); } + 40% { transform: scale(1.03); } + 100% { transform: scale(1); } +} +@keyframes quizWrongShake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-5px); } + 40% { transform: translateX(5px); } + 60% { transform: translateX(-3px); } + 80% { transform: translateX(2px); } +} +@keyframes quizResultIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} +@keyframes historyPaneIn { + from { opacity: 0; transform: translateX(8px); } + to { opacity: 1; transform: none; } +} +.muted { color: var(--muted); font-family: var(--font-sans); font-size: 13px; line-height: 1.5; } +.form-error { color: var(--ink); font-family: var(--font-sans); font-size: 13px; } .feature-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 26px; } -.feature { border: 1px solid var(--line); border-radius: 13px; padding: 20px 22px; transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; } -.feature:hover { border-color: var(--muted); box-shadow: 0 2px 8px rgba(0,0,0,.04); transform: translateY(-2px); } +.feature { border: 1px solid var(--line); border-radius: 10px; padding: 20px 22px; transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; } +.feature:hover { border-color: var(--accent); box-shadow: var(--card-shadow); transform: translateY(-2px); } .feature .fh { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } .feature .fh svg { width: 18px; height: 18px; stroke: var(--accent); stroke-width: 1.5; fill: none; } .feature .fh .t { font-weight: 600; font-size: 16px; } @@ -242,12 +568,12 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } line-height: 1.55; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 14px; transition: border-color .2s ease, background .2s ease; } -.stub:hover { border-color: var(--muted); background: var(--paper); } -.stub .stub__icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--hover); border-radius: 10px; color: var(--ink); } +.stub:hover { border-color: var(--accent); background: var(--paper); } +.stub .stub__icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--hover); border-radius: 8px; color: var(--accent); } .stub .stub__icon svg { width: 20px; height: 20px; } .stub b { color: var(--ink); font-weight: 600; } .stub__cta { - background: var(--ink); color: #fff; border: none; border-radius: 9px; + background: var(--accent); color: var(--on-accent); border: 1px solid var(--accent); border-radius: 8px; padding: 9px 18px; font-size: 13px; font-weight: 500; cursor: pointer; font-family: -apple-system, sans-serif; transition: opacity .2s ease, transform .2s ease; } @@ -256,29 +582,82 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } .d2 { display: inline-block; font-family: -apple-system, sans-serif; font-size: 10px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; - color: var(--muted); background: var(--hover); + color: var(--accent-strong); background: var(--accent-soft); border-radius: 6px; padding: 4px 8px; margin-left: 12px; vertical-align: 6px; } /* ================= settings modal ================= */ -.overlay { position: fixed; inset: 0; background: rgba(20, 18, 14, 0.32); display: flex; align-items: center; justify-content: center; z-index: 50; animation: fadeIn 0.2s ease both; } -.modal { width: 760px; height: 480px; background: var(--panel); border-radius: 16px; display: flex; overflow: hidden; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28); } +.overlay { position: fixed; inset: 0; background: rgba(33, 23, 47, 0.42); display: flex; align-items: center; justify-content: center; z-index: 50; animation: fadeIn 0.2s ease both; } +.modal { width: 760px; height: 480px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; display: flex; overflow: hidden; box-shadow: 10px 12px 0 rgba(33, 23, 47, .14); } .modal .mside { width: 210px; background: var(--shell); padding: 22px 12px; flex-shrink: 0; } .mside .mh { font-family: -apple-system, sans-serif; font-size: 10px; font-weight: 700; letter-spacing: 0.09em; color: var(--faint); padding: 0 10px 10px; } .mside button { display: block; width: 100%; text-align: left; background: transparent; border: none; border-radius: 8px; padding: 8px 10px; font-size: 14px; color: var(--ink); cursor: pointer; } -.mside button.on, .mside button:hover { background: var(--hover); } +.mside button.on, .mside button:hover { background: var(--hover); color: var(--accent-strong); } .mside .ver { font-family: -apple-system, sans-serif; font-size: 11px; color: var(--faint); padding: 14px 10px 0; } .modal .mbody { flex: 1; padding: 30px 34px; overflow-y: auto; } -.mbody h2 { font-style: italic; font-weight: 500; font-size: 24px; margin-bottom: 20px; } +.mbody h2 { font-family: var(--font-sans); font-style: normal; font-weight: 500; font-size: 25px; margin-bottom: 20px; color: var(--accent-strong); } .setrow { display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid var(--line); } .setrow:last-child { border-bottom: none; } .setrow .sl { font-weight: 600; font-size: 15px; } .setrow .sd { font-family: -apple-system, sans-serif; font-size: 12.5px; color: var(--muted); margin-top: 3px; max-width: 340px; line-height: 1.45; } -.setrow .act { background: var(--hover); border: none; border-radius: 8px; padding: 8px 15px; font-size: 13px; cursor: pointer; color: var(--ink); } +.act { + background: var(--accent); + color: var(--on-accent); + border: 1px solid var(--accent); + border-radius: 9px; + padding: 8px 15px; + font: inherit; + font-size: 13px; + font-weight: 650; + cursor: pointer; + transition: background .16s ease, border-color .16s ease, box-shadow .16s ease, transform .16s ease; +} +.act:not(:disabled):hover { + background: var(--accent-strong); + border-color: var(--accent-strong); + box-shadow: 3px 3px 0 rgba(79, 42, 169, .18); +} +.act:disabled { background: var(--hover); color: var(--faint); border-color: transparent; cursor: default; } +.setrow .act { background: var(--accent-soft); border: 1px solid transparent; color: var(--accent-strong); box-shadow: none; } +.setrow .act:not(:disabled):hover { border-color: var(--accent); background: var(--accent-soft); box-shadow: none; } .setrow .act:disabled { color: var(--faint); cursor: default; } +.primary-btn { + background: var(--accent); + color: var(--on-accent); + border: 1px solid var(--accent); + border-radius: 10px; + padding: 11px 18px; + font: inherit; + font-size: 14px; + font-weight: 650; + cursor: pointer; + transition: background .16s ease, border-color .16s ease, box-shadow .16s ease, transform .16s ease; +} +.primary-btn:not(:disabled):hover { + background: var(--accent-strong); + border-color: var(--accent-strong); + box-shadow: 3px 3px 0 rgba(79, 42, 169, .18); +} +.primary-btn:disabled { opacity: 0.45; cursor: default; } + +.soft-btn { + background: var(--accent-soft); + color: var(--accent-strong); + border: 1px solid transparent; + border-radius: 9px; + padding: 9px 14px; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: border-color .16s ease, background .16s ease, color .16s ease; +} +.soft-btn:not(:disabled):hover { border-color: var(--accent); } +.soft-btn:disabled { color: var(--faint); cursor: default; } + /* ================= feed (real) ================= */ -.feed { border: 1px solid var(--line); border-radius: 13px; overflow: hidden; } +.feed { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } .feed-row { display: flex; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--line-soft); } .feed-row:last-child { border-bottom: none; } .feed-time { font-family: -apple-system, sans-serif; font-size: 12px; color: var(--faint); width: 62px; flex-shrink: 0; } @@ -286,19 +665,19 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } .feed-title { font-size: 15px; font-weight: 500; } .feed-meta { font-family: -apple-system, sans-serif; font-size: 12px; color: var(--muted); margin-top: 2px; } .tag { font-family: -apple-system, sans-serif; font-size: 11px; border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px; color: var(--muted); white-space: nowrap; } -.tag--understood { border-color: var(--ink); color: var(--ink); } +.tag--understood { border-color: var(--accent); color: var(--accent-strong); background: var(--accent-soft); } .tag--needs_review { border-style: dashed; } /* ================= login ================= */ .login { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--shell); overflow: hidden; } -.login__bg { position: absolute; inset: 0; background: radial-gradient(ellipse 80% 70% at 50% 30%, var(--accent-soft) 0%, transparent 70%); pointer-events: none; } -.login__card { width: 400px; text-align: center; padding: 48px 40px 36px; position: relative; } -.login__mark { display: flex; justify-content: center; margin-bottom: 10px; color: var(--ink); } -.login__brand { font-size: 12px; letter-spacing: 0.34em; text-transform: uppercase; color: var(--faint); margin-bottom: 20px; font-family: -apple-system, sans-serif; font-weight: 600; } -.login__tag { font-style: italic; font-weight: 500; font-size: 28px; line-height: 1.2; margin-bottom: 24px; letter-spacing: -0.01em; } +.login__bg { position: absolute; width: 9px; height: 9px; right: 13%; top: 15%; opacity: .16; background: var(--accent); box-shadow: 18px 0 var(--accent), 36px 0 var(--accent), 9px 18px var(--accent), 27px 18px var(--accent), 45px 18px var(--accent), 0 36px var(--accent), 18px 36px var(--accent), 36px 36px var(--accent); pointer-events: none; } +.login__card { width: 420px; text-align: center; padding: 44px 42px 36px; position: relative; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: 8px 8px 0 rgba(111, 69, 210, .12); } +.login__mark { display: flex; justify-content: center; margin-bottom: 10px; color: var(--accent); } +.login__brand { font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase; color: var(--accent-strong); margin-bottom: 20px; font-family: var(--font-mono); font-weight: 700; } +.login__tag { font-family: var(--font-sans); font-style: normal; font-weight: 500; font-size: 30px; line-height: 1.15; margin-bottom: 24px; letter-spacing: -0.025em; } .login__features { display: flex; flex-direction: column; gap: 8px; margin-bottom: 28px; text-align: left; max-width: 260px; margin-left: auto; margin-right: auto; } .login__feature { display: flex; align-items: center; gap: 10px; font-family: -apple-system, sans-serif; font-size: 13.5px; color: var(--muted); } -.lf-icon { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: var(--hover); border-radius: 6px; font-size: 11px; color: var(--ink); flex-shrink: 0; } +.lf-icon { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: var(--hover); border-radius: 5px; font-size: 11px; color: var(--accent-strong); flex-shrink: 0; } .login__skip { margin-top: 20px; background: none; border: none; color: var(--faint); font-family: -apple-system, sans-serif; font-size: 13px; cursor: pointer; transition: color .2s ease, transform .2s ease; } .login__skip:hover { color: var(--ink); transform: translateX(2px); } @@ -306,12 +685,13 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } .signin { display: flex; flex-direction: column; gap: 10px; text-align: left; } .signin__tabs { display: flex; gap: 0; margin-bottom: 2px; background: var(--hover); border-radius: 9px; overflow: hidden; } .signin__tab { flex: 1; background: transparent; border: none; padding: 7px 0; font-size: 13px; font-weight: 500; color: var(--muted); cursor: pointer; } -.signin__tab.on { background: var(--ink); color: #fff; } +.signin__tab.on { background: var(--accent); color: var(--on-accent); } .signin__email-actions { display: flex; gap: 8px; } .signin__email-actions .field-btn { flex: 1; } .field { width: 100%; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 11px 13px; font-family: -apple-system, sans-serif; font-size: 14px; color: var(--ink); } -.field:focus { outline: none; border-color: var(--muted); } -.field-btn { background: var(--ink); color: #fff; border: none; border-radius: 10px; padding: 11px 0; font-size: 14px; font-weight: 500; cursor: pointer; transition: background .16s ease, color .16s ease, transform .16s ease; } +.field:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-ring); } +.field-btn { background: var(--accent); color: var(--on-accent); border: 1px solid var(--accent); border-radius: 9px; padding: 11px 0; font-size: 14px; font-weight: 650; cursor: pointer; transition: background .16s ease, color .16s ease, transform .16s ease, box-shadow .16s ease; } +.field-btn:not(:disabled):hover { background: var(--accent-strong); border-color: var(--accent-strong); box-shadow: 3px 3px 0 rgba(79, 42, 169, .18); } .field-btn:disabled { background: var(--hover); color: var(--faint); cursor: default; } .field-btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); } .field-btn.ghost:disabled { background: transparent; color: var(--faint); border-color: var(--line); } @@ -326,13 +706,13 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } /* ================= onboarding ================= */ .ob { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--shell); } -.ob__card { width: 440px; max-width: 90vw; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 34px 36px; text-align: center; } +.ob__card { width: 440px; max-width: 90vw; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 34px 36px; text-align: center; box-shadow: 8px 8px 0 rgba(111, 69, 210, .12); } .ob__progress { display: flex; justify-content: space-between; font-family: -apple-system, sans-serif; font-size: 11px; color: var(--faint); margin-bottom: 10px; } .ob__dots { display: flex; gap: 6px; justify-content: center; margin-bottom: 22px; } .ob__dot { width: 14px; height: 3px; border-radius: 2px; background: var(--line); transition: background-color .2s ease, transform .2s ease; } -.ob__dot.on { background: var(--ink); } -.ob__mark { display: flex; justify-content: center; margin-bottom: 14px; color: var(--ink); } -.ob__title { font-style: italic; font-weight: 500; font-size: 25px; margin-bottom: 12px; } +.ob__dot.on { background: var(--accent); transform: scaleX(1.3); } +.ob__mark { display: flex; justify-content: center; margin-bottom: 14px; color: var(--accent); } +.ob__title { font-family: var(--font-sans); font-style: normal; font-weight: 500; font-size: 27px; margin-bottom: 12px; letter-spacing: -.025em; } .ob__sub { font-family: -apple-system, sans-serif; font-size: 14px; color: var(--muted); line-height: 1.6; margin-bottom: 22px; } .ob__list { text-align: left; font-family: -apple-system, sans-serif; font-size: 14px; color: var(--muted); line-height: 1.6; margin: 0 0 22px; padding-left: 20px; } .ob__list li { margin-bottom: 10px; } @@ -342,22 +722,23 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } .ob__skip:hover { color: var(--ink); } .ob__skip:disabled { visibility: hidden; } .field-btn.inline { width: auto; padding: 10px 20px; } -.kbd-lg { font-family: ui-monospace, monospace; font-size: 15px; background: var(--hover); border-radius: 7px; padding: 3px 9px; } +.kbd-lg { font-family: var(--font-mono); font-size: 15px; background: var(--hover); color: var(--accent-strong); border-radius: 6px; padding: 3px 9px; } .ob__test { font-family: -apple-system, sans-serif; font-size: 13.5px; color: var(--muted); border: 1.5px dashed var(--line); border-radius: 10px; padding: 14px; margin-bottom: 18px; } -.ob__test.ok { color: var(--ink); border-style: solid; border-color: var(--ink); font-weight: 600; } +.ob__test.ok { color: var(--accent-strong); border-style: solid; border-color: var(--accent); background: var(--accent-soft); font-weight: 600; } .ob__status { font-family: -apple-system, sans-serif; font-size: 13px; color: #2c6b3f; background: #e7f0e9; border-radius: 9px; padding: 11px 13px; margin-bottom: 14px; overflow-wrap: anywhere; } -.ob__choices { display: grid; gap: 9px; margin: 0 0 20px; text-align: left; } +.ob__choices { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin: 0 0 20px; text-align: left; } +@media (max-width: 560px) { .ob__choices { grid-template-columns: 1fr; } } .ob__choice { width: 100%; display: flex; gap: 11px; align-items: center; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); color: var(--ink); padding: 12px 13px; text-align: left; cursor: pointer; } .ob__choice:hover { border-color: var(--muted); } .ob__choice.selected { border-color: var(--accent); background: var(--accent-soft); transform: translateY(-1px); } .ob__choice-check { flex: 0 0 19px; width: 19px; height: 19px; border: 1px solid var(--line); border-radius: 50%; color: transparent; display: grid; place-items: center; font-family: -apple-system, sans-serif; font-size: 12px; } -.ob__choice.selected .ob__choice-check { border-color: var(--accent); background: var(--accent); color: #fff; } +.ob__choice.selected .ob__choice-check { border-color: var(--accent); background: var(--accent); color: var(--on-accent); } .ob__choice b, .ob__choice small { display: block; } .ob__choice b { font-size: 14px; font-weight: 600; } .ob__choice small { font-family: -apple-system, sans-serif; color: var(--muted); font-size: 12px; margin-top: 2px; line-height: 1.35; } .ob__demo { margin: 0 0 20px; padding: 17px; border: 1px solid var(--line); border-radius: 11px; background: var(--paper); text-align: left; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; color: var(--muted); overflow-x: auto; } .ob__demo span { color: var(--accent); }.ob__demo b { color: var(--ink); } -.ob__widget { border: 1px solid var(--line); background: var(--paper); border-radius: 12px; padding: 16px; text-align: left; margin: 0 0 12px; box-shadow: 0 10px 25px rgba(45, 39, 89, .08); } +.ob__widget { border: 1px solid var(--line); background: var(--paper); border-radius: 10px; padding: 16px; text-align: left; margin: 0 0 12px; box-shadow: var(--card-shadow); } .ob__widget b, .ob__widget span, .ob__widget small { display: block; }.ob__widget b { font-size: 15px; margin-bottom: 6px; }.ob__widget span, .ob__widget small { font-family: -apple-system, sans-serif; color: var(--muted); font-size: 12px; line-height: 1.45; }.ob__widget small { color: var(--accent); margin-top: 11px; } .ob__theme { display: flex; align-items: center; justify-content: flex-end; gap: 7px; margin-bottom: 20px; font-family: -apple-system, sans-serif; font-size: 12px; color: var(--muted); }.ob__theme button { border: 1px solid var(--line); background: var(--panel); color: var(--muted); border-radius: 7px; padding: 5px 9px; cursor: pointer; font-size: 12px; }.ob__theme button.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } @@ -374,13 +755,13 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } /* settings controls */ .toggle { width: 42px; height: 24px; border-radius: 999px; background: var(--line); border: none; cursor: pointer; position: relative; transition: background 0.16s; padding: 0; flex-shrink: 0; } -.toggle.on { background: var(--ink); } +.toggle.on { background: var(--accent); } .toggle .knob { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left 0.16s; } .toggle.on .knob { left: 21px; } .sel-input, .time-input { font-family: -apple-system, sans-serif; font-size: 13px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--ink); cursor: pointer; } -.range { width: 140px; accent-color: var(--ink); } +.range { width: 140px; accent-color: var(--accent); } .kbd-cap { font-family: ui-monospace, monospace; min-width: 64px; } -.kbd-cap.rec { background: var(--ink); color: #fff; } +.kbd-cap.rec { background: var(--accent); color: var(--on-accent); } /* ================= motion ================= */ @keyframes fadeInUp { @@ -455,8 +836,18 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } .login { animation: fadeIn 0.4s ease both; } .ob { animation: fadeIn 0.35s ease both; } +/* ================= plan + usage ================= */ +.plan-view .page-head { max-width: 760px; }.plan-view .page-head h1 { max-width: 680px; margin: 7px 0 10px; font-size: clamp(34px, 5vw, 58px); line-height: .98; letter-spacing: -.055em; }.plan-view .page-head p { max-width: 650px; color: var(--muted); line-height: 1.65; }.plan-view .eyebrow { color: var(--accent); font-family: var(--font-mono); font-size: 11px; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } +.plan-message { margin: 18px 0; padding: 12px 14px; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft); color: var(--ink); }.plan-message.quiet { border-color: var(--line-strong); background: var(--panel-soft); color: var(--muted); } +.plan-current { display: flex; align-items: center; gap: 32px; margin-top: 24px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.plan-current > div { display: grid; gap: 3px; text-transform: capitalize; }.plan-current span { color: var(--muted); font-size: 11px; }.plan-current .soft-btn { margin-left: auto; } +.plan-usage { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 14px; }.plan-usage > div { display: grid; grid-template-columns: 1fr auto; gap: 6px 10px; padding: 14px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); text-transform: capitalize; }.plan-usage span { color: var(--muted); font-size: 11px; }.plan-usage progress { grid-column: 1 / -1; width: 100%; accent-color: var(--accent); } +.plan-billing-control { margin: 28px auto 14px; text-align: center; }.plan-toggle { display: flex; width: max-content; margin: 0 auto 7px; padding: 3px; border: 1px solid var(--line); border-radius: 8px; }.plan-toggle button { min-width: 100px; border: 0; border-radius: 5px; padding: 8px 14px; background: transparent; color: var(--muted); cursor: pointer; }.plan-toggle button span { display: block; margin-top: 2px; color: #277249; font: 700 8px/1 var(--font-mono); }.plan-toggle button.on { background: var(--accent); color: var(--on-accent); }.plan-toggle button.on span { color: var(--accent-soft); }.plan-billing-control > p { margin: 0; color: var(--muted); font-size: 11px; }.plan-billing-control strong { color: var(--accent-strong); }.plan-billing-control span { color: var(--faint); } +.plan-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.plan-options--two { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 720px; }.plan-options article { display: flex; flex-direction: column; min-width: 0; padding: 20px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); }.plan-options article.featured { border: 2px solid var(--accent); }.plan-options article > b { min-height: 34px; color: var(--accent); font-size: 11px; text-transform: uppercase; }.plan-options h2 { margin: 4px 0 3px; font-size: 25px; letter-spacing: -.04em; }.plan-options .plan-price-note { min-height: 36px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }.annual-save { display: inline-block; width: max-content; margin-left: 4px; padding: 2px 4px; background: #e3f3e8; color: #247643 !important; font: 700 9px/1.2 var(--font-mono); }.plan-feature-list { flex: 1; display: grid; align-content: start; gap: 8px; margin: 16px 0; padding: 0; list-style: none; }.plan-feature-list li { display: flex; align-items: flex-start; gap: 7px; color: var(--muted); font-size: 12px; line-height: 1.35; }.plan-feature-list svg { width: 14px; height: 14px; flex: 0 0 auto; stroke: var(--accent); }.plan-options button { width: 100%; min-height: 40px; }.plan-team-inputs { display: grid; grid-template-columns: 1fr 58px; gap: 6px; margin-bottom: 8px; }.plan-team-inputs input { width: 100%; min-width: 0; height: 38px; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--panel-soft); color: var(--ink); padding: 7px 8px; } +@media (max-width: 760px) { .learning-summary, .quiz-callout, .history-split, .study-layout, .quiz-split { grid-template-columns: 1fr; }.learning-card, .quiz-row { align-items: flex-start; flex-direction: column; }.history-row { grid-template-columns: 1fr; gap: 7px; }.history-row .tag { width: max-content; } } + /* ================= toast ================= */ -.toast { position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); background: var(--ink); color: #fff; border-radius: 10px; padding: 10px 18px; font-family: -apple-system, sans-serif; font-size: 13px; z-index: 60; } +.toast { position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); background: var(--accent-strong); color: var(--on-accent); border: 1px solid var(--accent); border-radius: 8px; padding: 10px 18px; font-family: var(--font-sans); font-size: 13px; z-index: 60; box-shadow: 4px 4px 0 rgba(33, 23, 47, .18); } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } @@ -474,11 +865,15 @@ h1 { font-size: 30px; font-weight: 500; letter-spacing: -0.01em; } .fade-stagger .stats, .fade-stagger .rail-card, .fade-stagger .stub, - .fade-stagger .two { opacity: 1 !important; transform: none !important; } + .fade-stagger .two, + .quiz-card, + .quiz-options button, + .quiz-result, + .history-detail { opacity: 1 !important; transform: none !important; } } @media (max-width: 760px) { - .side { width: 76px; padding-left: 8px; padding-right: 8px; }.brand .name, .brand .badge, .nav button { font-size: 0; }.nav button svg { margin: 0; width: 19px; height: 19px; }.promo, .sync-state { display: none; }.page { padding: 32px 24px 52px; }.rail { width: 230px; }.tiles { grid-template-columns: repeat(2, 1fr); }.modal { width: min(94vw, 760px); }.modal .mside { width: 160px; }.delete-confirm { max-width: 150px; } + .side { width: 76px; padding-left: 8px; padding-right: 8px; }.brand .name, .brand .badge, .nav button { font-size: 0; }.nav button svg { margin: 0; width: 19px; height: 19px; }.promo, .sync-state { display: none; }.page { padding: 32px 24px 52px; }.rail { width: 230px; }.tiles { grid-template-columns: repeat(2, 1fr); }.modal { width: min(94vw, 760px); }.modal .mside { width: 160px; }.delete-confirm { max-width: 150px; }.plan-options { grid-template-columns: 1fr; }.plan-usage { grid-template-columns: 1fr; }.plan-current { align-items: stretch; flex-direction: column; gap: 10px; }.plan-current .soft-btn { margin-left: 0; } } @media (max-width: 560px) { diff --git a/app/src/renderer/companion/companion.html b/app/src/renderer/companion/companion.html index 236c214..02f829b 100644 --- a/app/src/renderer/companion/companion.html +++ b/app/src/renderer/companion/companion.html @@ -4,9 +4,15 @@ Unvibe + + + diff --git a/app/src/renderer/companion/companion.tsx b/app/src/renderer/companion/companion.tsx index e6e40ec..2a8b1a9 100644 --- a/app/src/renderer/companion/companion.tsx +++ b/app/src/renderer/companion/companion.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { createRoot } from 'react-dom/client'; import { LogoMark } from '../shared/logo'; +import { RichText } from '../shared/richText'; -type PageId = 'Home' | 'Progress' | 'Projects' | 'Study' | 'Concepts' | 'Notebook' | 'Briefings' | 'Library' | 'Profile'; +type PageId = 'Home' | 'Study' | 'History' | 'Quiz' | 'Progress' | 'Plan' | 'Projects' | 'Concepts' | 'Notebook' | 'Briefings' | 'Library' | 'Profile'; interface Feature { icon: string; t: string; d: string } interface PageDef { id: PageId; icon: string; lead: string; features: Feature[] } @@ -16,6 +17,19 @@ interface Profile { usage: Array<{ label: string; pct: number }>; heat: number[]; } interface FeedItem { id: string; ts: string; title: string; meta: string; outcome: string } +interface LearningItem extends FeedItem { + concept?: string; level: string; lines: number; + file?: string; project?: string; scope?: string; dueLabel?: string; + language?: string; code?: string; explanation?: string; +} + +const STUDY_LEVELS = [ + { id: 'new', label: 'New' }, + { id: 'beginner', label: 'Beginner' }, + { id: 'intermediate', label: 'Intermediate' }, + { id: 'advanced', label: 'Advanced' }, + { id: 'expert', label: 'Expert' }, +] as const; interface SyncStatus { phase: 'local' | 'syncing' | 'synced' | 'offline' | 'auth_required' | 'error'; pending: number; lastSyncedAt?: string; nextRetryAt?: string; message?: string; @@ -26,13 +40,29 @@ interface Settings { widgetOpacityInactive: number; inactiveBehavior: string; launchAtLogin: boolean; theme: 'system' | 'light' | 'dark'; notifications: boolean; quietHours: { enabled: boolean; start: string; end: string }; + useOwnAi: boolean; + aiProvider: 'gemini' | 'anthropic' | 'openai' | 'grok' | 'deepseek' | 'kimi'; +} +interface BillingOverview { + workspace: { id: string; name: string; type: 'personal' | 'team'; role: string }; + subscription: { plan: 'free' | 'pro' | 'teams'; interval: 'monthly' | 'annual' | null; status: string; seats: number; currentPeriodEnd?: string }; + usage: Array<{ kind: string; used: number; limit: number; remaining: number; resetsAt: string }>; + canManageBilling: boolean; + hasBillingAccount: boolean; } +const PLAN_FEATURES = { + free: ['50 explanations each month', '1 active project', 'Core explanation levels', 'Selected-code explanations', 'No credit card required'], + pro: ['100 explanations each month', 'Git diff + agent change briefs', 'Nearby-file context', 'Since-last-understood compares', 'Expert explanations'], +} as const; + const IC = { home: 'M3 9.5 10 3l7 6.5V17H3z M8 17v-5h4v5', progress: 'M4 16V9 M10 16V4 M16 16v-6', projects: 'M3 6a1 1 0 0 1 1-1h4l2 2h6a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z', study: 'M4 4h9a3 3 0 0 1 3 3v9a3 3 0 0 0-3-3H4z M4 4v9', + history: 'M10 3a7 7 0 1 0 7 7 M10 6v4l3 2', + quiz: 'M10 3a7 7 0 1 0 7 7 M8.2 8.1a2 2 0 1 1 3.4 1.4c-.8.7-1.6 1.1-1.6 2.3 M10 15h.01', concepts: 'M10 3l2.1 4.9L17 10l-4.9 2.1L10 17l-2.1-4.9L3 10l4.9-2.1z', notebook: 'M5 3h9a1 1 0 0 1 1 1v13l-3-2-3 2-3-2V4a1 1 0 0 1 1-1z M8 7h5 M8 10h5', briefings: 'M5 3h10v14H5z M8 7h4 M8 10h4 M8 13h2', @@ -44,9 +74,10 @@ const IC = { check: 'M4 10l4 4 8-9', clock: 'M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16z M10 6v4l3 2', map: 'M3 5l5-2 4 2 5-2v12l-5 2-4-2-5 2z M8 3v12 M12 5v12', + plan: 'M3 5h14v10H3z M3 8h14 M6 12h3', }; -const PAGES: Record, PageDef> = { +const PAGES: Record, PageDef> = { Projects: { id: 'Projects', icon: IC.projects, lead: 'Every repository you point Unvibe at, distilled into something you can actually hold in your head.', features: [ { icon: IC.eye, t: 'Plain-English summaries', d: 'What each repo is for and how it earns its keep — no folder-tree dumps.' }, { icon: IC.layers, t: 'How it fits together', d: 'The moving parts and where they connect, so a new codebase stops feeling like a maze.' }, @@ -92,7 +123,12 @@ const PAGES: Record, PageDef> = { }; const NAV: Array<{ id: PageId; icon: string }> = [ - { id: 'Home', icon: IC.home }, { id: 'Progress', icon: IC.progress }, + { id: 'Home', icon: IC.home }, + { id: 'Study', icon: IC.study }, + { id: 'History', icon: IC.history }, + { id: 'Quiz', icon: IC.quiz }, + { id: 'Progress', icon: IC.progress }, + { id: 'Plan', icon: IC.plan }, ]; const FOOT: Array<{ id: string; icon: string; toast: string }> = [ @@ -158,9 +194,9 @@ function SignInForm({ onDone }: { onDone: (email: string) => void }) { }; return (
- + {err &&
{err}
} -
{code ? `A secure browser window is open. Enter code ${code} after signing in.` : 'We open your browser so Supabase can verify your account. This app never sees your password.'}
+
{code ? `Browser open — sign in with Google, then approve code ${code}.` : 'Opens your browser for Google sign-in. Unvibe never sees your Google password.'}
); } @@ -181,7 +217,7 @@ function PermRow({ compact }: { compact?: boolean }) { {na ? 'N/A' : granted ? 'Granted' : 'Not granted'} Accessibility
-
Lets Unvibe read the code you have selected in another app when you press the shortcut. Without it, Unvibe falls back to explaining whatever you last copied.
+
Lets Unvibe read the code you have selected in another app when you press the shortcut. Without it, Unvibe falls back to explaining whatever you last copied. If the toggle is already on, quit Unvibe fully and reopen it — macOS only applies the grant to the next launch.
{!granted && !na && (
@@ -204,7 +240,8 @@ function Onboarding({ shortcut, account, onDone, onSignedIn }: { shortcut: strin const [level, setLevel] = useState('intermediate'); const [theme, setTheme] = useState<'light' | 'dark'>('light'); useEffect(() => { window.unvibe.onShortcutFired(() => setFired(true)); }, []); - const steps = ['Welcome', 'Account', 'How it works', 'Permission', 'Shortcut', 'Overlay', 'Select code', 'Your level', 'First explanation', 'Done']; + // Product tour first; account / "keep it local" is near the end so local users still learn the loop. + const steps = ['Welcome', 'How it works', 'Permission', 'Shortcut', 'Overlay', 'Select code', 'Your level', 'First explanation', 'Account', 'Done']; const next = () => setStep((s) => Math.min(s + 1, steps.length - 1)); const back = () => setStep((s) => Math.max(s - 1, 0)); @@ -229,27 +266,18 @@ function Onboarding({ shortcut, account, onDone, onSignedIn }: { shortcut: strin )} {step === 1 && ( - <> -

Keep it on this Mac—or sync it

-

An account syncs your learning across devices. Local-only keeps every record on this Mac.

- {account ?
✓ Signed in as {account.email}
: onSignedIn()} />} - {nav(account ? 'Continue' : 'Keep it local')} - - )} - - {step === 2 && ( <>

A quieter way to learn code

  • Select code in Cursor, VS Code, a terminal, or a browser.
  • Press {prettyAccel(shortcut)} — a floating explanation appears beside your work.
  • -
  • Keep what you learn — every review builds your streak, concepts, and progress.
  • +
  • Keep what you learn — every review builds your streak, concepts, and progress on this Mac.
{nav()} )} - {step === 3 && ( + {step === 2 && ( <>

One permission

@@ -257,7 +285,7 @@ function Onboarding({ shortcut, account, onDone, onSignedIn }: { shortcut: strin )} - {step === 4 && ( + {step === 3 && ( <>

Try your shortcut

Press {prettyAccel(shortcut)} now. A floating widget should appear — that is where explanations live. You can change this shortcut any time in Settings.

@@ -266,16 +294,21 @@ function Onboarding({ shortcut, account, onDone, onSignedIn }: { shortcut: strin )} - {step === 5 && ( + {step === 4 && ( <> -

Put the overlay where it helps

-

You can drag and pin explanations later. This only chooses where the small activation bar begins.

-
{ setPosition('bottom-center'); void window.unvibe.setSettings({ barPosition: 'bottom-center' }); }} /> { setPosition('top-right'); void window.unvibe.setSettings({ barPosition: 'top-right' }); }} />
+

Put the bar where it helps

+

Default is bottom center. Pick another corner anytime — same choices live in Settings → Overlay.

+
+ { setPosition('bottom-center'); void window.unvibe.setSettings({ barPosition: 'bottom-center' }); }} /> + { setPosition('top-center'); void window.unvibe.setSettings({ barPosition: 'top-center' }); }} /> + { setPosition('top-right'); void window.unvibe.setSettings({ barPosition: 'top-right' }); }} /> + { setPosition('bottom-right'); void window.unvibe.setSettings({ barPosition: 'bottom-right' }); }} /> +
{nav()} )} - {step === 6 && ( + {step === 5 && ( <>

Start with a small selection

Highlight a function, a condition, or a confusing line. Unvibe only uses the exact filtered snippet you choose.

@@ -284,7 +317,7 @@ function Onboarding({ shortcut, account, onDone, onSignedIn }: { shortcut: strin )} - {step === 7 && ( + {step === 6 && ( <>

Choose how deep to go

Every explanation can change level later.

@@ -293,7 +326,7 @@ function Onboarding({ shortcut, account, onDone, onSignedIn }: { shortcut: strin )} - {step === 8 && ( + {step === 7 && ( <>

Your first explanation lives beside your work

Why this condition?The check keeps underage users out of an adult-only flow.
{level} · Save · Test me
@@ -302,11 +335,20 @@ function Onboarding({ shortcut, account, onDone, onSignedIn }: { shortcut: strin )} + {step === 8 && ( + <> +

Keep it on this Mac—or sync it

+

An account syncs your learning across devices. Local-only keeps every record on this Mac — you already know how the product works either way.

+ {account ?
✓ Signed in as {account.email}
: onSignedIn()} />} + {nav(account ? 'Continue' : 'Keep it local')} + + )} + {step === 9 && ( <>

You're set

-

Select code anywhere and press {prettyAccel(shortcut)}. Your progress collects in this dashboard.

+

Select code anywhere and press {prettyAccel(shortcut)}. Your progress collects here — kept on this Mac until you choose to sync.

)} @@ -316,7 +358,7 @@ function Onboarding({ shortcut, account, onDone, onSignedIn }: { shortcut: strin ); } -function LoginScreen({ onSignedIn, onSkip }: { onSignedIn: (email: string) => void; onSkip: () => void }) { +function LoginScreen({ onSignedIn, onSkip, shortcut }: { onSignedIn: (email: string) => void; onSkip: () => void; shortcut: string }) { return (
@@ -325,9 +367,10 @@ function LoginScreen({ onSignedIn, onSkip }: { onSignedIn: (email: string) => vo
Unvibe

Understand everything you ship.

-
Select code anywhere
-
Press your shortcut
-
Understand and keep it
+
1Select code anywhere
+
2Press {prettyAccel(shortcut)}
+
3Read the explanation beside your work
+
4Save it on this Mac — sync later if you want
@@ -336,16 +379,58 @@ function LoginScreen({ onSignedIn, onSkip }: { onSignedIn: (email: string) => vo ); } -function Home({ user, shortcut, profile, feed }: { user: string; shortcut: string; profile: Profile | null; feed: FeedItem[] }) { +function UsageChip({ usage, onPlan, compact = false }: { + usage: { used: number; limit: number; remaining: number; resetsAt: string; plan?: string } | null; + onPlan: () => void; + compact?: boolean; +}) { + if (!usage) return null; + const low = usage.remaining <= 5; + const out = usage.remaining <= 0; + const planLabel = usage.plan === 'pro' ? 'Pro' : usage.plan === 'teams' ? 'Teams' : 'Free'; + return ( + + ); +} + +function Home({ shortcut, profile, feed, usage, onPlan }: { + shortcut: string; + profile: Profile | null; + feed: FeedItem[]; + usage: { used: number; limit: number; remaining: number; resetsAt: string } | null; + onPlan: () => void; +}) { return ( <> -

Hello again, {user}

{user[0]?.toUpperCase() ?? 'U'}
+
+

Keep it local.

+
+ {usage && usage.remaining <= 0 && ( +
+
+ You have reached your monthly explanation limit. +

Your saved history and projects remain available. Allowance resets on {new Date(usage.resetsAt).toLocaleDateString(undefined, { month: 'long', day: 'numeric' })}.

+
+ +
+ )}

Understand everything you ship.

Highlight code in any app and Unvibe explains it right where you are working — pitched to how much you already know, and quiet until you ask.

-
or press {shortcut} anywhere
+
+ + or press {shortcut} anywhere +
LATELY
{feed.length === 0 ? ( @@ -412,6 +497,353 @@ function Progress({ profile }: { profile: Profile | null }) { ); } +function outcomeName(outcome: string): string { + return outcome === 'understood' ? 'Understood' : outcome === 'needs_review' ? 'To revisit' : 'Reviewed'; +} + +function LessonCode({ code, language }: { code: string; language?: string }) { + return ( +
+
{language || 'code'}{code.split('\n').length} lines
+
{code}
+
+ ); +} + +function Study({ history, queue, shortcut, onReview, onRestudy, onRefresh }: { + history: LearningItem[]; queue: LearningItem[]; shortcut: string; onReview: () => void; + onRestudy: (item: LearningItem, level: string) => void | Promise; + onRefresh: () => void | Promise; +}) { + const lessons = history.filter((item) => Boolean(item.code)); + const [selectedId, setSelectedId] = useState(lessons[0]?.id ?? queue[0]?.id ?? null); + const selected = history.find((item) => item.id === selectedId) ?? queue.find((item) => item.id === selectedId) ?? null; + const [level, setLevel] = useState(selected?.level || 'intermediate'); + const [question, setQuestion] = useState(''); + const [answer, setAnswer] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [askLeft, setAskLeft] = useState(null); + + useEffect(() => { + void window.unvibe.studyAskStatus().then((s) => { + const status = s as { remaining: number }; + setAskLeft(status.remaining); + }); + }, [selectedId]); + + useEffect(() => { + if (selected?.level) setLevel(selected.level); + }, [selected?.id, selected?.level]); + + const ask = async () => { + if (!selected) return; + setBusy(true); setError(''); setAnswer(''); + const result = await window.unvibe.studyAsk({ eventId: selected.id, question }) as { ok: boolean; answer?: string; error?: string; remaining?: number }; + setBusy(false); + if (result.remaining !== undefined) setAskLeft(result.remaining); + if (!result.ok) { setError(result.error ?? 'Could not ask.'); return; } + setAnswer(result.answer ?? ''); + setQuestion(''); + void onRefresh(); + }; + + const revisit = queue.filter((item) => item.outcome === 'needs_review').length; + const catalog = lessons.length > 0 ? lessons : queue; + + return <> +

Study

+

Everything you have already reviewed stays here. Re-open the code, pick a level again, and ask a short follow-up when you get stuck.

+ {catalog.length === 0 ? : ( +
+ +
+ {!selected ?

Pick a lesson from the left.

: <> +
+
+ {outcomeName(selected.outcome)} +

{selected.title}

+

{selected.meta || `${selected.lines} lines · ${selected.level}`}

+
+
+ {selected.code ? :

No saved code on this item yet — restudy will try to reopen the file.

} + {selected.explanation ?
Last explanation
: null} +
+ Restudy level +
+ {STUDY_LEVELS.map((opt) => ( + + ))} +
+ +
+
+
+ Study assistant + {askLeft === null ? '' : `${askLeft} questions left today`} +
+

Ask about this lesson only — short clarifying questions work best.

+