From 58e5510445d7ab403d8df0792c29743a933531e0 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Fri, 17 Jul 2026 11:45:13 -0700 Subject: [PATCH] Add configurable model prompts --- PROMPTS.md | 34 ++++ README.md | 6 + package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/prompts/conversation.json | 17 ++ src-tauri/prompts/drafting.json | 14 ++ src-tauri/src/lib.rs | 261 ++++++++++++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- src/Thinkloom.tsx | 43 +++-- src/globals.css | 4 +- tests/native-app.test.mjs | 45 +++++ 13 files changed, 403 insertions(+), 33 deletions(-) create mode 100644 PROMPTS.md create mode 100644 src-tauri/prompts/conversation.json create mode 100644 src-tauri/prompts/drafting.json diff --git a/PROMPTS.md b/PROMPTS.md new file mode 100644 index 0000000..5608de9 --- /dev/null +++ b/PROMPTS.md @@ -0,0 +1,34 @@ +# Thinkloom prompt configuration + +Thinkloom 0.2.0 exposes every instruction sent to a language model as editable JSON. The desktop app creates a prompts folder in its operating-system configuration directory and shows its exact path under Settings → Prompt configuration. Use Open prompt folder to open it. + +Prompt files are loaded immediately before every model request. Save a valid edit, then make the next request; no restart or rebuild is required. Thinkloom never overwrites existing user prompt files during startup or an update. + +## Files and effects + +### conversation.json + +Affects replies in Ideation. systemPrompt defines the overall role, userPromptTemplate defines the turn task, and challengeGuidance supplies the Gentle, Balanced, or Rigorous instruction. The {{challenge_guidance}} and {{context}} placeholders are required. + +### drafting.json + +Affects passage previews in Drafting and editorial previews in Finalization. systemPrompt defines the overall role, draftPromptTemplate is used by Draft a passage, and editorialPromptTemplate is used by the editorial actions. Available placeholders are {{relation}}, {{action}}, and {{context}}. + +The description, effect, and variables objects document the configuration and are not sent to the model. + +## Editing safely + +1. You do not need to close Thinkloom; prompt files reload automatically. +2. Back up a JSON file before a substantial change. +3. Edit string values, preserving double quotes, commas, escaped line breaks as \n, and double-brace placeholders. +4. Save the file and make a new request in the affected screen. + +Thinkloom validates the schema, required fields, challenge level, and unresolved placeholders before contacting the provider. An invalid file leaves the writing untouched and displays the file path and correction needed. + +## Resetting a prompt + +Close Thinkloom, rename or delete only the affected JSON file in the user prompt folder, then reopen Thinkloom. The missing file is recreated from the bundled default. Source defaults for developers are in src-tauri/prompts/; rebuilding them does not overwrite an existing user's customized files. + +## Privacy and security + +Prompt content and substituted context are sent only to the provider selected in Settings. Local Ollama requests remain local. Cloud providers still require project approval. Do not put passwords or API keys in prompt files. \ No newline at end of file diff --git a/README.md b/README.md index 250ef34..4ae4664 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,12 @@ Judges should not need to rebuild Thinkloom. A public Windows x64 installer rele 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. +## Prompt configuration + +Every language-model prompt is editable JSON. Thinkloom creates documented user copies of conversation.json and drafting.json and exposes their folder under Settings → Prompt configuration. The files reload before every request, so technical users can iterate without rebuilding or restarting. Invalid JSON or missing template variables stop the request with a recoverable error and leave the writing unchanged. + +See [PROMPTS.md](PROMPTS.md) for each file's effect, variables, editing workflow, validation behavior, and reset procedure. + ## 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. diff --git a/package-lock.json b/package-lock.json index 44ce553..4939d90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "thinkloom", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "thinkloom", - "version": "0.1.0", + "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { "@tiptap/markdown": "^3.28.0", diff --git a/package.json b/package.json index 2454604..f7a7d72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thinkloom", - "version": "0.1.0", + "version": "0.2.0", "author": "Christopher Chambers", "license": "AGPL-3.0-only", "repository": "https://github.com/Labyricorn/thinkloom-openai-hackathon.git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b66f4cc..8ba75ca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3980,7 +3980,7 @@ dependencies = [ [[package]] name = "thinkloom" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "hex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7bac6ef..09598cb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkloom" -version = "0.1.0" +version = "0.2.0" description = "Local-first writing studio with creative provenance" authors = ["Christopher Chambers"] license = "AGPL-3.0-only" diff --git a/src-tauri/prompts/conversation.json b/src-tauri/prompts/conversation.json new file mode 100644 index 0000000..e2cadcc --- /dev/null +++ b/src-tauri/prompts/conversation.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "conversation", + "description": "Controls Thinkloom replies during the Ideation conversation.", + "effect": "Changes how the assistant reflects on the writer's latest message and which single follow-up question it asks.", + "systemPrompt": "You are Thinkloom, a focused writing collaborator in an ideation conversation. Respond naturally to the writer's latest message, briefly reflect what is useful, and ask exactly one focused question. Do not force a stock interpretation, fabricate details, or draft prose unless asked.", + "userPromptTemplate": "Continue this ideation conversation. Respond directly and naturally to the writer's latest message, then ask exactly one focused question that helps develop their writing. {{challenge_guidance}}\n\nConversation so far:\n{{context}}", + "challengeGuidance": { + "Gentle": "Be warm and exploratory; help the writer locate a concrete personal example.", + "Balanced": "Surface one useful tension or assumption without forcing a predetermined interpretation.", + "Rigorous": "Test the claim respectfully; ask for evidence, a distinction, or a counterexample." + }, + "variables": { + "challenge_guidance": "Selected from challengeGuidance using the current Gentle, Balanced, or Rigorous setting.", + "context": "The ten most recent Writer and Thinkloom conversation turns." + } +} diff --git a/src-tauri/prompts/drafting.json b/src-tauri/prompts/drafting.json new file mode 100644 index 0000000..08abbc4 --- /dev/null +++ b/src-tauri/prompts/drafting.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "drafting", + "description": "Controls generated passages and editorial previews in Drafting and Finalization.", + "effect": "Changes the prose returned when drafting from selected ideas or applying an editorial action to a passage.", + "systemPrompt": "You are Thinkloom, a focused writing collaborator. Return only proposed prose; it will be staged for review. Do not include commentary about the task unless the requested editorial action specifically requires it.", + "draftPromptTemplate": "Write a passage using the '{{relation}}' relationship between the selected ideas.\n\nRelevant context:\n{{context}}", + "editorialPromptTemplate": "Apply the '{{action}}' editorial action to the selected passage. Return only the proposed result.\n\nRelevant context:\n{{context}}", + "variables": { + "relation": "The relationship selected in Drafting, such as synthesize, compare, contrast, sequence, support with evidence, or separate into sections.", + "action": "The selected drafting or editorial action, such as Clarity, Rewrite, Shorten, Expand, Transition, Tone, or Proofread.", + "context": "The selected ideas or current passage supplied by Thinkloom." + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0b06856..1d49bf9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,22 +1,26 @@ use chrono::Utc; use rusqlite::{params, Connection}; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::{ + collections::HashMap, fs, io::Write, path::{Path, PathBuf}, process::Command, sync::Mutex, }; -use tauri::State; +use tauri::{AppHandle, Manager, State}; use uuid::Uuid; use walkdir::WalkDir; use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter}; const SCHEMA_VERSION: &str = "1.0"; const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +const CONVERSATION_PROMPT_DEFAULT: &str = include_str!("../prompts/conversation.json"); +const DRAFTING_PROMPT_DEFAULT: &str = include_str!("../prompts/drafting.json"); +const PROMPT_GUIDE: &str = include_str!("../../PROMPTS.md"); #[derive(Default)] struct RuntimeState { @@ -85,6 +89,32 @@ struct ProviderProfile { model: String, mode: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConversationPromptConfig { + schema_version: u32, + system_prompt: String, + user_prompt_template: String, + challenge_guidance: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DraftingPromptConfig { + schema_version: u32, + system_prompt: String, + draft_prompt_template: String, + editorial_prompt_template: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PromptConfigInfo { + directory: String, + files: Vec, + reload_behavior: String, +} + #[derive(Debug, Serialize)] struct ConnectionResult { ok: bool, @@ -147,6 +177,194 @@ fn write_json(path: &Path, value: &impl Serialize) -> CommandResult<()> { .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string(), false))?; atomic_write(path, &bytes) } +fn prompt_config_dir(app: &AppHandle) -> CommandResult { + app.path() + .app_config_dir() + .map(|path| path.join("prompts")) + .map_err(|error| { + CommandError::io( + "Could not locate the application configuration folder", + error, + ) + }) +} + +fn ensure_prompt_files_at(app: &AppHandle) -> CommandResult { + let directory = prompt_config_dir(app)?; + fs::create_dir_all(&directory).map_err(|error| { + CommandError::io("Could not create the prompt configuration folder", error) + })?; + for (name, contents) in [ + ("conversation.json", CONVERSATION_PROMPT_DEFAULT), + ("drafting.json", DRAFTING_PROMPT_DEFAULT), + ("README.md", PROMPT_GUIDE), + ] { + let path = directory.join(name); + if !path.exists() { + atomic_write(&path, contents.as_bytes())?; + } + } + Ok(directory) +} + +fn load_prompt_config(path: &Path) -> CommandResult { + let raw = fs::read_to_string(path).map_err(|error| { + CommandError::io( + &format!("Could not read prompt configuration {}", path.display()), + error, + ) + })?; + serde_json::from_str(&raw).map_err(|error| { + CommandError::new( + "PROMPT_CONFIG_INVALID", + format!("Invalid prompt configuration {}: {error}", path.display()), + true, + ) + }) +} + +fn required_prompt_variable<'a>( + variables: &'a HashMap, + name: &str, +) -> CommandResult<&'a str> { + variables + .get(name) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + CommandError::new( + "PROMPT_VARIABLE_MISSING", + format!("The prompt variable '{{{{{name}}}}}' is missing."), + true, + ) + }) +} + +fn render_prompt_template( + template: &str, + variables: &HashMap, +) -> CommandResult { + let mut rendered = template.to_owned(); + for (name, value) in variables { + rendered = rendered.replace(&format!("{{{{{name}}}}}"), value); + } + if let Some(start) = rendered.find("{{") { + if let Some(end) = rendered[start + 2..].find("}}") { + let name = &rendered[start + 2..start + 2 + end]; + return Err(CommandError::new( + "PROMPT_VARIABLE_MISSING", + format!("The prompt template references unknown or unavailable variable '{{{{{name}}}}}'."), + true, + )); + } + } + Ok(rendered) +} + +fn prompts_for_request( + app: &AppHandle, + purpose: &str, + mut variables: HashMap, +) -> CommandResult<(String, String)> { + let directory = ensure_prompt_files_at(app)?; + match purpose { + "conversation" => { + let path = directory.join("conversation.json"); + let config: ConversationPromptConfig = load_prompt_config(&path)?; + if config.schema_version != 1 + || config.system_prompt.trim().is_empty() + || config.user_prompt_template.trim().is_empty() + { + return Err(CommandError::new( + "PROMPT_CONFIG_INVALID", + format!( + "{} must use schemaVersion 1 and non-empty required prompt fields.", + path.display() + ), + true, + )); + } + let challenge = required_prompt_variable(&variables, "challenge")?; + let guidance = config.challenge_guidance.get(challenge).ok_or_else(|| { + CommandError::new( + "PROMPT_CONFIG_INVALID", + format!( + "{} has no challengeGuidance entry for '{challenge}'.", + path.display() + ), + true, + ) + })?; + variables.insert("challenge_guidance".into(), guidance.clone()); + Ok(( + config.system_prompt, + render_prompt_template(&config.user_prompt_template, &variables)?, + )) + } + "drafting" => { + let path = directory.join("drafting.json"); + let config: DraftingPromptConfig = load_prompt_config(&path)?; + if config.schema_version != 1 + || config.system_prompt.trim().is_empty() + || config.draft_prompt_template.trim().is_empty() + || config.editorial_prompt_template.trim().is_empty() + { + return Err(CommandError::new( + "PROMPT_CONFIG_INVALID", + format!( + "{} must use schemaVersion 1 and non-empty required prompt fields.", + path.display() + ), + true, + )); + } + let template = if required_prompt_variable(&variables, "action")? == "draft" { + &config.draft_prompt_template + } else { + &config.editorial_prompt_template + }; + Ok(( + config.system_prompt, + render_prompt_template(template, &variables)?, + )) + } + _ => Err(CommandError::new( + "PROMPT_PURPOSE_INVALID", + format!("No prompt configuration is registered for '{purpose}'."), + false, + )), + } +} + +#[tauri::command] +fn ensure_prompt_files(app: AppHandle) -> CommandResult { + let directory = ensure_prompt_files_at(&app)?; + Ok(PromptConfigInfo { + directory: directory.to_string_lossy().into_owned(), + files: vec![ + "conversation.json".into(), + "drafting.json".into(), + "README.md".into(), + ], + reload_behavior: "Prompt files reload before every model request.".into(), + }) +} + +#[tauri::command] +fn open_prompt_folder(app: AppHandle) -> CommandResult { + let directory = ensure_prompt_files_at(&app)?; + #[cfg(target_os = "windows")] + let mut command = Command::new("explorer.exe"); + #[cfg(target_os = "macos")] + let mut command = Command::new("open"); + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = Command::new("xdg-open"); + command.arg(&directory).spawn().map_err(|error| { + CommandError::io("Could not open the prompt configuration folder", error) + })?; + Ok(directory.to_string_lossy().into_owned()) +} + fn init_db(root: &Path) -> CommandResult { let state_dir = root.join(".thinkloom"); fs::create_dir_all(&state_dir) @@ -1179,11 +1397,11 @@ fn load_project_state(state: State) -> CommandResult #[tauri::command] fn generate_text( + app: AppHandle, profile: ProviderProfile, - prompt: String, - context: String, + prompt_variables: HashMap, cloud_approved: bool, - purpose: Option, + purpose: String, ) -> CommandResult { if profile.mode == "cloud" && !cloud_approved { return Err(CommandError::new( @@ -1192,19 +1410,15 @@ fn generate_text( true, )); } + let (system, prompt) = prompts_for_request(&app, &purpose, prompt_variables)?; let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(90)) .build() .map_err(|e| CommandError::new("PROVIDER_ERROR", e.to_string(), true))?; - let system = if purpose.as_deref() == Some("conversation") { - "You are Thinkloom, a focused writing collaborator in an ideation conversation. Respond naturally to the writer's latest message, briefly reflect what is useful, and ask exactly one focused question. Do not force a stock interpretation, fabricate details, or draft prose unless asked." - } else { - "You are Thinkloom, a focused writing collaborator. Return only proposed prose; it will be staged for review." - }; let (url, body) = if profile.kind == "ollama" { ( format!("{}/api/chat", profile.endpoint.trim_end_matches('/')), - json!({"model":profile.model,"stream":false,"messages":[{"role":"system","content":system},{"role":"user","content":format!("{prompt}\n\nRelevant context:\n{context}")}]}), + json!({"model":profile.model,"stream":false,"messages":[{"role":"system","content":system},{"role":"user","content":prompt}]}), ) } else { ( @@ -1212,7 +1426,7 @@ fn generate_text( "{}/chat/completions", profile.endpoint.trim_end_matches('/') ), - json!({"model":profile.model,"stream":false,"messages":[{"role":"system","content":system},{"role":"user","content":format!("{prompt}\n\nRelevant context:\n{context}")}]}), + json!({"model":profile.model,"stream":false,"messages":[{"role":"system","content":system},{"role":"user","content":prompt}]}), ) }; let mut request = client.post(url).json(&body); @@ -1271,6 +1485,8 @@ pub fn run() { store_provider_secret, delete_provider_secret, test_provider, + ensure_prompt_files, + open_prompt_folder, generate_text, export_project, create_backup, @@ -1286,6 +1502,27 @@ pub fn run() { #[cfg(test)] mod tests { use super::*; + + #[test] + fn bundled_prompt_configs_parse_and_templates_validate() { + let conversation: ConversationPromptConfig = + serde_json::from_str(CONVERSATION_PROMPT_DEFAULT).unwrap(); + let drafting: DraftingPromptConfig = serde_json::from_str(DRAFTING_PROMPT_DEFAULT).unwrap(); + assert_eq!(conversation.schema_version, 1); + assert_eq!(drafting.schema_version, 1); + + let mut variables = HashMap::new(); + variables.insert("context".into(), "A current thought".into()); + variables.insert("challenge_guidance".into(), "Ask one question.".into()); + let rendered = + render_prompt_template(&conversation.user_prompt_template, &variables).unwrap(); + assert!(rendered.contains("A current thought")); + + variables.remove("context"); + let error = render_prompt_template(&conversation.user_prompt_template, &variables) + .expect_err("an unresolved placeholder must fail"); + assert_eq!(error.code, "PROMPT_VARIABLE_MISSING"); + } #[test] fn pdf_is_structurally_complete() { let bytes = pdf_bytes("Title", "A short manuscript."); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ccfa704..c0e529e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Thinkloom", - "version": "0.1.0", + "version": "0.2.0", "identifier": "com.thinkloom.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/Thinkloom.tsx b/src/Thinkloom.tsx index 80783ca..b6b5640 100644 --- a/src/Thinkloom.tsx +++ b/src/Thinkloom.tsx @@ -88,9 +88,10 @@ const StructuredEditor = forwardRef
; }); export default function Thinkloom() { - const [project, setProject] = useState(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState("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(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const editor = useRef(null); const finalEditor = useRef(null); const conversationEnd = useRef(null); + const [project, setProject] = useState(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState("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(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const [promptPath, setPromptPath] = useState(""); const editor = useRef(null); const finalEditor = useRef(null); const conversationEnd = useRef(null); 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]); + useEffect(() => { void invokeNative<{ directory: string }>("ensure_prompt_files").then((info) => { if (info?.directory) setPromptPath(info.directory); }).catch((error) => setNotice(`Prompt configuration could not be prepared: ${nativeError(error)}`)); }, []); 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); }, []); @@ -102,11 +103,9 @@ export default function Thinkloom() { const userTurn: Turn = { id: uid("turn"), speaker: "user", text, createdAt: now() }; mutate("USER_TEXT_TURN_CREATED", "Saved your conversation turn", (current) => ({ ...current, turns: [...current.turns, userTurn] })); const conversation = [...project.turns, userTurn].slice(-10).map((turn) => `${turn.speaker === "user" ? "Writer" : "Thinkloom"}: ${turn.text}`).join("\n"); - const challengeGuidance = project.challenge === "Gentle" ? "Be warm and exploratory; help the writer locate a concrete personal example." : project.challenge === "Rigorous" ? "Test the claim respectfully; ask for evidence, a distinction, or a counterexample." : "Surface one useful tension or assumption without forcing a predetermined interpretation."; - const prompt = `Continue this ideation conversation. Respond directly and naturally to the writer's latest message, then ask exactly one focused question that helps develop their writing. ${challengeGuidance}`; let reply: string; try { - const generated = await invokeNative("generate_text", { profile: project.provider, prompt, context: conversation, cloudApproved: project.cloudApproved, purpose: "conversation" }); + const generated = await invokeNative("generate_text", { profile: project.provider, promptVariables: { challenge: project.challenge, context: conversation }, cloudApproved: project.cloudApproved, purpose: "conversation" }); if (!generated?.trim()) throw new Error("The provider returned an empty response."); reply = generated.trim(); setProject((current) => ({ ...current, provider: { ...current.provider, connected: true } })); @@ -128,14 +127,30 @@ export default function Thinkloom() { const mergeSelected = () => { if (selectedIdeas.length < 2) { setNotice("Select at least two ideas to merge."); return; } const merged: Idea = { id: uid("idea"), title: selectedIdeas.map((idea) => idea.title).join(" + ").slice(0, 70), summary: selectedIdeas.map((idea) => idea.summary).join(" "), detail: "A synthesis retaining both parent ideas.", status: "accepted", sourceTurnIds: [...new Set(selectedIdeas.flatMap((idea) => idea.sourceTurnIds))], parentIdeaIds: selectedIdeas.map((idea) => idea.id), tags: [...new Set(selectedIdeas.flatMap((idea) => idea.tags))], pinned: true, selected: true, createdBy: "mixed" }; mutate("IDEAS_MERGED", `Merged ${selectedIdeas.length} ideas`, (current) => ({ ...current, ideas: [...current.ideas.map((idea) => selectedIdeas.some((selected) => selected.id === idea.id) ? { ...idea, selected: false } : idea), merged] })); }; const startGeneration = async (action = "draft") => { - if (busy) return; if (action === "draft" && !selectedIdeas.length) { setNotice("Select at least one accepted idea first."); return; } setBusy(true); const id = uid("generation"); const source = selectedIdeas.map((idea) => idea.summary).join(" ") || "the current passage"; const prompt = action === "draft" ? `Draft a passage that ${project.generation.relation}s the selected ideas.` : `${action} the selected passage.`; - mutate("GENERATION_REQUESTED", `Started ${action.toLowerCase()} preview`, (current) => ({ ...current, generation: { ...current.generation, id, state: "streaming", text: "", prompt } })); await new Promise((resolve) => setTimeout(resolve, 500)); - let nativeText: string | null = null; - try { nativeText = await invokeNative("generate_text", { profile: project.provider, prompt, context: source, cloudApproved: project.cloudApproved, purpose: "draft" }); } - catch (error) { mutate("GENERATION_FAILED", "Provider request failed; your request is preserved for retry", (current) => ({ ...current, generation: { ...current.generation, id, state: "failed", text: "", prompt } }), "system"); setNotice(`Provider request failed: ${nativeError(error)}`); setBusy(false); return; } - const variants: Record = { draft: `The language of personal discipline obscures a public design problem. ${source} A community’s capacity for sustained thought depends on the expectations it builds into ordinary places: whether a classroom protects silence long enough for uncertainty, whether a meeting rewards listening before reaction, and whether public argument leaves room for ideas that cannot arrive as slogans.`, Clarity: "Attention is personal in experience but public in consequence. Institutions shape whether people can sustain it together.", Rewrite: "We guard attention as private property, yet its most important work happens between us—in classrooms, meetings, and the slow exchange of public argument.", Shorten: "Attention feels private, but its loss reshapes public life.", Expand: "Attention feels private, but its loss reshapes public life. The change appears first as friction: a classroom that cannot settle, a meeting that repeats itself, a debate that rewards instant response. Over time, those moments become an institutional condition.", Transition: "That is why the question must move beyond individual habit and toward the environments we share.", Repetition: "Consider trimming repeated uses of “private,” “shared,” and “attention” in the opening two paragraphs.", Consistency: "The draft consistently frames attention as infrastructure; keep the institutional examples parallel to maintain that logic.", Tone: "A measured version can make the claim firmly without blaming readers for systems they did not design.", "User voice": "In your reflective voice: the room matters because attention is never brought into it alone; it is invited, protected, or spent there.", Proofread: "The selected passage is mechanically clean. Consider an em dash instead of the current parenthetical pause." }; - mutate("GENERATION_COMPLETED", "Preview ready — your manuscript is unchanged", (current) => ({ ...current, generation: { ...current.generation, id, state: "staged", text: nativeText ?? variants[action] ?? variants.draft, prompt } }), "assistant"); setBusy(false); + if (busy) return; + if (action === "draft" && !selectedIdeas.length) { setNotice("Select at least one accepted idea first."); return; } + setBusy(true); + const id = uid("generation"); + const source = selectedIdeas.map((idea) => idea.summary).join(" ") || "the current passage"; + const prompt = action; + mutate("GENERATION_REQUESTED", `Started ${action.toLowerCase()} preview`, (current) => ({ ...current, generation: { ...current.generation, id, state: "streaming", text: "", prompt } })); + try { + const nativeText = await invokeNative("generate_text", { + profile: project.provider, + promptVariables: { action, relation: project.generation.relation, context: source }, + cloudApproved: project.cloudApproved, + purpose: "drafting", + }); + if (!nativeText?.trim()) throw new Error("The provider returned an empty response."); + mutate("GENERATION_COMPLETED", "Preview ready — your manuscript is unchanged", (current) => ({ ...current, generation: { ...current.generation, id, state: "staged", text: nativeText.trim(), prompt } }), "assistant"); + } catch (error) { + mutate("GENERATION_FAILED", "Provider request failed; your request is preserved for retry", (current) => ({ ...current, generation: { ...current.generation, id, state: "failed", text: "", prompt } }), "system"); + setNotice(`Provider request failed: ${nativeError(error)}`); + } finally { + setBusy(false); + } }; + const acceptGeneration = (mode: "cursor" | "replace" | "append" | "section", partial = false) => { const full = project.generation.text; if (!full) return; const accepted = partial ? full.split(/(?<=[.!?])\s+/).slice(0, 2).join(" ") : full; let manuscript = project.manuscript; if ((mode === "replace" || mode === "cursor") && view === "drafting") { manuscript = editor.current?.insert(accepted, mode === "replace") ?? manuscript; } else if (mode === "replace") { const start = finalEditor.current?.selectionStart ?? manuscript.length; const end = finalEditor.current?.selectionEnd ?? start; manuscript = `${manuscript.slice(0, start)}${accepted}${manuscript.slice(end)}`; } else if (mode === "section") manuscript = `${manuscript.trimEnd()}\n\n## New section\n\n${accepted}\n`; else manuscript = `${manuscript.trimEnd()}\n\n${accepted}\n`; mutate(partial ? "GENERATION_PARTIALLY_ACCEPTED" : "GENERATION_ACCEPTED", `${partial ? "Partially accepted" : "Accepted"} generated text`, (current) => ({ ...current, manuscript, generation: { ...current.generation, state: "accepted" } })); }; const discardGeneration = () => mutate("GENERATION_REJECTED", "Discarded generated preview", (current) => ({ ...current, generation: { ...current.generation, state: "rejected", text: "" } })); const saveCheckpoint = (name = `Version ${project.checkpoints.length + 1}`) => { const point: Checkpoint = { id: uid("version"), name, at: now(), manuscript: project.manuscript, words: countWords(project.manuscript) }; mutate("CHECKPOINT_CREATED", `Saved version “${name}”`, (current) => ({ ...current, checkpoints: [...current.checkpoints, point] })); void invokeNative("create_checkpoint", { name }); }; @@ -162,6 +177,7 @@ export default function Thinkloom() { }; const createNativeProject = async () => { try { const folder = await invokeNative("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("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("load_project_state"); if (restored) setProject(restored); setNotice("Project reopened and its history verified."); } catch (error) { setNotice(`Project could not be opened: ${String(error)}`); } }; + const openPromptFolder = async () => { try { const path = await invokeNative("open_prompt_folder"); if (path) { setPromptPath(path); setNotice("Prompt configuration folder opened."); } } catch (error) { setNotice(`Prompt configuration folder could not be opened: ${nativeError(error)}`); } }; if (!hydrated) return
T

Gathering your threads…

; return
@@ -200,9 +216,10 @@ export default function Thinkloom() { {view === "settings" &&
Private by design

Settings

Choose where thinking happens and how Thinkloom supports the work.

01

Model provider

Your active writing assistant.

{project.provider.kind !== "ollama" && }{project.provider.mode === "cloud" && !project.cloudApproved &&
Cloud approval required

Your first cloud request sends only relevant context shown in preview.

}
02

Voice & listening

Audio is processed in memory and never retained.

-
03

Your writing voice

Inspectable traits guide suggestions without impersonating you.

{project.styleTraits.map((trait, index) => )}
{project.disallowedHabits.map((habit) => {habit})}
+
03

Your writing voice

Inspectable traits guide suggestions without impersonating you.

{project.styleTraits.map((trait, index) => )}
{project.disallowedHabits.map((habit) => {habit})}
+
04

Prompt configuration

Technical users can tune every instruction sent to the model.

conversation.json affects Ideation replies. drafting.json affects passage and editorial previews. Files reload before every model request, so no restart is needed.

{promptPath || "Preparing prompt files…"}
README.md in this folder documents every field, variable, effect, validation rule, and reset procedure.
} -
Thinkloom 0.1.0Local-first · audio retention always off{project.events.at(-1)?.hash ?? "No history"}
+
; } diff --git a/src/globals.css b/src/globals.css index 7dc1b73..848fd86 100644 --- a/src/globals.css +++ b/src/globals.css @@ -1,8 +1,8 @@ :root{--ink:#20201d;--muted:#5f5d57;--paper:#f4f1e9;--panel:#fbfaf5;--line:#dcd7ca;--line-dark:#c5bfb0;--moss:#52684f;--moss-soft:#e3eadf;--clay:#a86145;--clay-soft:#f2e4da;--gold:#b38b39;--blue:#536c80;--field-bg:#fffefa;--field-ink:#24231f;--field-muted:#57554f;--field-disabled:#ece9e0;--control-bg:#eeece5;--selection-bg:#d6e1d1;--selection-ink:#152014;--toolbar-bg:rgba(255,254,250,.94);--active-bg:#20201d;--active-hover-bg:#383731;--active-ink:#fff;--accent-button-bg:#814733;--accent-hover-bg:#6f3c2b;--accent-button-ink:#fff;--shadow:0 18px 60px rgba(54,48,36,.08);color-scheme:light;font-synthesis:none}*{box-sizing:border-box}html,body,#root{width:100%;height:100%;overflow:hidden}html{font-size:15px;background:var(--paper);scroll-behavior:smooth}body{margin:0;color:var(--ink);background:radial-gradient(circle at 20% 0,rgba(255,255,255,.85),transparent 32rem),var(--paper);font-family:"Segoe UI",system-ui,-apple-system,sans-serif}button,input,textarea,select{font:inherit}button{color:inherit}input,textarea,select{color:var(--field-ink);background-color:var(--field-bg);caret-color:var(--clay)}input::placeholder,textarea::placeholder{color:var(--field-muted);opacity:1}input:disabled,textarea:disabled,select:disabled{background:var(--field-disabled);color:var(--field-muted)}select option{color:var(--field-ink);background:var(--field-bg)}input:-webkit-autofill,input:-webkit-autofill:hover,input:-webkit-autofill:focus{-webkit-text-fill-color:var(--field-ink);box-shadow:0 0 0 1000px var(--field-bg) inset}::selection{color:var(--selection-ink);background:var(--selection-bg)}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.48}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:3px solid rgba(82,104,79,.35);outline-offset:2px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip-link{position:fixed;left:1rem;top:-4rem;z-index:99;padding:.7rem 1rem;background:var(--ink);color:white;border-radius:0 0 6px 6px}.skip-link:focus{top:0}.loading{height:100dvh;display:grid;place-content:center;text-align:center;gap:1rem}.loading .brand-mark{margin:auto}.loading p{color:var(--muted)}.app-shell{height:100dvh;min-height:0;overflow:hidden;display:flex;flex-direction:column}.project-bar{height:68px;flex:none;display:flex;align-items:center;gap:1.5rem;padding:0 2rem;border-bottom:1px solid var(--line);background:rgba(251,250,245,.88);backdrop-filter:blur(18px);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:.7rem;border:0;background:none;font-family:Georgia,serif;font-size:1.18rem;font-weight:700;padding:0}.brand-mark{width:32px;height:32px;display:grid;place-items:center;color:#fff;background:var(--ink);border-radius:50% 50% 45% 52%;font:italic 700 1.2rem Georgia,serif;box-shadow:inset 0 0 0 1px rgba(255,255,255,.2)}.project-identity{display:flex;flex-direction:column;min-width:0;padding-left:1.5rem;border-left:1px solid var(--line)}.project-identity strong{font-family:Georgia,serif;font-size:1rem}.project-identity span{font-size:.72rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38vw}.new-project-button{margin-left:auto;padding:.5rem .72rem;border:1px solid var(--line-dark);border-radius:5px;background:var(--field-bg);color:var(--field-ink);font-size:.7rem;font-weight:700;white-space:nowrap}.new-project-button:hover{border-color:var(--moss);background:var(--moss-soft)}.project-status{display:flex;align-items:center;gap:.85rem}.mark{display:inline-flex;align-items:center;gap:.42rem;padding:.3rem .64rem;border:1px solid var(--line);border-radius:999px;color:#625f58;background:#f0eee7;font-size:.69rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.mark-good{color:#40533d;background:var(--moss-soft);border-color:#cbd8c6}.mark-warm{color:#855039;background:var(--clay-soft);border-color:#e4ccbd}.mark-cloud{color:#3f596d;background:#e4edf2;border-color:#c9dbe4}.status-dot{width:6px;height:6px;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px rgba(82,104,79,.12)}.history-state{border:0;background:none;color:var(--muted);font-size:.75rem}.history-state:hover{color:var(--ink)}.save-state{font-size:.7rem;color:var(--moss);padding-left:.85rem;border-left:1px solid var(--line)}.main-nav{height:53px;flex:none;display:flex;align-items:stretch;padding:0 2rem;border-bottom:1px solid var(--line);background:var(--panel)}.main-nav button{border:0;border-bottom:2px solid transparent;background:none;color:var(--muted);font-size:.78rem;padding:0 .95rem}.main-nav button:hover,.main-nav button.active{color:var(--ink)}.main-nav button.active{border-bottom-color:var(--clay)}.phase-nav,.utility-nav{display:flex}.phase-nav{gap:.2rem}.phase-nav button{font-family:Georgia,serif;font-size:.86rem}.phase-nav button span{display:inline-grid;place-items:center;width:20px;height:20px;margin-right:.45rem;border:1px solid var(--line-dark);border-radius:50%;font:600 .64rem "Segoe UI",sans-serif}.phase-nav button.active span{background:var(--active-bg);border-color:var(--active-bg);color:var(--active-ink)}.utility-nav{margin-left:auto}.notice{min-height:33px;flex:none;display:flex;align-items:center;justify-content:center;gap:.45rem;border-bottom:1px solid #e5dcc5;background:#f6eed8;color:#665a3e;font-size:.7rem;letter-spacing:.02em}.notice span{color:var(--gold)}.workspace{flex:1;min-height:0;overflow:auto;padding:18px 22px 22px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:9px;box-shadow:0 2px 8px rgba(50,43,30,.025);min-width:0}.panel-heading{min-height:75px;padding:1.25rem 1.35rem;display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;border-bottom:1px solid var(--line)}.eyebrow{display:block;margin-bottom:.32rem;color:var(--clay);font-size:.64rem;font-weight:800;letter-spacing:.16em;text-transform:uppercase}.panel h1,.panel h2,.page-heading h1,.feature-card h2,.export-card h2{margin:0;font-family:Georgia,"Times New Roman",serif;font-weight:500;line-height:1.12}.panel-heading h1{font-size:1.35rem}.panel-heading h2{font-size:1.18rem}.segmented{display:flex;padding:3px;background:#eeece5;border-radius:6px}.segmented button{border:0;background:none;padding:.35rem .52rem;border-radius:4px;color:var(--muted);font-size:.65rem}.segmented button.active{background:white;color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.09)}.ideation-layout{height:100%;min-height:0;display:grid;grid-template-columns:minmax(480px,1.55fr) minmax(320px,.8fr);gap:14px}.conversation-panel,.ideas-panel{min-height:0;overflow:hidden;display:flex;flex-direction:column}.conversation-stream{flex:1;overflow:auto;padding:1rem 9% 1.5rem}.date-divider{display:flex;align-items:center;gap:.5rem;margin:.2rem 0 1.4rem;color:var(--muted);font-size:.65rem;text-transform:uppercase;letter-spacing:.1em}.date-divider::before,.date-divider::after{content:"";height:1px;background:var(--line);flex:1}.turn{max-width:78%;margin-bottom:1.4rem}.turn.user{margin-left:auto}.turn-author{display:flex;gap:.65rem;align-items:center;margin-bottom:.42rem;color:var(--muted);font-size:.67rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.turn.user .turn-author{justify-content:flex-end}.turn-author time{font-weight:400;text-transform:none;letter-spacing:0;color:var(--muted)}.turn p{margin:0;padding:1rem 1.1rem;border-radius:4px 13px 13px 13px;background:#eeece5;font:1rem/1.62 Georgia,serif}.turn.user p{background:#e5e9e1;border-radius:13px 4px 13px 13px}.inline-action{display:block;margin:.4rem 0 0 auto;border:0;background:none;color:var(--moss);font-size:.67rem}.thinking p span{display:inline-block;width:6px;height:6px;margin-right:4px;border-radius:50%;background:#8f8b82;animation:thinking 1.1s infinite}.thinking p span:nth-child(2){animation-delay:.16s}.thinking p span:nth-child(3){animation-delay:.32s}.composer{margin:0 1.1rem 1.1rem;border:1px solid var(--line-dark);border-radius:8px;background:white;box-shadow:0 8px 30px rgba(43,38,28,.06);overflow:hidden}.composer.listening{border-color:var(--clay);box-shadow:0 0 0 3px rgba(168,97,69,.1)}.composer textarea{display:block;width:100%;resize:none;border:0;outline:0;padding:1rem 1.1rem .6rem;background:transparent;font:1rem/1.45 Georgia,serif}.composer-actions{display:flex;align-items:center;justify-content:space-between;padding:.35rem .55rem .55rem}.composer-actions>div{display:flex;align-items:center;gap:.55rem}.privacy-note{color:var(--muted);font-size:.64rem}.icon-button{width:30px;height:30px;border:1px solid var(--line);background:#f5f3ed;border-radius:50%;font-size:.65rem;color:var(--clay)}.icon-button.danger{color:var(--accent-button-ink);background:var(--accent-button-bg)}.primary-button,.secondary-button,.release-button{border-radius:5px;padding:.62rem .9rem;font-size:.75rem;font-weight:700}.primary-button{border:1px solid var(--active-bg);background:var(--active-bg);color:var(--active-ink)}.primary-button:hover{background:var(--active-hover-bg)}.release-button:hover{background:var(--accent-hover-bg)}.secondary-button{border:1px solid var(--line-dark);background:white}.full{width:100%;justify-content:center}.voice-state{display:flex;gap:.55rem;align-items:center;padding:.48rem .75rem;background:var(--clay-soft);color:#814a33;font-size:.66rem}.pulse{width:7px;height:7px;border-radius:50%;background:var(--clay);animation:pulse 1.2s infinite}.idea-list{flex:1;overflow:auto;padding:1rem}.idea-card{position:relative;padding:1rem;margin-bottom:.85rem;border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:6px;background:white}.idea-card.status-accepted{border-left-color:var(--moss)}.idea-card.status-rejected,.idea-card.status-archived{opacity:.62;border-left-color:#999}.idea-meta{display:flex;justify-content:space-between;margin-bottom:.65rem;color:var(--muted);font-size:.61rem;text-transform:uppercase;letter-spacing:.08em}.idea-card h3{margin:0 0 .45rem;font:600 1rem/1.2 Georgia,serif}.idea-card p{margin:0;color:#55534d;font-size:.78rem;line-height:1.5}.tag-row{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.75rem}.tag-row span{padding:.2rem .43rem;border-radius:3px;background:#f0eee7;color:#726f66;font-size:.59rem}.card-actions{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.8rem;padding-top:.7rem;border-top:1px solid #eeeae0}.card-actions button,.text-button{border:0;background:none;padding:.3rem .4rem;color:var(--muted);font-size:.66rem}.card-actions button:hover{color:var(--ink);background:#f1efe9;border-radius:3px}.card-actions .accept{color:var(--moss);font-weight:800}.edit-title,.idea-card textarea{width:100%;border:1px solid var(--line-dark);padding:.45rem;border-radius:3px;background:var(--field-bg);color:var(--field-ink)}.edit-title{font:600 1rem Georgia,serif;margin-bottom:.4rem}.idea-card textarea{min-height:76px;font-size:.75rem}.show-history{margin:0 1rem 1rem;padding:.6rem;border:1px dashed var(--line-dark);background:none;color:var(--muted);font-size:.67rem;border-radius:5px} @keyframes thinking{0%,60%,100%{opacity:.25;transform:translateY(0)}30%{opacity:1;transform:translateY(-2px)}}@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(168,97,69,.35)}100%{box-shadow:0 0 0 8px rgba(168,97,69,0)}} -.drafting-layout,.finalization-layout{height:100%;min-height:0;display:grid;grid-template-columns:minmax(210px,.7fr) minmax(480px,2.15fr) minmax(280px,.95fr);gap:12px}.draft-ideas,.draft-assistant,.manuscript-panel,.editorial-rail,.release-panel{display:flex;min-height:0;overflow:hidden;flex-direction:column}.draft-ideas,.draft-assistant,.editorial-rail,.release-panel{overflow:auto}.relation{padding:.9rem 1rem;border-bottom:1px solid var(--line)}.relation label,.settings-section>label{display:flex;flex-direction:column;gap:.35rem;color:var(--muted);font-size:.67rem;font-weight:700}.relation select,.settings-section select,.settings-section input,.feature-card input,.feature-card textarea{width:100%;padding:.55rem;border:1px solid var(--line-dark);border-radius:4px;background:var(--field-bg);color:var(--field-ink)}.draft-idea{display:flex;gap:.65rem;margin:.75rem .8rem 0;padding:.75rem;border:1px solid var(--line);border-radius:5px;background:white;cursor:pointer}.draft-idea.selected{border-color:#abbba6;background:#edf2ea}.draft-idea input{accent-color:var(--moss)}.draft-idea span{min-width:0}.draft-idea strong,.draft-idea small{display:block}.draft-idea strong{font:600 .82rem Georgia,serif}.draft-idea small{margin-top:.35rem;color:var(--muted);font-size:.65rem;line-height:1.4}.draft-ideas>.secondary-button{margin:1rem;width:calc(100% - 2rem)}.shortcut{margin:auto 1rem 1rem;color:var(--muted);font-size:.61rem;text-align:center}.editor-toolbar{min-height:75px;display:flex;justify-content:space-between;align-items:center;padding:1rem 1.4rem;border-bottom:1px solid var(--line)}.editor-toolbar>div:last-child{display:flex;align-items:center;gap:.8rem}.editor-toolbar button{border:1px solid var(--line);background:white;border-radius:4px;padding:.48rem .65rem;font-size:.66rem}.editor-toolbar span{color:var(--muted);font-size:.66rem}.manuscript-editor{flex:1;min-height:0;resize:none;border:0;outline:0;padding:3.5rem clamp(2rem,7vw,7rem);background:var(--field-bg);color:var(--field-ink);font:1.05rem/1.85 Georgia,"Times New Roman",serif;caret-color:var(--clay);tab-size:2}.editor-footer{display:flex;justify-content:space-between;padding:.55rem 1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.61rem}.assistant-context{margin:1rem;color:var(--muted);font-size:.73rem;line-height:1.5}.draft-assistant>.primary-button{margin:0 1rem;width:calc(100% - 2rem)}.generation-preview{margin:1rem;padding:1rem;border:1px solid #d8c5ac;border-radius:6px;background:#fffbf1}.generation-preview.compact{margin:1rem 0}.preview-label{display:flex;align-items:center;justify-content:space-between;margin-bottom:.8rem;font-size:.7rem;font-weight:800}.generation-preview>p{font:italic .88rem/1.65 Georgia,serif;color:#464239}.preview-actions{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.9rem;padding-top:.8rem;border-top:1px solid #e8dcc8}.preview-actions button{border:1px solid var(--line);background:white;padding:.42rem .55rem;border-radius:4px;font-size:.62rem}.preview-actions .primary-button{background:var(--active-bg);color:var(--active-ink);border-color:var(--active-bg)}.preview-actions .danger-text{color:#9b4c3f}.empty{margin:auto;padding:2rem 1.4rem;text-align:center;color:var(--muted)}.empty h2{font-size:1.08rem;color:var(--ink);margin-bottom:.5rem}.empty p{font-size:.72rem;line-height:1.5}.finalization-layout{grid-template-columns:minmax(180px,.62fr) minmax(500px,2.15fr) minmax(250px,.9fr)}.editorial-rail,.release-panel{padding:1.25rem}.editorial-rail>p,.release-panel>p{color:var(--muted);font-size:.72rem;line-height:1.5}.action-stack{display:flex;flex-direction:column;margin-top:1.2rem}.action-stack button{display:flex;justify-content:space-between;padding:.75rem .15rem;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;font:600 .8rem Georgia,serif}.action-stack button:hover{padding-left:.4rem;color:var(--clay)}.release-panel h2,.editorial-rail h2{margin-bottom:.5rem}.release-check{display:flex;align-items:center;gap:.7rem;padding:.8rem 0;border-bottom:1px solid var(--line)}.release-check>span{display:grid;place-items:center;width:22px;height:22px;border-radius:50%;background:var(--moss-soft);color:var(--moss);font-size:.68rem}.release-check strong,.release-check small{display:block}.release-check strong{font-size:.73rem}.release-check small{margin-top:.15rem;color:var(--muted);font-size:.61rem}.release-panel>.secondary-button{margin-top:auto}.release-button{margin-top:.6rem;border:1px solid var(--accent-button-bg);background:var(--accent-button-bg);color:var(--accent-button-ink)}.fine-print{text-align:center;font-size:.6rem!important}.page-layout{max-width:1440px;margin:0 auto;padding:2.5rem 1rem 4rem}.page-heading{margin:0 0 2rem}.page-heading h1{font-size:2.15rem;margin-bottom:.5rem}.page-heading>p,.page-heading>div>p{margin:0;color:var(--muted);font:1rem/1.5 Georgia,serif}.provenance-heading{display:flex;justify-content:space-between;align-items:end}.overview-grid{display:grid;grid-template-columns:1.6fr 1fr;gap:1rem}.feature-card,.export-card{position:relative;padding:1.7rem;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow)}.feature-card.wide{grid-row:span 2}.card-index,.export-type{display:block;margin-bottom:1.2rem;color:var(--clay);font-size:.61rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}.feature-card h2,.export-card h2{font-size:1.55rem;margin-bottom:.8rem}.feature-card p,.export-card p{color:var(--muted);line-height:1.55}.feature-card input{font:1.25rem Georgia,serif;margin:.8rem 0}.feature-card textarea{min-height:85px;resize:vertical}.stat-row{display:flex;gap:2.5rem;margin-top:2rem;padding-top:1.3rem;border-top:1px solid var(--line)}.stat-row strong,.stat-row span{display:block}.stat-row strong{font:1.7rem Georgia,serif}.stat-row span{color:var(--muted);font-size:.65rem}.history-layout,.provenance-layout{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,.7fr);gap:1rem}.version-list,.timeline{padding:1rem}.version-row{display:grid;grid-template-columns:30px 1fr auto;align-items:center;gap:.8rem;padding:1rem;border-bottom:1px solid var(--line)}.version-symbol{color:var(--clay)}.version-row strong,.version-row span{display:block}.version-row strong{font:600 .9rem Georgia,serif}.version-row span{margin-top:.25rem;color:var(--muted);font-size:.65rem}.version-row button{border:1px solid var(--line);background:white;padding:.4rem .55rem;margin-left:.35rem;border-radius:4px;font-size:.63rem}.history-aside,.contribution{padding:1.4rem;height:max-content}.history-aside h2,.contribution h2{margin-bottom:.7rem}.history-aside p,.contribution>p{color:var(--muted);font-size:.75rem;line-height:1.5}.event-row{display:grid;grid-template-columns:32px 1fr;gap:.8rem;padding:1rem;border-bottom:1px solid var(--line)}.actor-mark{display:grid;place-items:center;width:28px;height:28px;border-radius:50%;font-size:.63rem;font-weight:800}.actor-user{background:var(--moss-soft);color:var(--moss)}.actor-assistant{background:var(--clay-soft);color:var(--clay)}.actor-system{background:#e9e8e3;color:#666}.event-top{display:flex;justify-content:space-between;gap:1rem}.event-top strong{font:600 .86rem Georgia,serif}.event-top time{color:var(--muted);font-size:.61rem}.event-row p{margin:.3rem 0;color:var(--muted);font-size:.64rem}.event-row code{color:var(--muted);font-size:.56rem}.contribution-chain{display:flex;flex-direction:column;align-items:center;gap:.35rem;margin:1.2rem 0}.contribution-chain span{width:100%;padding:.7rem;border:1px solid var(--line);border-radius:4px;background:white;text-align:center;font-size:.67rem}.contribution-chain i{color:var(--clay);font-style:normal}.export-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}.export-card{min-height:340px;display:flex;flex-direction:column}.export-card.featured{background:#26322a;color:white;border-color:#26322a}.export-card.featured p{color:#cbd3cb}.export-card.featured .export-type{color:#adc2ab}.export-card>button{margin-top:auto;width:max-content}.export-buttons{display:flex;flex-direction:column;margin-top:auto}.export-buttons button{display:flex;justify-content:space-between;padding:.72rem 0;border:0;border-bottom:1px solid var(--line);background:none;text-align:left}.export-buttons span{color:var(--muted);font-size:.65rem}.settings-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1rem}.settings-section{padding:1.5rem}.settings-section:last-child{grid-column:1/-1}.settings-heading{display:flex;gap:1rem;padding-bottom:1rem;border-bottom:1px solid var(--line);margin-bottom:1rem}.settings-number{color:var(--clay);font:italic 1.2rem Georgia,serif}.settings-heading h2{font-size:1.15rem}.settings-heading p{margin:.2rem 0 0;color:var(--muted);font-size:.7rem}.settings-section>label{margin-bottom:.85rem}.toggle-row{display:flex!important;flex-direction:row!important;align-items:flex-start;gap:.7rem!important;padding:1rem;border:1px solid var(--line);border-radius:5px;background:white;cursor:pointer}.toggle-row input{width:auto!important;accent-color:var(--moss)}.toggle-row strong,.toggle-row small{display:block}.toggle-row small{margin-top:.25rem;color:var(--muted);font-weight:400;line-height:1.4}.cloud-warning{padding:.8rem;margin-bottom:.8rem;border-left:3px solid var(--gold);background:#f6eed8;font-size:.7rem}.cloud-warning p{color:#6f6348}.cloud-warning button{border:1px solid #cdbb8e;background:var(--field-bg);color:var(--field-ink);padding:.45rem;border-radius:3px}.trait-list label{display:grid;grid-template-columns:80px 1fr;align-items:center;gap:.6rem;margin-bottom:.5rem}.confidence{color:var(--moss);font-size:.61rem;text-transform:uppercase}.inline-input{display:flex}.inline-input input{border-radius:4px 0 0 4px}.inline-input button{border:1px solid var(--line-dark);border-left:0;background:var(--control-bg);border-radius:0 4px 4px 0}.app-footer{height:28px;flex:none;display:flex;justify-content:space-between;align-items:center;padding:0 1rem;border-top:1px solid var(--line);background:var(--panel);color:var(--muted);font-size:.57rem} +.drafting-layout,.finalization-layout{height:100%;min-height:0;display:grid;grid-template-columns:minmax(210px,.7fr) minmax(480px,2.15fr) minmax(280px,.95fr);gap:12px}.draft-ideas,.draft-assistant,.manuscript-panel,.editorial-rail,.release-panel{display:flex;min-height:0;overflow:hidden;flex-direction:column}.draft-ideas,.draft-assistant,.editorial-rail,.release-panel{overflow:auto}.relation{padding:.9rem 1rem;border-bottom:1px solid var(--line)}.relation label,.settings-section>label{display:flex;flex-direction:column;gap:.35rem;color:var(--muted);font-size:.67rem;font-weight:700}.relation select,.settings-section select,.settings-section input,.feature-card input,.feature-card textarea{width:100%;padding:.55rem;border:1px solid var(--line-dark);border-radius:4px;background:var(--field-bg);color:var(--field-ink)}.draft-idea{display:flex;gap:.65rem;margin:.75rem .8rem 0;padding:.75rem;border:1px solid var(--line);border-radius:5px;background:white;cursor:pointer}.draft-idea.selected{border-color:#abbba6;background:#edf2ea}.draft-idea input{accent-color:var(--moss)}.draft-idea span{min-width:0}.draft-idea strong,.draft-idea small{display:block}.draft-idea strong{font:600 .82rem Georgia,serif}.draft-idea small{margin-top:.35rem;color:var(--muted);font-size:.65rem;line-height:1.4}.draft-ideas>.secondary-button{margin:1rem;width:calc(100% - 2rem)}.shortcut{margin:auto 1rem 1rem;color:var(--muted);font-size:.61rem;text-align:center}.editor-toolbar{min-height:75px;display:flex;justify-content:space-between;align-items:center;padding:1rem 1.4rem;border-bottom:1px solid var(--line)}.editor-toolbar>div:last-child{display:flex;align-items:center;gap:.8rem}.editor-toolbar button{border:1px solid var(--line);background:white;border-radius:4px;padding:.48rem .65rem;font-size:.66rem}.editor-toolbar span{color:var(--muted);font-size:.66rem}.manuscript-editor{flex:1;min-height:0;resize:none;border:0;outline:0;padding:3.5rem clamp(2rem,7vw,7rem);background:var(--field-bg);color:var(--field-ink);font:1.05rem/1.85 Georgia,"Times New Roman",serif;caret-color:var(--clay);tab-size:2}.editor-footer{display:flex;justify-content:space-between;padding:.55rem 1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.61rem}.assistant-context{margin:1rem;color:var(--muted);font-size:.73rem;line-height:1.5}.draft-assistant>.primary-button{margin:0 1rem;width:calc(100% - 2rem)}.generation-preview{margin:1rem;padding:1rem;border:1px solid #d8c5ac;border-radius:6px;background:#fffbf1}.generation-preview.compact{margin:1rem 0}.preview-label{display:flex;align-items:center;justify-content:space-between;margin-bottom:.8rem;font-size:.7rem;font-weight:800}.generation-preview>p{font:italic .88rem/1.65 Georgia,serif;color:#464239}.preview-actions{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.9rem;padding-top:.8rem;border-top:1px solid #e8dcc8}.preview-actions button{border:1px solid var(--line);background:white;padding:.42rem .55rem;border-radius:4px;font-size:.62rem}.preview-actions .primary-button{background:var(--active-bg);color:var(--active-ink);border-color:var(--active-bg)}.preview-actions .danger-text{color:#9b4c3f}.empty{margin:auto;padding:2rem 1.4rem;text-align:center;color:var(--muted)}.empty h2{font-size:1.08rem;color:var(--ink);margin-bottom:.5rem}.empty p{font-size:.72rem;line-height:1.5}.finalization-layout{grid-template-columns:minmax(180px,.62fr) minmax(500px,2.15fr) minmax(250px,.9fr)}.editorial-rail,.release-panel{padding:1.25rem}.editorial-rail>p,.release-panel>p{color:var(--muted);font-size:.72rem;line-height:1.5}.action-stack{display:flex;flex-direction:column;margin-top:1.2rem}.action-stack button{display:flex;justify-content:space-between;padding:.75rem .15rem;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;font:600 .8rem Georgia,serif}.action-stack button:hover{padding-left:.4rem;color:var(--clay)}.release-panel h2,.editorial-rail h2{margin-bottom:.5rem}.release-check{display:flex;align-items:center;gap:.7rem;padding:.8rem 0;border-bottom:1px solid var(--line)}.release-check>span{display:grid;place-items:center;width:22px;height:22px;border-radius:50%;background:var(--moss-soft);color:var(--moss);font-size:.68rem}.release-check strong,.release-check small{display:block}.release-check strong{font-size:.73rem}.release-check small{margin-top:.15rem;color:var(--muted);font-size:.61rem}.release-panel>.secondary-button{margin-top:auto}.release-button{margin-top:.6rem;border:1px solid var(--accent-button-bg);background:var(--accent-button-bg);color:var(--accent-button-ink)}.fine-print{text-align:center;font-size:.6rem!important}.page-layout{max-width:1440px;margin:0 auto;padding:2.5rem 1rem 4rem}.page-heading{margin:0 0 2rem}.page-heading h1{font-size:2.15rem;margin-bottom:.5rem}.page-heading>p,.page-heading>div>p{margin:0;color:var(--muted);font:1rem/1.5 Georgia,serif}.provenance-heading{display:flex;justify-content:space-between;align-items:end}.overview-grid{display:grid;grid-template-columns:1.6fr 1fr;gap:1rem}.feature-card,.export-card{position:relative;padding:1.7rem;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow)}.feature-card.wide{grid-row:span 2}.card-index,.export-type{display:block;margin-bottom:1.2rem;color:var(--clay);font-size:.61rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}.feature-card h2,.export-card h2{font-size:1.55rem;margin-bottom:.8rem}.feature-card p,.export-card p{color:var(--muted);line-height:1.55}.feature-card input{font:1.25rem Georgia,serif;margin:.8rem 0}.feature-card textarea{min-height:85px;resize:vertical}.stat-row{display:flex;gap:2.5rem;margin-top:2rem;padding-top:1.3rem;border-top:1px solid var(--line)}.stat-row strong,.stat-row span{display:block}.stat-row strong{font:1.7rem Georgia,serif}.stat-row span{color:var(--muted);font-size:.65rem}.history-layout,.provenance-layout{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,.7fr);gap:1rem}.version-list,.timeline{padding:1rem}.version-row{display:grid;grid-template-columns:30px 1fr auto;align-items:center;gap:.8rem;padding:1rem;border-bottom:1px solid var(--line)}.version-symbol{color:var(--clay)}.version-row strong,.version-row span{display:block}.version-row strong{font:600 .9rem Georgia,serif}.version-row span{margin-top:.25rem;color:var(--muted);font-size:.65rem}.version-row button{border:1px solid var(--line);background:white;padding:.4rem .55rem;margin-left:.35rem;border-radius:4px;font-size:.63rem}.history-aside,.contribution{padding:1.4rem;height:max-content}.history-aside h2,.contribution h2{margin-bottom:.7rem}.history-aside p,.contribution>p{color:var(--muted);font-size:.75rem;line-height:1.5}.event-row{display:grid;grid-template-columns:32px 1fr;gap:.8rem;padding:1rem;border-bottom:1px solid var(--line)}.actor-mark{display:grid;place-items:center;width:28px;height:28px;border-radius:50%;font-size:.63rem;font-weight:800}.actor-user{background:var(--moss-soft);color:var(--moss)}.actor-assistant{background:var(--clay-soft);color:var(--clay)}.actor-system{background:#e9e8e3;color:#666}.event-top{display:flex;justify-content:space-between;gap:1rem}.event-top strong{font:600 .86rem Georgia,serif}.event-top time{color:var(--muted);font-size:.61rem}.event-row p{margin:.3rem 0;color:var(--muted);font-size:.64rem}.event-row code{color:var(--muted);font-size:.56rem}.contribution-chain{display:flex;flex-direction:column;align-items:center;gap:.35rem;margin:1.2rem 0}.contribution-chain span{width:100%;padding:.7rem;border:1px solid var(--line);border-radius:4px;background:white;text-align:center;font-size:.67rem}.contribution-chain i{color:var(--clay);font-style:normal}.export-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}.export-card{min-height:340px;display:flex;flex-direction:column}.export-card.featured{background:#26322a;color:white;border-color:#26322a}.export-card.featured p{color:#cbd3cb}.export-card.featured .export-type{color:#adc2ab}.export-card>button{margin-top:auto;width:max-content}.export-buttons{display:flex;flex-direction:column;margin-top:auto}.export-buttons button{display:flex;justify-content:space-between;padding:.72rem 0;border:0;border-bottom:1px solid var(--line);background:none;text-align:left}.export-buttons span{color:var(--muted);font-size:.65rem}.settings-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1rem}.settings-section{padding:1.5rem}.settings-section.wide{grid-column:1/-1}.settings-heading{display:flex;gap:1rem;padding-bottom:1rem;border-bottom:1px solid var(--line);margin-bottom:1rem}.settings-number{color:var(--clay);font:italic 1.2rem Georgia,serif}.settings-heading h2{font-size:1.15rem}.settings-heading p{margin:.2rem 0 0;color:var(--muted);font-size:.7rem}.settings-section>label{margin-bottom:.85rem}.toggle-row{display:flex!important;flex-direction:row!important;align-items:flex-start;gap:.7rem!important;padding:1rem;border:1px solid var(--line);border-radius:5px;background:white;cursor:pointer}.toggle-row input{width:auto!important;accent-color:var(--moss)}.toggle-row strong,.toggle-row small{display:block}.toggle-row small{margin-top:.25rem;color:var(--muted);font-weight:400;line-height:1.4}.cloud-warning{padding:.8rem;margin-bottom:.8rem;border-left:3px solid var(--gold);background:#f6eed8;font-size:.7rem}.cloud-warning p{color:#6f6348}.cloud-warning button{border:1px solid #cdbb8e;background:var(--field-bg);color:var(--field-ink);padding:.45rem;border-radius:3px}.trait-list label{display:grid;grid-template-columns:80px 1fr;align-items:center;gap:.6rem;margin-bottom:.5rem}.confidence{color:var(--moss);font-size:.61rem;text-transform:uppercase}.inline-input{display:flex}.inline-input input{border-radius:4px 0 0 4px}.inline-input button{border:1px solid var(--line-dark);border-left:0;background:var(--control-bg);border-radius:0 4px 4px 0}.prompt-settings>p,.prompt-settings>small{display:block;color:var(--muted);font-size:.72rem;line-height:1.55}.prompt-path{display:block;margin:1rem 0;padding:.75rem;border:1px solid var(--line-dark);border-radius:4px;background:var(--field-bg);color:var(--field-ink);overflow-wrap:anywhere;user-select:text}.prompt-settings .project-actions{margin:0 0 1rem}.app-footer{height:28px;flex:none;display:flex;justify-content:space-between;align-items:center;padding:0 1rem;border-top:1px solid var(--line);background:var(--panel);color:var(--muted);font-size:.57rem} @media(max-width:1100px){.project-identity span,.save-state{display:none}.utility-nav button{padding:0 .5rem}.workspace{padding:12px}.ideation-layout{grid-template-columns:1.3fr .8fr}.drafting-layout,.finalization-layout{height:100%;grid-template-columns:minmax(150px,.55fr) minmax(400px,1.75fr) minmax(220px,.8fr)}.draft-assistant,.release-panel{grid-column:auto;max-height:none}.manuscript-editor{min-height:0;padding:2.5rem clamp(1.5rem,5vw,3.5rem)}.export-grid{grid-template-columns:1fr 1fr}.export-card:last-child{grid-column:1/-1}} -@media(max-width:760px){.project-bar{padding:0 1rem}.project-identity,.history-state{display:none}.main-nav{height:auto;min-height:53px;padding:0 .5rem;overflow-x:auto}.main-nav button{white-space:nowrap}.utility-nav{margin-left:1rem}.notice{padding:.4rem 1rem;text-align:center}.workspace{padding:8px}.ideation-layout,.drafting-layout,.finalization-layout{height:auto;min-height:0;display:flex;flex-direction:column}.conversation-panel{height:720px}.ideas-panel{max-height:700px}.draft-ideas,.editorial-rail{order:2}.manuscript-panel{order:1}.draft-assistant,.release-panel{order:3}.manuscript-editor{min-height:65vh;padding:2rem 1.5rem}.page-layout{padding:1.5rem .25rem}.overview-grid,.history-layout,.provenance-layout,.export-grid,.settings-grid{grid-template-columns:1fr}.feature-card.wide{grid-row:auto}.settings-section:last-child,.export-card:last-child{grid-column:auto}.provenance-heading{align-items:start;gap:1rem}.page-heading h1{font-size:1.75rem}.app-footer span:nth-child(2){display:none}} +@media(max-width:760px){.project-bar{padding:0 1rem}.project-identity,.history-state{display:none}.main-nav{height:auto;min-height:53px;padding:0 .5rem;overflow-x:auto}.main-nav button{white-space:nowrap}.utility-nav{margin-left:1rem}.notice{padding:.4rem 1rem;text-align:center}.workspace{padding:8px}.ideation-layout,.drafting-layout,.finalization-layout{height:auto;min-height:0;display:flex;flex-direction:column}.conversation-panel{height:720px}.ideas-panel{max-height:700px}.draft-ideas,.editorial-rail{order:2}.manuscript-panel{order:1}.draft-assistant,.release-panel{order:3}.manuscript-editor{min-height:65vh;padding:2rem 1.5rem}.page-layout{padding:1.5rem .25rem}.overview-grid,.history-layout,.provenance-layout,.export-grid,.settings-grid{grid-template-columns:1fr}.feature-card.wide{grid-row:auto}.settings-section.wide,.export-card:last-child{grid-column:auto}.provenance-heading{align-items:start;gap:1rem}.page-heading h1{font-size:1.75rem}.app-footer span:nth-child(2){display:none}} @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}} @media(prefers-color-scheme:dark){:root{--ink:#f0ede4;--muted:#c1bcb2;--paper:#1d1e1b;--panel:#252622;--line:#3c3d36;--line-dark:#55564d;--moss-soft:#344032;--clay-soft:#4a352c;--field-bg:#292a26;--field-ink:#f5f2e9;--field-muted:#c7c2b8;--field-disabled:#343530;--control-bg:#373833;--selection-bg:#40513e;--selection-ink:#fff;--toolbar-bg:rgba(37,38,34,.94);--active-bg:#e8e4da;--active-hover-bg:#d3cfc5;--active-ink:#1d1e1b;--accent-button-bg:#d29173;--accent-hover-bg:#e0a083;--accent-button-ink:#211a17}body{background:var(--paper)}.project-bar{background:rgba(37,38,34,.9)}.brand-mark{background:#eeeae0;color:#1e1f1c}.composer,.idea-card,.draft-idea,.editor-toolbar button,.secondary-button,.preview-actions button,.contribution-chain span,.toggle-row,.version-row button{background:#2c2d29;color:var(--ink)}.turn p{background:#30312c}.turn.user p{background:#354034}.generation-preview{background:#342f25}.export-card.featured{background:#324034}.mark{background:#30312d}.segmented{background:#30312d}.segmented button.active{background:#45463f}.notice{background:#3a3425}.cloud-warning{background:#3a3425}.draft-idea.selected{background:#344032}.app-footer{background:var(--panel)}} .project-actions{display:flex;flex-wrap:wrap;gap:.45rem}.project-path{display:block;margin-top:.75rem;color:var(--muted);overflow-wrap:anywhere} diff --git a/tests/native-app.test.mjs b/tests/native-app.test.mjs index 4519168..38263e1 100644 --- a/tests/native-app.test.mjs +++ b/tests/native-app.test.mjs @@ -93,3 +93,48 @@ test("never ships retained audio assets", async () => { const files = await walk(root); assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false); }); + +test("externalizes and documents every model prompt", async () => { + const [conversationRaw, draftingRaw, source, rust, guide, packageRaw, tauriRaw, cargoRaw] = await Promise.all([ + readFile(new URL("../src-tauri/prompts/conversation.json", import.meta.url), "utf8"), + readFile(new URL("../src-tauri/prompts/drafting.json", import.meta.url), "utf8"), + readFile(new URL("../src/Thinkloom.tsx", import.meta.url), "utf8"), + readFile(new URL("../src-tauri/src/lib.rs", import.meta.url), "utf8"), + readFile(new URL("../PROMPTS.md", 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"), + readFile(new URL("../src-tauri/Cargo.toml", import.meta.url), "utf8"), + ]); + const conversation = JSON.parse(conversationRaw); + const drafting = JSON.parse(draftingRaw); + + assert.equal(conversation.schemaVersion, 1); + assert.match(conversation.systemPrompt, /Thinkloom/i); + assert.match(conversation.userPromptTemplate, /\{\{challenge_guidance\}\}/); + assert.match(conversation.userPromptTemplate, /\{\{context\}\}/); + assert.deepEqual(Object.keys(conversation.challengeGuidance).sort(), ["Balanced", "Gentle", "Rigorous"]); + assert.equal(drafting.schemaVersion, 1); + assert.match(drafting.draftPromptTemplate, /\{\{relation\}\}/); + assert.match(drafting.editorialPromptTemplate, /\{\{action\}\}/); + assert.match(drafting.draftPromptTemplate, /\{\{context\}\}/); + assert.match(drafting.editorialPromptTemplate, /\{\{context\}\}/); + + assert.match(source, /promptVariables/); + assert.match(source, /ensure_prompt_files/); + assert.match(source, /Open prompt folder/); + assert.doesNotMatch(source, /const challengeGuidance|const variants: Record/); + assert.match(rust, /include_str!\("\.\.\/prompts\/conversation\.json"\)/); + assert.match(rust, /include_str!\("\.\.\/prompts\/drafting\.json"\)/); + assert.match(rust, /Prompt files reload before every model request/); + assert.doesNotMatch(rust, /You are Thinkloom, a focused writing collaborator in an ideation conversation/); + for (const phrase of ["Files and effects", "Editing safely", "Resetting a prompt", "Privacy and security"]) { + assert.match(guide, new RegExp(phrase, "i")); + } + + const version = JSON.parse(packageRaw).version; + assert.equal(version, "0.2.0"); + assert.equal(JSON.parse(tauriRaw).version, version); + const escapedVersion = version.replaceAll(".", "\\."); + assert.match(cargoRaw, new RegExp("^version = \"" + escapedVersion + "\"$", "m")); + assert.match(source, new RegExp("Thinkloom " + escapedVersion)); +}); \ No newline at end of file