Make Thinkloom native-only

This commit is contained in:
2026-07-17 07:58:10 -07:00
parent 1d42788505
commit 7866081c78
38 changed files with 4147 additions and 14787 deletions
+6 -46
View File
@@ -1,48 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
next-env.d.ts
/dist/
/.wrangler/
/outputs/
/work/
# Thinkloom generated artifacts
/desktop-dist/
/node_modules/
/dist/
/src-tauri/target/
*.tsbuildinfo
.env*
npm-debug.log*
.DS_Store
/.wrangler/
-5
View File
@@ -1,5 +0,0 @@
{
"project_id": "appgprj_6a59ce11a2688191a982245d4f9a82db",
"d1": null,
"r2": null
}
+11 -12
View File
@@ -1,26 +1,27 @@
# Thinkloom implementation status
Status date: 2026-07-16
Status date: 2026-07-17
## Completed locally
- M0 engineering baseline: shared React/TypeScript interface, Tauri 2 shell, typed command errors, Windows packaging configuration, browser/Sites companion, build/type/lint/unit checks.
- M1 project and provenance foundation: project layout, SQLite schema, atomic canonical writes, rotating snapshots, SHA-256 event chain, chain verification, hidden Git checkpoints, canonical rebuild inputs.
- M2 provider/privacy boundary: Ollama, OpenAI and compatible request paths; OS vault secrets; provider test; local/cloud status; first-cloud-use approval; retry-preserving failure state.
- M3 ideation and idea curation: typed turns, one focused question, challenge modes, suggestion review, direct save, edits, variants, merge, tags, archive/rejected history, drafting sets.
- M4 drafting workspace: TipTap structured editor, dominant three-panel layout, selective context, staged generation, all acceptance destinations, partial acceptance, manual editing, undo/redo, autosave.
- M5 finalization/style/history: editorial action previews, editable style traits and disallowed habits, saved versions, comparison summary, restore, release checkpoints, provenance timeline and contribution relationships.
- M7 export/backup/evidence: Markdown, HTML, PDF, text, backup ZIP, evidence ZIP, sanitization disclosure, hashes, manifest, atomic finalization, strict ZIP import validation.
- M0 engineering baseline: native-only React/TypeScript interface, Tauri 2 shell, typed command errors, Windows packaging configuration, and build/type/lint/unit checks.
- M1 project and provenance foundation: project layout, SQLite schema, atomic canonical writes, rotating snapshots, SHA-256 event chain, chain verification, hidden Git checkpoints, and canonical rebuild inputs.
- M2 provider/privacy boundary: Ollama, OpenAI and compatible request paths; OS vault secrets; provider test; local/cloud status; first-cloud-use approval; and retry-preserving failure state.
- M3 ideation and idea curation: typed turns, one focused question, challenge modes, suggestion review, direct save, edits, variants, merge, tags, archive/rejected history, and drafting sets.
- M4 drafting workspace: TipTap structured editor, dominant three-panel layout, selective context, staged generation, all acceptance destinations, partial acceptance, manual editing, undo/redo, and autosave.
- M5 finalization/style/history: editorial action previews, editable style traits and disallowed habits, saved versions, comparison summary, restore, release checkpoints, provenance timeline, and contribution relationships.
- M7 export/backup/evidence: Markdown, HTML, PDF, text, backup ZIP, evidence ZIP, sanitization disclosure, hashes, manifest, atomic finalization, and strict ZIP import validation.
- Product surface: Sites, Next, Vinext, Cloudflare, and browser-companion code and configuration have been removed. Tauri is the sole application target.
## External release gates
These items cannot be honestly certified from this workspace alone and need project-owner decisions or assets:
1. Voice runtime: provide/approve the faster-whisper model size and Silero VAD ONNX model distribution. The interface currently uses ephemeral browser speech recognition as a functional fallback and retains no audio, but the required bundled local voice pipeline is not yet present.
1. Voice runtime: provide or approve the faster-whisper model size and Silero VAD ONNX model distribution. The interface currently uses ephemeral webview speech recognition as a functional fallback and retains no audio, but the required bundled local voice pipeline is not yet present.
2. Local-model acceptance baseline: confirm the Ollama model used for release testing. The implementation default is `llama3.2`.
3. Signing and distribution: provide the Windows code-signing certificate and choose the installer distribution channel. MSI/NSIS configuration is present, but signed release artifacts cannot be produced without credentials.
4. Secondary platforms: decide whether macOS and Linux are part of the first signed release. Their keychain, microphone, installer, and packaging gates require platform runners.
5. PDF typography: the native exporter creates a valid dependency-free PDF. Approve whether the release should instead bundle a browser-print engine for richer typography.
5. PDF typography: the native exporter creates a valid dependency-free PDF. Approve whether the release should instead bundle a native print engine for richer typography.
6. Diagnostics policy: confirm whether opt-in diagnostic reports are allowed. Current diagnostics are local, redacted, and exclude prompts, responses, credentials, and audio.
## Remaining hardening
@@ -28,5 +29,3 @@ These items cannot be honestly certified from this workspace alone and need proj
- Run microphone/VAD/transcription integration tests once the approved model assets are supplied.
- Run signed clean-install, upgrade, downgrade-warning, and uninstall tests on each release platform.
- Run full 20,000-word interaction and fault-injection profiling on packaged release hardware.
- Resolve the upstream moderate PostCSS advisory inherited by the pinned Next/Vinext preview toolchain when a compatible patch is available. Native packaged assets do not execute the Sites server dependency.
- Deploy the Sites companion after a deployment credential is issued by the connected Sites service.
+6 -10
View File
@@ -1,16 +1,13 @@
# Thinkloom
Thinkloom is a desktop-first, local-first writing studio that helps one writer move from conversation to ideas, draft, revision, and a versioned release while preserving an inspectable creative-process record.
Thinkloom is a native, local-first writing studio that helps one writer move from conversation to ideas, draft, revision, and a versioned release while preserving an inspectable creative-process record.
This repository contains two builds of the same React interface:
- a Tauri 2 desktop application backed by Rust, SQLite, canonical Markdown/JSON/JSONL files, hidden Git checkpoints, operating-system credentials, and atomic exports;
- a Sites companion build for product preview and browser-based evaluation.
The application is built with Tauri 2, React, TypeScript, Rust, SQLite, canonical Markdown/JSON/JSONL files, hidden Git checkpoints, operating-system credentials, and atomic exports. There is no hosted or browser companion build.
## Implemented workflow
- Reversible Ideation, Drafting, and Finalization phases
- Typed conversation, challenge levels, push-to-talk browser transcription, and optional visible speech output
- Typed conversation, challenge levels, push-to-talk transcription, and optional visible speech output
- Suggested ideas with explicit accept/reject, editing, variants, archiving, source links, drafting sets, and merges
- TipTap/ProseMirror structured manuscript editor with canonical Markdown round-tripping, undo/redo, headings, lists, selection replacement, and cursor insertion
- Persisted preview-first generation states with retry-safe provider errors and partial acceptance
@@ -30,9 +27,8 @@ Requirements: Node.js 22.13 or newer, Rust 1.77.2 or newer, Git, and the Windows
```powershell
npm install
npm run dev
npm run desktop:dev
npm run build
npm run desktop:build
npm run tauri -- dev
npm run tauri -- build
```
@@ -49,10 +45,10 @@ cargo test
## Project storage
A desktop project is self-contained. Canonical files live under `manuscript/`, `ideas/`, `conversations/`, `provenance/`, and `style/`. Live SQLite state and rotating snapshots are under `.thinkloom/` and are excluded from the projects hidden Git history. Audio retention is always false; no audio file extension is created by the native service.
A Thinkloom project is self-contained. Canonical files live under `manuscript/`, `ideas/`, `conversations/`, `provenance/`, and `style/`. Live SQLite state and rotating snapshots are under `.thinkloom/` and are excluded from the project's hidden Git history. Audio retention is always false; no audio file extension is created by the native service.
## Provider setup
Ollama defaults to `http://127.0.0.1:11434` and model `llama3.2`. OpenAI and compatible credentials are entered in Settings and saved through Windows Credential Manager, macOS Keychain, or Linux Secret Service. The first cloud operation in each project requires explicit approval.
See [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) for release gates that require external models, signing credentials, or additional platform validation.
See [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) for release gates that require external models, signing credentials, or additional platform validation.
-86
View File
@@ -1,86 +0,0 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
-20
View File
@@ -1,20 +0,0 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost";
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
const image = new URL("/og.png", `${protocol}://${host}`).toString();
return {
title: "Thinkloom — ideas into writing",
description: "A local-first writing studio for exploring ideas, shaping drafts, and preserving your creative process.",
icons: { icon: "/icon.png", shortcut: "/icon.png", apple: "/icon.png" },
openGraph: { title: "Thinkloom — ideas into writing", description: "Explore, shape, and publish thoughtful writing without losing the thread of how it came together.", type: "website", images: [{ url: image, width: 1732, height: 909, alt: "Thinkloom — ideas into writing, without losing the thread." }] },
twitter: { card: "summary_large_image", images: [image] },
};
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="en"><body>{children}</body></html>; }
-2
View File
@@ -1,2 +0,0 @@
import Thinkloom from "./thinkloom";
export default function Home() { return <Thinkloom />; }
-45
View File
@@ -1,45 +0,0 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}
-7
View File
@@ -1,7 +0,0 @@
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
/** Create a typed database client from the D1 binding supplied by the worker. */
export function getDb(binding: D1Database) {
return drizzle(binding, { schema });
}
-4
View File
@@ -1,4 +0,0 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f1e9" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'" />
<title>Thinkloom</title>
</head>
<body><div id="root"></div><script type="module" src="/main.tsx"></script></body>
</html>
-6
View File
@@ -1,6 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import Thinkloom from "../app/thinkloom";
import "../app/globals.css";
createRoot(document.getElementById("root")!).render(<React.StrictMode><Thinkloom /></React.StrictMode>);
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});
-5
View File
@@ -1,5 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}
+22 -18
View File
@@ -1,18 +1,22 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**", "src-tauri/target/**", "node_modules/**"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}", "vite.config.ts"],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
plugins: { "react-hooks": reactHooks },
rules: { ...reactHooks.configs.recommended.rules },
},
{
files: ["tests/**/*.mjs"],
languageOptions: { globals: globals.node },
},
);
-58
View File
@@ -1,58 +0,0 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db.insert(notes).values({ title, content }).returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
-9
View File
@@ -1,9 +0,0 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f1e9" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'" />
<link rel="icon" href="/icon.png" />
<title>Thinkloom — ideas into writing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+3906 -12198
View File
File diff suppressed because it is too large Load Diff
+38 -51
View File
@@ -1,51 +1,38 @@
{
"name": "thinkloom",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "vinext dev",
"build": "vinext build",
"start": "vinext start",
"test": "npm run typecheck && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern desktop-dist --ignore-pattern src-tauri/target",
"db:generate": "drizzle-kit generate",
"typecheck": "tsc --noEmit",
"desktop:dev": "vite --config vite.desktop.config.ts",
"desktop:build": "vite build --config vite.desktop.config.ts",
"tauri": "tauri"
},
"dependencies": {
"@tiptap/markdown": "^3.28.0",
"@tiptap/pm": "^3.28.0",
"@tiptap/react": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0",
"drizzle-orm": "0.45.2",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@cloudflare/workers-types": "^4.20260702.1",
"@tailwindcss/postcss": "4.2.1",
"@tauri-apps/cli": "^2.11.4",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-config-next": "16.2.6",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"vinext": "0.0.50",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}
{
"name": "thinkloom",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=22.13.0" },
"scripts": {
"dev": "vite --port 1420 --strictPort",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src tests vite.config.ts",
"test": "npm run typecheck && node tests/native-app.test.mjs",
"tauri": "tauri"
},
"dependencies": {
"@tiptap/markdown": "^3.28.0",
"@tiptap/pm": "^3.28.0",
"@tiptap/react": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@eslint/js": "9.39.4",
"@tauri-apps/cli": "^2.11.4",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"eslint": "9.39.4",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "14.0.0",
"typescript": "5.9.3",
"typescript-eslint": "8.59.3",
"vite": "8.0.13"
}
}
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-6
View File
@@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 718 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

File diff suppressed because it is too large Load Diff
+44 -44
View File
@@ -1,44 +1,44 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Thinkloom",
"version": "0.1.0",
"identifier": "com.thinkloom.desktop",
"build": {
"beforeDevCommand": "npm run desktop:dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run desktop:build",
"frontendDist": "../desktop-dist"
},
"app": {
"windows": [
{
"title": "Thinkloom",
"width": 1440,
"height": 920,
"minWidth": 960,
"minHeight": 680,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'"
}
},
"bundle": {
"active": true,
"targets": [
"msi",
"nsis"
],
"shortDescription": "Ideas into writing, without losing the thread.",
"longDescription": "A local-first writing studio for exploring ideas, shaping drafts, and preserving creative process history.",
"category": "Productivity",
"copyright": "Copyright © 2026 Thinkloom",
"icon": [
"icons/icon.ico",
"icons/icon.png"
]
}
}
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Thinkloom",
"version": "0.1.0",
"identifier": "com.thinkloom.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Thinkloom",
"width": 1440,
"height": 920,
"minWidth": 960,
"minHeight": 680,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'"
}
},
"bundle": {
"active": true,
"targets": [
"msi",
"nsis"
],
"shortDescription": "Ideas into writing, without losing the thread.",
"longDescription": "A local-first writing studio for exploring ideas, shaping drafts, and preserving creative process history.",
"category": "Productivity",
"copyright": "Copyright © 2026 Thinkloom",
"icon": [
"icons/icon.ico",
"icons/icon.png"
]
}
}
+9 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
type Phase = "ideation" | "drafting" | "finalization";
@@ -73,13 +73,13 @@ const StructuredEditor = forwardRef<StructuredEditorHandle, { value: string; onC
});
export default function Thinkloom() {
const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null);
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch {} setHydrated(true); }); }, []);
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch { /* Ignore invalid local recovery state. */ } setHydrated(true); }); }, []);
useEffect(() => { if (hydrated) localStorage.setItem("thinkloom-project-v1", JSON.stringify(project)); }, [project, hydrated]);
const mutate = useCallback((type: string, summary: string, update: (current: State) => State, actor: Actor = "user") => { setProject((current) => { const previousHash = current.events.at(-1)?.hash ?? null; const event: Event = { id: uid("event"), type, actor, at: now(), summary, previousHash, hash: hash(`${previousHash}|${type}|${summary}|${Date.now()}`), provider: actor === "assistant" ? current.provider.name : undefined }; const next = { ...update(current), events: [...current.events, event], updatedAt: event.at }; void invokeNative("persist_state", { appState: next }).catch(() => undefined); return next; }); setNotice(summary); }, []);
const navigate = (next: View) => { setView(next); if (["ideation", "drafting", "finalization"].includes(next) && project.phase !== next) mutate("PHASE_CHANGED", `Moved to ${next}`, (current) => ({ ...current, phase: next as Phase })); };
useEffect(() => { const shortcut = (e: KeyboardEvent) => { if (!e.altKey) return; if (e.key === "1") document.getElementById("ideas-panel")?.focus(); if (e.key === "2") editor.current?.focus(); if (e.key === "3") document.getElementById("assistant-panel")?.focus(); }; window.addEventListener("keydown", shortcut); return () => window.removeEventListener("keydown", shortcut); }, []);
const visibleIdeas = useMemo(() => project.ideas.filter((idea) => showRejected || !["rejected", "archived"].includes(idea.status)), [project.ideas, showRejected]);
const draftingIdeas = project.ideas.filter((idea) => idea.status === "accepted"); const selectedIdeas = draftingIdeas.filter((idea) => idea.selected);
const draftingIdeas = project.ideas.filter((idea) => idea.status === "accepted"); const selectedIdeas = draftingIdeas.filter((idea) => idea.selected);
const sendMessage = async () => {
const text = message.trim(); if (!text || busy) return; setMessage(""); setBusy(true);
@@ -119,7 +119,7 @@ export default function Thinkloom() {
const createNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) { setNotice("Choose a folder when you are ready to create the desktop project."); return; } const result = await invokeNative<{ path: string }>("create_project", { path: folder, title: project.title }); if (!result) { setNotice("Project folders are available in the installed desktop app."); return; } setProjectPath(result.path); await invokeNative("persist_state", { appState: project }); setNotice("Self-contained project created and ready."); } catch (error) { setNotice(`Project could not be created: ${String(error)}`); } };
const openNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) return; const result = await invokeNative<{ path: string }>("open_project", { path: folder }); if (!result) { setNotice("Opening project folders is available in the installed desktop app."); return; } setProjectPath(result.path); const restored = await invokeNative<State>("load_project_state"); if (restored) setProject(restored); setNotice("Project reopened and its history verified."); } catch (error) { setNotice(`Project could not be opened: ${String(error)}`); } };
if (!hydrated) return <main className="loading"><div className="brand-mark">T</div><p>Gathering your threads</p></main>;
if (!hydrated) return <main className="loading"><div className="brand-mark">T</div><p>Gathering your threads</p></main>;
return <main className="app-shell">
<a className="skip-link" href="#workspace">Skip to workspace</a>
<header className="project-bar"><button className="brand" onClick={() => navigate("project")} aria-label="Open project overview"><span className="brand-mark">T</span><span>Thinkloom</span></button><div className="project-identity"><strong>{project.title}</strong><span>{project.subtitle}</span></div><div className="project-status"><Mark tone={project.privacy === "Local" ? "good" : project.privacy === "Cloud" ? "cloud" : "warm"}><span className="status-dot" />{project.privacy}</Mark><button className="history-state" onClick={() => navigate("provenance")}> History recorded</button><span className="save-state">Saved</span></div></header>
@@ -139,7 +139,7 @@ export default function Thinkloom() {
<aside id="ideas-panel" className="draft-ideas panel" tabIndex={-1} aria-label="Selected ideas"><div className="panel-heading"><div><span className="eyebrow">Drafting set</span><h2>Selected threads</h2></div></div><div className="relation"><label htmlFor="relation">Relation</label><select id="relation" value={project.generation.relation} onChange={(event) => setProject((current) => ({ ...current, generation: { ...current.generation, relation: event.target.value } }))}><option>synthesize</option><option>compare</option><option>contrast</option><option>sequence</option><option>support with evidence</option><option>separate into sections</option></select></div>{draftingIdeas.map((idea) => <label className={`draft-idea ${idea.selected ? "selected" : ""}`} key={idea.id}><input type="checkbox" checked={idea.selected} onChange={() => updateIdea(idea.id, { selected: !idea.selected }, `${idea.selected ? "Deselected" : "Selected"}${idea.title}`)} /><span><strong>{idea.title}</strong><small>{idea.summary}</small></span></label>)}<button className="secondary-button full" onClick={mergeSelected}>Merge selected</button><p className="shortcut">Focus: Alt+1</p></aside>
<section className="manuscript-panel panel" aria-label="Manuscript editor"><div className="editor-toolbar"><div><span className="eyebrow">Manuscript</span><h1>{project.title}</h1></div><div><button onClick={() => saveCheckpoint()}>Save version</button><span>{countWords(project.manuscript).toLocaleString()} words</span></div></div><StructuredEditor ref={editor} value={project.manuscript} onChange={(manuscript) => setProject((current) => ({ ...current, manuscript, updatedAt: now() }))} onCommit={() => mutate("MANUSCRIPT_TEXT_EDITED", "Autosaved manuscript edits", (current) => current)} /><div className="editor-footer"><span>Markdown structure · Autosaved locally</span><span>Focus: Alt+2</span></div></section>
<aside id="assistant-panel" className="draft-assistant panel" tabIndex={-1} aria-label="Generation assistant"><div className="panel-heading"><div><span className="eyebrow">Writing room</span><h2>Shape a passage</h2></div></div><p className="assistant-context">Using {selectedIdeas.length} selected idea{selectedIdeas.length === 1 ? "" : "s"} and your project voice. Your manuscript will not change until you choose.</p><button className="primary-button full" onClick={() => void startGeneration()} disabled={busy || !selectedIdeas.length}>{busy ? "Gathering threads…" : "Draft a passage"}</button>{project.generation.state === "staged" ? <div className="generation-preview"><div className="preview-label"><span>Preview</span><Mark tone="warm">AI draft · not inserted</Mark></div><p>{project.generation.text}</p><div className="preview-actions"><button className="primary-button" onClick={() => acceptGeneration("cursor")}>Insert at cursor</button><button onClick={() => acceptGeneration("replace")}>Replace selection</button><button onClick={() => acceptGeneration("append")}>Append</button><button onClick={() => acceptGeneration("section")}>New section</button><button onClick={() => acceptGeneration("cursor", true)}>Insert first 2 sentences</button><button onClick={() => void startGeneration()}>Regenerate</button><button className="danger-text" onClick={discardGeneration}>Discard</button></div></div> : <Empty eyebrow="Preview first" title="Nothing enters unseen" body="Choose your ideas, set their relationship, and generate a passage to review here." />}<p className="shortcut">Focus: Alt+3</p></aside>
</section>}
</section>}
{view === "finalization" && <section className="finalization-layout">
<aside className="editorial-rail panel" aria-label="Editorial actions"><span className="eyebrow">Editorial pass</span><h2>Refine with intent</h2><p>Select text in the manuscript, then preview an action.</p><div className="action-stack">{["Clarity", "Rewrite", "Shorten", "Expand", "Transition", "Repetition", "Consistency", "Tone", "User voice", "Proofread"].map((action) => <button key={action} onClick={() => void startGeneration(action)}>{action}<span></span></button>)}</div></aside>
<section className="manuscript-panel panel final-manuscript"><div className="editor-toolbar"><div><span className="eyebrow">Release manuscript</span><h1>{project.title}</h1></div><Mark tone={project.finalized ? "good" : "warm"}>{project.finalized ? "Release ready" : "Working version"}</Mark></div><textarea ref={finalEditor} className="manuscript-editor" value={project.manuscript} onChange={(event) => setProject((current) => ({ ...current, manuscript: event.target.value }))} aria-label="Final manuscript" /><div className="editor-footer"><span>{countWords(project.manuscript)} words</span><span>{project.checkpoints.length} saved versions</span></div></section>
@@ -150,7 +150,7 @@ export default function Thinkloom() {
{view === "history" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Restorable moments</span><h1>Version history</h1><p>Compare or return to meaningful stages without technical terminology.</p></div><div className="history-layout"><div className="version-list panel">{[...project.checkpoints].reverse().map((point, index) => <article className="version-row" key={point.id}><div className="version-symbol">{index === 0 ? "◆" : "◇"}</div><div><strong>{point.name}</strong><span>{fmt(point.at)} · {point.words} words</span></div><div><button onClick={() => setNotice(`Compared with “${point.name}”: ${countWords(project.manuscript) - point.words >= 0 ? "+" : ""}${countWords(project.manuscript) - point.words} words.`)}>Compare</button><button onClick={() => restoreCheckpoint(point)}>Restore</button></div></article>)}</div><aside className="panel history-aside"><span className="eyebrow">Current work</span><h2>{countWords(project.manuscript)} words</h2><p>{project.checkpoints.length ? `${countWords(project.manuscript) - project.checkpoints.at(-1)!.words >= 0 ? "+" : ""}${countWords(project.manuscript) - project.checkpoints.at(-1)!.words} words since ${project.checkpoints.at(-1)!.name}.` : "Save your first version when the draft reaches a meaningful moment."}</p><button className="primary-button full" onClick={() => saveCheckpoint()}>Save current version</button></aside></div></section>}
{view === "provenance" && <section className="page-layout"><div className="page-heading provenance-heading"><div><span className="eyebrow">Creative process record</span><h1>How this work took shape</h1><p>A linked record of decisions and contributionsnot a score or legal conclusion.</p></div><button className="secondary-button" onClick={verifyChain}>Verify linked history</button></div><div className="provenance-layout"><div className="timeline panel">{[...project.events].reverse().map((event) => <article className="event-row" key={event.id}><div className={`actor-mark actor-${event.actor}`}>{event.actor === "user" ? "Y" : event.actor === "assistant" ? "A" : "S"}</div><div><div className="event-top"><strong>{event.summary}</strong><time>{fmt(event.at)}</time></div><p>{event.type.replaceAll("_", " ").toLowerCase()}{event.provider ? ` · ${event.provider}` : ""}</p><code>{event.hash}</code></div></article>)}</div><aside className="panel contribution"><span className="eyebrow">Contribution threads</span><h2>Relationships, not percentages</h2><div className="contribution-chain"><span>You explored a question</span><i></i><span>Ideas were suggested</span><i></i><span>You accepted and shaped them</span><i></i><span>Drafts were previewed</span><i></i><span>You decided what entered the work</span></div><p>Every accepted passage remains connected to source ideas and later revisions.</p></aside></div></section>}
{view === "provenance" && <section className="page-layout"><div className="page-heading provenance-heading"><div><span className="eyebrow">Creative process record</span><h1>How this work took shape</h1><p>A linked record of decisions and contributionsnot a score or legal conclusion.</p></div><button className="secondary-button" onClick={verifyChain}>Verify linked history</button></div><div className="provenance-layout"><div className="timeline panel">{[...project.events].reverse().map((event) => <article className="event-row" key={event.id}><div className={`actor-mark actor-${event.actor}`}>{event.actor === "user" ? "Y" : event.actor === "assistant" ? "A" : "S"}</div><div><div className="event-top"><strong>{event.summary}</strong><time>{fmt(event.at)}</time></div><p>{event.type.replaceAll("_", " ").toLowerCase()}{event.provider ? ` · ${event.provider}` : ""}</p><code>{event.hash}</code></div></article>)}</div><aside className="panel contribution"><span className="eyebrow">Contribution threads</span><h2>Relationships, not percentages</h2><div className="contribution-chain"><span>You explored a question</span><i></i><span>Ideas were suggested</span><i></i><span>You accepted and shaped them</span><i></i><span>Drafts were previewed</span><i></i><span>You decided what entered the work</span></div><p>Every accepted passage remains connected to source ideas and later revisions.</p></aside></div></section>}
{view === "export" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Take your work with you</span><h1>Publish, preserve, or share</h1><p>Exports are created from the current release manuscript and checked before completion.</p></div><div className="export-grid"><article className="export-card"><span className="export-type">Publication</span><h2>Reading files</h2><p>Clean files for editors, websites, and your own archive.</p><div className="export-buttons"><button onClick={() => void exportFile("markdown")}>Markdown <span>.md</span></button><button onClick={() => void exportFile("html")}>Web page <span>.html</span></button><button onClick={() => void exportFile("text")}>Plain text <span>.txt</span></button><button onClick={() => setNotice("PDF export is available in the installed desktop app.")}>Print-ready <span>.pdf</span></button></div></article><article className="export-card featured"><span className="export-type">Preservation</span><h2>Project backup</h2><p>A complete, restorable package with manuscript, ideas, history, and recovery data.</p><button className="primary-button" onClick={() => void invokeNative<string>("create_backup").then((path) => setNotice(path ? `Backup created at ${path}` : "Project Backup ZIP is available in the desktop app."))}>Create project backup</button></article><article className="export-card"><span className="export-type">Creative process</span><h2>Authorship evidence</h2><p>A human-readable and machine-verifiable record of how the publication developed.</p><label className="toggle-row"><input type="checkbox" checked={sanitized} onChange={(event) => setSanitized(event.target.checked)} /><span><strong>Share a sanitized subset</strong><small>Excludes private conversations, provider details, identifiers, and internal paths.</small></span></label><button className="secondary-button" onClick={() => void exportFile("evidence")}>Create evidence package</button></article></div></section>}
{view === "settings" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Private by design</span><h1>Settings</h1><p>Choose where thinking happens and how Thinkloom supports the work.</p></div><div className="settings-grid">
@@ -161,9 +161,4 @@ export default function Thinkloom() {
</section>
<footer className="app-footer"><span>Thinkloom 0.1.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer>
</main>;
}
}
View File
+8
View File
@@ -0,0 +1,8 @@
import React from "react";
import { createRoot } from "react-dom/client";
import Thinkloom from "./Thinkloom";
import "./globals.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode><Thinkloom /></React.StrictMode>,
);
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
const root = new URL("../", import.meta.url);
test("defines a native-only Thinkloom application shell", async () => {
const [html, packageJson, tauriConfig] = await Promise.all([
readFile(new URL("../index.html", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../src-tauri/tauri.conf.json", import.meta.url), "utf8"),
]);
assert.match(html, /Thinkloom — ideas into writing/i);
assert.match(html, /\/src\/main\.tsx/);
assert.doesNotMatch(`${html}\n${packageJson}`, /sites|next|vinext|wrangler|cloudflare/i);
assert.match(tauriConfig, /"beforeDevCommand": "npm run dev"/);
assert.match(tauriConfig, /"beforeBuildCommand": "npm run build"/);
assert.match(tauriConfig, /"frontendDist": "\.\.\/dist"/);
});
test("implements the control and privacy contracts", async () => {
const [source, css] = await Promise.all([
readFile(new URL("../src/Thinkloom.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/globals.css", import.meta.url), "utf8"),
]);
for (const phrase of ["Insert at cursor", "Replace selection", "New section", "Discard", "History recorded", "No audio retained", "Approve for this project", "Relationships, not percentages"]) {
assert.match(source, new RegExp(phrase, "i"));
}
assert.match(source, /GENERATION_PARTIALLY_ACCEPTED/);
assert.match(source, /CLOUD_PROCESSING_APPROVED/);
assert.match(source, /store_provider_secret/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /:focus-visible/);
});
test("never ships retained audio assets", async () => {
async function walk(url) {
const entries = await readdir(url, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (["node_modules", "dist", "target", ".git"].includes(entry.name)) continue;
const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, url);
if (entry.isDirectory()) files.push(...await walk(child));
else files.push(entry.name);
}
return files;
}
const files = await walk(root);
assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false);
});
-43
View File
@@ -1,43 +0,0 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
const root = new URL("../", import.meta.url);
async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
return worker.fetch(new Request("http://localhost/", { headers: { accept: "text/html" } }), { ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) } }, { waitUntil() {}, passThroughOnException() {} });
}
test("serves the Thinkloom product shell", async () => {
const response = await render();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, /Thinkloom — ideas into writing/i);
assert.match(html, /Gathering your threads/i);
assert.doesNotMatch(html, /codex-preview|starter project|react-loading-skeleton/i);
});
test("implements the control and privacy contracts", async () => {
const [source, css, layout] = await Promise.all([
readFile(new URL("../app/thinkloom.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
]);
for (const phrase of ["Insert at cursor", "Replace selection", "New section", "Discard", "History recorded", "No audio retained", "Approve for this project", "Relationships, not percentages"]) assert.match(source, new RegExp(phrase, "i"));
assert.match(source, /GENERATION_PARTIALLY_ACCEPTED/);
assert.match(source, /CLOUD_PROCESSING_APPROVED/);
assert.match(source, /store_provider_secret/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /:focus-visible/);
assert.match(layout, /Thinkloom — ideas into writing/);
});
test("never ships retained audio assets", async () => {
async function walk(url) { const entries = await readdir(url, { withFileTypes: true }); const files = []; for (const entry of entries) { if (["node_modules", "dist", "desktop-dist", "target", ".git"].includes(entry.name)) continue; const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, url); if (entry.isDirectory()) files.push(...await walk(child)); else files.push(entry.name); } return files; }
const files = await walk(root);
assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false);
});
+20 -38
View File
@@ -1,38 +1,20 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"types": ["@cloudflare/workers-types"],
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules", "examples"]
}
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"],
"exclude": ["node_modules", "dist", "src-tauri/target"]
}
+9 -59
View File
@@ -1,59 +1,9 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 1420, strictPort: true },
clearScreen: false,
build: { outDir: "dist", emptyOutDir: true },
});
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
root: "desktop",
build: { outDir: "../desktop-dist", emptyOutDir: true },
server: { port: 1420, strictPort: true },
clearScreen: false,
});
-47
View File
@@ -1,47 +0,0 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;