Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
dbe57df
Redesign marketing site around pixelated learning
ShadowEsu Jul 16, 2026
2651128
feat(marketing): make waitlist durable and observable
ShadowEsu Jul 16, 2026
44134a5
fix(marketing): encrypt waitlist storage at rest
ShadowEsu Jul 16, 2026
3df656e
fix(marketing): make email delivery retryable
ShadowEsu Jul 16, 2026
83d79dc
feat(marketing): animate product story and clarify free pricing
ShadowEsu Jul 16, 2026
eca9bc7
Align desktop app with Unvibe brand
ShadowEsu Jul 16, 2026
e520c7e
feat: add Free Pro and Teams billing
ShadowEsu Jul 16, 2026
d7a1fe7
fix(marketing): document durable waitlist setup
ShadowEsu Jul 16, 2026
145169a
fix(marketing): align canonical production domain
ShadowEsu Jul 16, 2026
dfcbaf7
docs: record overnight repository health audit
ShadowEsu Jul 16, 2026
f0a03ba
feat: add connected learning destinations
ShadowEsu Jul 16, 2026
55ac743
feat: clarify annual pricing across product
ShadowEsu Jul 16, 2026
71fbff8
docs: add launch configuration handoff
ShadowEsu Jul 16, 2026
2f32b6c
fix(marketing): clarify waitlist storage failures
ShadowEsu Jul 17, 2026
451c5d5
Keep canonical investor decks and drop scratch PDF builds.
ShadowEsu Jul 18, 2026
9f58dc8
Ship Pro annual pricing, Free/Pro surfaces, and waitlist reliability.
ShadowEsu Jul 18, 2026
149ea84
fix(marketing): read waitlist blob via download URL to avoid stale CD…
ShadowEsu Jul 18, 2026
fe460ba
fix(marketing): store waitlist signups as per-entry blobs
ShadowEsu Jul 18, 2026
4033fb4
fix(marketing): lock waitlist alerts to founder inbox
ShadowEsu Jul 18, 2026
b64e690
fix(app): verify integrity in local DMG packages
ShadowEsu Jul 18, 2026
88b3180
fix(marketing): clarify Free Pro and annual pricing
ShadowEsu Jul 18, 2026
d22b899
feat(marketing): add standalone investor materials page
ShadowEsu Jul 18, 2026
39c9cf2
marketing: frame private beta as ready with waitlist and email access
ShadowEsu Jul 18, 2026
d690837
marketing: align mobile nav waitlist CTA copy
ShadowEsu Jul 18, 2026
c8395a3
feat(marketing): feature YC applicant status for investors
ShadowEsu Jul 19, 2026
08f73b2
feat(marketing): feature YC application on landing page
ShadowEsu Jul 19, 2026
32f9d06
copy(marketing): wish luck for YC application
ShadowEsu Jul 19, 2026
59ac76e
feat(marketing): add local browser code workspace
ShadowEsu Jul 19, 2026
23fd656
fix(marketing): slim YC application announcement
ShadowEsu Jul 19, 2026
83d4358
feat: complete product and marketing updates
ShadowEsu Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
*.dmg
dist-test/
release/
.env
2 changes: 1 addition & 1 deletion app/PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Binary file modified app/build/icon.icns
Binary file not shown.
Binary file modified app/build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 20 additions & 4 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -52,6 +65,9 @@
"LSUIElement": false
}
},
"dmg": { "sign": false, "artifactName": "Unvibe-${version}-${arch}-unsigned.${ext}" }
"dmg": {
"sign": false,
"artifactName": "Unvibe-${version}-${arch}-unsigned.${ext}"
}
}
}
26 changes: 24 additions & 2 deletions app/scripts/package-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 });
}
Expand All @@ -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`);
4 changes: 3 additions & 1 deletion app/scripts/verify-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
135 changes: 135 additions & 0 deletions app/src/core/gitDiff.ts
Original file line number Diff line number Diff line change
@@ -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<DiffHunk[]> {
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<DiffHunk[]> {
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<string | null> {
try {
const { stdout } = await pexecFile('git', ['rev-parse', '--show-toplevel'], { cwd: startDir });
const root = stdout.trim();
return root || null;
} catch {
return null;
}
}
94 changes: 86 additions & 8 deletions app/src/core/learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -186,14 +210,68 @@ const OUTCOME_LABEL: Record<Outcome, string> = {
};

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<LocalEvent & { dueLabel: string }> = [];
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));
}
Loading