An offline-first branching dialogue workstation for Unity and Pixel Crushers.
- Write, localize, review, playtest, version, and export a story from macOS or an Android tablet.
+ Write, localize, review, playtest, version, and export a story from macOS, Windows, or an Android tablet.
@@ -49,32 +49,32 @@ The screenshots use the disposable demo project created by [`scripts/create-demo
- A visual graph for line, choice, junction, and cross-conversation flow, with direct connector dragging and create-on-drop.
- Permanent Line IDs, stable Pixel Crushers numeric mappings, conditions, Lua, sequences, tags, variables, cast metadata, and voice context.
-- Side-by-side languages, translation coverage, local validation, read view, text-only playtest, beat map, review snapshots, and search.
+- Side-by-side languages, a 47-language game catalog with BCP 47 custom tags, translation coverage, local validation, read view, text-only playtest, beat map, review snapshots, and search.
- Autosave with a visible state, SQLite/WAL history, rolling and named backups, deterministic JSON, and reversible or confirmed destructive actions.
- Voice-actor PDFs, studio XLSX/CSV, screenplay Markdown, word/minute budgets, audio manifests, and Pixel Crushers JSON.
- Optional local Git checkpoints, branches, GitHub sync, and pull-request review from inside Reed.
- A local stdio MCP server and CLI backed by the same versioned core contracts as the editor.
-## macOS and Android
+## Desktop and Android
-Reed has two native application shells over equivalent project rules.
+Reed has native desktop and Android application shells over equivalent project rules.
-| | macOS | Android / Daylight DC-1 |
-|---|---|---|
-| Interface | Electron, React, and React Flow | Kotlin and Jetpack Compose |
-| Current target | Apple silicon | Android 13+; tested on Daylight DC-1 |
-| Storage | `@reed/core`, SQLite in WAL mode | Android `ReedCore`, SQLite in WAL mode |
-| Input | Keyboard, mouse, and trackpad | Touch, hardware keyboard, mouse, trackpad, and Wacom EMR pen |
-| Canvas | Marquee select, live node drag, connector drop-to-create, trackpad pan, focal zoom | Responsive panes, pen hover, side-button marquee, connector drag, two-finger pan, focal pinch zoom |
-| Git | Bundled/system `git` and GitHub CLI credential store | Embedded JGit and optional Android Keystore token |
-| External agents | Local stdio MCP or CLI | Explicit foreground Remote Agent Access session |
-| Offline default | Yes | Yes |
+| | macOS | Windows | Android / Daylight DC-1 |
+|---|---|---|---|
+| Interface | Electron, React, and React Flow | Electron, React, and React Flow | Kotlin and Jetpack Compose |
+| Current target | Apple silicon | Windows 10/11 x64 | Android 13+; tested on Daylight DC-1 |
+| Storage | `@reed/core`, SQLite in WAL mode | `@reed/core`, SQLite in WAL mode | Android `ReedCore`, SQLite in WAL mode |
+| Input | Keyboard, mouse, and trackpad | Keyboard, mouse, and precision touchpad | Touch, hardware keyboard, mouse, trackpad, and Wacom EMR pen |
+| Canvas | Marquee select, live node drag, connector drop-to-create, trackpad pan, focal zoom | Same desktop graph interactions | Responsive panes, pen hover, side-button marquee, connector drag, two-finger pan, focal pinch zoom |
+| Git | System `git`; bundled or system GitHub CLI | Git for Windows and GitHub CLI | Embedded JGit and optional Android Keystore token |
+| External agents | Local stdio MCP or CLI | Local stdio MCP or CLI | Explicit foreground Remote Agent Access session |
+| Offline default | Yes | Yes | Yes |
-### macOS
+### macOS and Windows
Download the latest Apple-silicon build from [GitHub Releases](https://github.com/is2b007/Reed/releases/latest), or build from source below. Published binary releases currently trail the active source tree, so contributors testing the newest Android, GitHub, and remote-agent work should build from `main`.
-The current development app is locally signed but not Apple-notarized. If macOS blocks it, Control-click **Reed.app**, choose **Open**, and confirm once. Intel macOS, Windows, and Linux packages are not currently produced.
+The macOS development app is locally signed but not Apple-notarized. If macOS blocks it, Control-click **Reed.app**, choose **Open**, and confirm once. The Windows ZIP is unsigned, so Windows may show a SmartScreen warning. Optional desktop GitHub collaboration uses the locally installed Git and GitHub CLI when a compatible CLI is not bundled.
Build a local application bundle:
@@ -83,10 +83,11 @@ pnpm install --frozen-lockfile
CI=true pnpm typecheck
CI=true pnpm exec vitest run --no-file-parallelism --maxWorkers=1
CI=true pnpm test:offline
-CI=true pnpm --filter @reed/editor package
+CI=true pnpm desktop:make:mac
+CI=true pnpm desktop:make:windows
```
-The application is written to `apps/editor/out/Reed-darwin-arm64/Reed.app`.
+The macOS DMG/ZIP and Windows x64 ZIP are written beneath `apps/editor/out/make/`. Building Windows from macOS downloads the target Electron runtime and target-native `better-sqlite3` prebuild; the resulting archive runs without a package-install step.
### Android and Daylight DC-1
@@ -110,7 +111,7 @@ Read [Android and Daylight DC-1 setup](docs/ANDROID_DC1.md) and the [Android arc
```mermaid
flowchart LR
- UI["macOS or Android editor"] --> Core["Reed Core contracts"]
+ UI["macOS, Windows, or Android editor"] --> Core["Reed Core contracts"]
CLI["Local CLI"] --> Core
MCP["Local stdio MCP"] --> Core
Core --> DB["SQLite + WAL"]
@@ -225,6 +226,7 @@ The platform-level view is in [ARCHITECTURE.md](ARCHITECTURE.md).
| [AI MCP playbook](docs/AI_MCP_PLAYBOOK.md) | Safe agent operating sequence |
| [Project history](docs/PROJECT_HISTORY.md) | Backups, local Git, optional GitHub, and pull requests |
| [Voice production](docs/VOICE_PRODUCTION.md) | Actor and studio export workflow |
+| [Languages and game exports](docs/LOCALIZATION_EXPORTS.md) | BCP 47 catalog plus Unity, Godot, Unreal, Yarn, PO, XLIFF, JSON, and CSV handoff |
| [Offline contract](docs/OFFLINE_CONTRACT.md) | Allowed networking and release acceptance |
| [Android / DC-1](docs/ANDROID_DC1.md) | Build, install, input, Git, and device verification |
| [Remote Android MCP](docs/REMOTE_MCP_ANDROID.md) | Claude connector model and relay deployment |
diff --git a/apps/android/app/src/main/java/com/reed/editor/MainActivity.kt b/apps/android/app/src/main/java/com/reed/editor/MainActivity.kt
index 00afa68..2964078 100644
--- a/apps/android/app/src/main/java/com/reed/editor/MainActivity.kt
+++ b/apps/android/app/src/main/java/com/reed/editor/MainActivity.kt
@@ -77,6 +77,7 @@ import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Translate
@@ -166,6 +167,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.reed.core.DialogueNodeRecord
+import com.reed.core.GameLanguages
import com.reed.core.NodeKind
import com.reed.core.ProjectOverview
import com.reed.core.TextStatus
@@ -433,6 +435,7 @@ private fun ProjectScreen(
Workspace.VARIABLES -> VariableWorkspace(state, viewModel, Modifier.weight(1f))
Workspace.GIT -> GitWorkspace(state, viewModel, onCheckpoint, Modifier.weight(1f))
Workspace.AGENT -> AgentAccessWorkspace(state, viewModel, Modifier.weight(1f))
+ Workspace.SETTINGS -> ProjectSettingsWorkspace(state, viewModel, Modifier.weight(1f))
}
}
}
@@ -519,6 +522,7 @@ private fun WorkspaceRail(selected: Workspace, viewModel: ReedViewModel) {
Workspace.VARIABLES -> Icons.Outlined.DataObject
Workspace.GIT -> Icons.Outlined.Source
Workspace.AGENT -> Icons.Outlined.Hub
+ Workspace.SETTINGS -> Icons.Outlined.Settings
}
val isSelected = workspace == selected
Column(
@@ -2103,6 +2107,129 @@ private fun StatusBar(state: ReedUiState) {
}
}
+@Composable
+private fun ProjectSettingsWorkspace(state: ReedUiState, viewModel: ReedViewModel, modifier: Modifier = Modifier) {
+ val project = state.activeProject ?: return
+ var languages by remember(project.id, project.updatedAt) { mutableStateOf(project.languages.toSet()) }
+ var defaultLanguage by remember(project.id, project.updatedAt) { mutableStateOf(project.defaultLanguage) }
+ var query by remember { mutableStateOf("") }
+ var customTag by remember { mutableStateOf("") }
+ var defaultMenuOpen by remember { mutableStateOf(false) }
+ val filtered = remember(query) {
+ val term = query.trim().lowercase()
+ if (term.isBlank()) GameLanguages.catalog else GameLanguages.catalog.filter {
+ it.code.lowercase().contains(term) || it.englishName.lowercase().contains(term) || it.nativeName.lowercase().contains(term)
+ }
+ }
+ val canonicalCustom = GameLanguages.canonicalize(customTag)
+ val customValid = GameLanguages.isValidTag(canonicalCustom) && languages.none { it.equals(canonicalCustom, true) }
+ val changed = languages.toList() != project.languages || defaultLanguage != project.defaultLanguage
+
+ fun setLanguages(next: Set) {
+ languages = next
+ if (next.isNotEmpty() && defaultLanguage !in next) defaultLanguage = next.first()
+ }
+
+ LazyColumn(
+ modifier.fillMaxSize().padding(horizontal = 18.dp),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 18.dp),
+ ) {
+ item {
+ Text("Project settings", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
+ Text("Configure the language contract stored with this project. Every change is versioned and remains local.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.height(18.dp))
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text("Languages", fontWeight = FontWeight.SemiBold)
+ Text("IETF BCP 47 tags · ${languages.size} enabled", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ OutlinedButton(onClick = { setLanguages(GameLanguages.gameMarketPreset().toSet()) }) { Text("Game preset") }
+ }
+ Spacer(Modifier.height(8.dp))
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ languages.forEach { code ->
+ OutlinedButton(onClick = { setLanguages(languages - code) }, shape = RoundedCornerShape(4.dp)) {
+ Text(GameLanguages.displayName(code), maxLines = 1)
+ Spacer(Modifier.width(6.dp))
+ Text(code, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
+ Spacer(Modifier.width(6.dp))
+ Text("×")
+ }
+ }
+ }
+ Spacer(Modifier.height(12.dp))
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Find a language") },
+ placeholder = { Text("Name or tag") },
+ leadingIcon = { Icon(Icons.Outlined.Search, null) },
+ singleLine = true,
+ )
+ Spacer(Modifier.height(6.dp))
+ }
+ items(filtered, key = { it.code }) { language ->
+ LanguageCheckbox(
+ code = language.code,
+ checked = language.code in languages,
+ modifier = Modifier.fillMaxWidth(),
+ onCheckedChange = { checked -> setLanguages(if (checked) languages + language.code else languages - language.code) },
+ )
+ }
+ item {
+ if (filtered.isEmpty()) Text("No catalog result. Add a custom tag below.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.height(12.dp))
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ OutlinedTextField(
+ value = customTag,
+ onValueChange = { customTag = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Custom BCP 47 tag") },
+ placeholder = { Text("ga or x-fictional") },
+ isError = customTag.isNotBlank() && !customValid,
+ singleLine = true,
+ )
+ Spacer(Modifier.width(8.dp))
+ Button(
+ enabled = customValid,
+ onClick = { setLanguages(languages + canonicalCustom); customTag = "" },
+ ) { Text("Add") }
+ }
+ Spacer(Modifier.height(14.dp))
+ Text("Default writing language", fontWeight = FontWeight.SemiBold)
+ Text("The source language writers author first. Translation can use any configured pair.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.height(6.dp))
+ Box {
+ OutlinedButton(
+ onClick = { defaultMenuOpen = true },
+ modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
+ enabled = languages.isNotEmpty(),
+ ) {
+ Icon(Icons.Outlined.Translate, null)
+ Spacer(Modifier.width(8.dp))
+ Text(if (languages.isEmpty()) "Choose a language" else GameLanguages.displayName(defaultLanguage), Modifier.weight(1f))
+ Text(defaultLanguage, fontFamily = FontFamily.Monospace)
+ }
+ DropdownMenu(defaultMenuOpen, { defaultMenuOpen = false }) {
+ languages.forEach { code -> DropdownMenuItem(
+ text = { Text("${GameLanguages.displayName(code)} · $code") },
+ onClick = { defaultLanguage = code; defaultMenuOpen = false },
+ ) }
+ }
+ }
+ Spacer(Modifier.height(18.dp))
+ Button(
+ onClick = { viewModel.updateProjectLanguages(languages.toList(), defaultLanguage) },
+ enabled = changed && languages.isNotEmpty() && defaultLanguage in languages && state.busyLabel == null,
+ modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
+ ) { Text(if (state.busyLabel == "Saving language settings") "Saving…" else "Save language settings") }
+ Spacer(Modifier.height(8.dp))
+ Text("Removing a language from the active set does not erase its authored text. Re-enable the tag to restore it.", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
+
@Composable
private fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String, List, String) -> Unit) {
var name by remember { mutableStateOf("") }
@@ -2114,7 +2241,12 @@ private fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String
val nameFocus = remember { FocusRequester() }
val slugValid = slug.matches(Regex("^[a-z0-9]+(?:-[a-z0-9]+)*$")) && slug.length >= 2
val canCreate = name.isNotBlank() && slugValid && languages.isNotEmpty() && sourceLanguage in languages
- val languageOptions = listOf("en", "el", "it", "de", "griko")
+ var languageSearch by remember { mutableStateOf("") }
+ val languageOptions = GameLanguages.catalog.map { it.code }.filter { code ->
+ val definition = GameLanguages.definition(code)
+ val term = languageSearch.trim().lowercase()
+ term.isBlank() || code.lowercase().contains(term) || definition?.englishName?.lowercase()?.contains(term) == true || definition?.nativeName?.lowercase()?.contains(term) == true
+ }
LaunchedEffect(Unit) { nameFocus.requestFocus() }
Dialog(
@@ -2192,6 +2324,22 @@ private fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String
Text("Project languages", fontWeight = FontWeight.SemiBold)
Text("Choose every language this story will contain.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(6.dp))
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ OutlinedTextField(
+ value = languageSearch,
+ onValueChange = { languageSearch = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Find language") },
+ leadingIcon = { Icon(Icons.Outlined.Search, null) },
+ singleLine = true,
+ )
+ Spacer(Modifier.width(8.dp))
+ OutlinedButton(onClick = {
+ languages = GameLanguages.gameMarketPreset().toSet()
+ if (sourceLanguage !in languages) sourceLanguage = "en"
+ }) { Text("Game preset") }
+ }
+ Spacer(Modifier.height(6.dp))
if (useTwoLanguageColumns) {
languageOptions.chunked(2).forEach { rowLanguages ->
Row(Modifier.fillMaxWidth()) {
@@ -2239,7 +2387,7 @@ private fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String
Text(sourceLanguage, fontFamily = FontFamily.Monospace)
}
DropdownMenu(sourceMenuOpen, { sourceMenuOpen = false }) {
- languageOptions.filter { it in languages }.forEach { code ->
+ GameLanguages.catalog.map { it.code }.filter { it in languages }.forEach { code ->
DropdownMenuItem(
text = { Text("${languageName(code)} · $code") },
onClick = { sourceLanguage = code; sourceMenuOpen = false },
@@ -2516,6 +2664,7 @@ private fun CommandPalette(state: ReedUiState, viewModel: ReedViewModel) {
"Open Variables" to { viewModel.setWorkspace(Workspace.VARIABLES) },
"Open Git" to { viewModel.setWorkspace(Workspace.GIT) },
"Open Agent Access" to { viewModel.setWorkspace(Workspace.AGENT) },
+ "Open Project Settings" to { viewModel.setWorkspace(Workspace.SETTINGS) },
"Sync Git" to { viewModel.sync() },
"Back to Projects" to { viewModel.closeProject() },
).filter { it.first.contains(query, ignoreCase = true) }
@@ -2575,6 +2724,7 @@ private val Workspace.displayName: String get() = when (this) {
Workspace.VARIABLES -> "Variables"
Workspace.GIT -> "Git versioning"
Workspace.AGENT -> "Agent access"
+ Workspace.SETTINGS -> "Project settings"
}
private val Workspace.shortLabel: String get() = when (this) {
@@ -2584,6 +2734,7 @@ private val Workspace.shortLabel: String get() = when (this) {
Workspace.VARIABLES -> "Variables"
Workspace.GIT -> "Git"
Workspace.AGENT -> "Agent"
+ Workspace.SETTINGS -> "Settings"
}
private val TextStatus.displayName: String
@@ -2601,11 +2752,4 @@ private fun speakerAccentColor(characterId: String?): Color {
return accents[(characterId.hashCode() and Int.MAX_VALUE) % accents.size]
}
-private fun languageName(code: String) = when (code) {
- "en" -> "English"
- "el" -> "Greek"
- "it" -> "Italian"
- "de" -> "German"
- "griko" -> "Griko"
- else -> code
-}
+private fun languageName(code: String) = GameLanguages.displayName(code)
diff --git a/apps/android/app/src/main/java/com/reed/editor/ReedViewModel.kt b/apps/android/app/src/main/java/com/reed/editor/ReedViewModel.kt
index 95db603..f2f5ba6 100644
--- a/apps/android/app/src/main/java/com/reed/editor/ReedViewModel.kt
+++ b/apps/android/app/src/main/java/com/reed/editor/ReedViewModel.kt
@@ -26,7 +26,7 @@ import kotlinx.serialization.json.JsonPrimitive
import java.io.File
import java.security.MessageDigest
-enum class Workspace { GRAPH, CHARACTERS, VARIABLES, TRANSLATION, GIT, AGENT }
+enum class Workspace { GRAPH, CHARACTERS, VARIABLES, TRANSLATION, GIT, AGENT, SETTINGS }
data class RepositoryConnection(
val directory: File,
@@ -217,6 +217,17 @@ class ReedViewModel(application: Application) : AndroidViewModel(application) {
fun selectLanguage(language: String) = mutableState.update { it.copy(selectedLanguage = language) }
+ fun updateProjectLanguages(languages: List, defaultLanguage: String) {
+ val project = state.value.activeProject ?: return
+ io("Saving language settings") {
+ val result = core.updateProjectLanguages(project.id, languages, defaultLanguage, project.version)
+ reloadActive("Language settings saved")
+ mutableState.update { current ->
+ current.copy(selectedLanguage = current.selectedLanguage.takeIf { it in result.value.languages } ?: result.value.defaultLanguage)
+ }
+ }
+ }
+
fun setWorkspace(workspace: Workspace) = mutableState.update { it.copy(workspace = workspace) }
fun updateAgentRelayUrl(value: String) = mutableState.update { it.copy(agentRelayUrl = value) }
diff --git a/apps/android/app/src/main/java/com/reed/editor/agent/AndroidMcpDispatcher.kt b/apps/android/app/src/main/java/com/reed/editor/agent/AndroidMcpDispatcher.kt
index 3b5e462..d387e9f 100644
--- a/apps/android/app/src/main/java/com/reed/editor/agent/AndroidMcpDispatcher.kt
+++ b/apps/android/app/src/main/java/com/reed/editor/agent/AndroidMcpDispatcher.kt
@@ -1,6 +1,7 @@
package com.reed.editor.agent
import com.reed.core.NodeKind
+import com.reed.core.GameLanguages
import com.reed.core.ProjectSnapshot
import com.reed.core.ReedCore
import com.reed.core.TextStatus
@@ -107,6 +108,16 @@ class AndroidMcpDispatcher(
"list_variables" -> json.encodeToJsonElement(snapshot.variables)
"find_lines" -> findLines(snapshot, arguments)
"get_validation_report" -> validationReport(snapshot)
+ "update_project" -> {
+ val result = core.updateProjectLanguages(
+ projectId = projectId,
+ languages = arguments.optionalStringArray("languages") ?: snapshot.project.languages,
+ defaultLanguage = arguments.optionalString("defaultLanguage") ?: snapshot.project.defaultLanguage,
+ expectedVersion = arguments.long("expectedVersion"),
+ source = "agent",
+ )
+ mutation(json.encodeToJsonElement(result.value), result.version, result.changeSequence)
+ }
"create_scene" -> {
val result = core.createScene(
projectId = projectId,
@@ -320,6 +331,13 @@ class AndroidMcpDispatcher(
put("textStatuses", JsonArray(TextStatus.entries.map { JsonPrimitive(it.name.lowercase()) }))
put("agentMaximumTextStatus", "polished")
put("languages", json.encodeToJsonElement(core.projectOverview(projectId).project.languages))
+ put("languageTagStandard", "IETF BCP 47")
+ put("gameLanguageCatalog", buildJsonArray {
+ GameLanguages.catalog.forEach { language -> add(buildJsonObject {
+ put("code", language.code); put("englishName", language.englishName); put("nativeName", language.nativeName)
+ put("direction", if (language.rightToLeft) "rtl" else "ltr")
+ }) }
+ })
put("transport", "remote-streamable-http-via-user-authorized-tablet-bridge")
put("gitAvailable", false)
}
@@ -359,6 +377,7 @@ class AndroidMcpDispatcher(
tool("list_variables", "List declared dialogue variables."),
tool("find_lines", "Search local dialogue text.", optional = mapOf("query" to stringSchema(), "characterId" to stringSchema(), "limit" to integerSchema())),
tool("get_validation_report", "Validate the active project."),
+ tool("update_project", "Update the active project's versioned language list and default writing language.", required = mapOf("expectedVersion" to integerSchema()), optional = mapOf("languages" to arraySchema(), "defaultLanguage" to stringSchema())),
tool("create_scene", "Create an agent-sourced scene.", required = mapOf("name" to stringSchema(), "slug" to stringSchema(), "expectedVersion" to integerSchema()), optional = mapOf("beatRef" to stringSchema(), "sortOrder" to integerSchema())),
tool("create_conversation", "Create a conversation with a permanent START node.", required = mapOf("sceneId" to stringSchema(), "title" to stringSchema(), "expectedVersion" to integerSchema()), optional = mapOf("description" to stringSchema())),
tool("add_dialogue_node", "Add a Draft line, choice, or group/junction node.", required = mapOf("conversationId" to stringSchema(), "expectedVersion" to integerSchema()), optional = mapOf("kind" to enumSchema("line", "choice", "group"), "speakerCharacterId" to stringSchema(), "language" to stringSchema(), "text" to stringSchema(), "menuText" to stringSchema(), "x" to numberSchema(), "y" to numberSchema())),
@@ -430,7 +449,7 @@ class AndroidMcpDispatcher(
companion object {
private val SUPPORTED_PROTOCOLS = setOf("2025-06-18", "2025-03-26", "2024-11-05")
private val MUTATING_TOOLS = setOf(
- "create_scene", "create_conversation", "add_dialogue_node", "update_node_text", "set_line_text",
+ "update_project", "create_scene", "create_conversation", "add_dialogue_node", "update_node_text", "set_line_text",
"set_node_menu_text", "set_node_speaker", "update_node", "set_node_conditions", "set_node_sequence",
"add_tags", "set_line_status", "link_nodes", "unlink_nodes", "delete_node", "upsert_character", "declare_variable",
)
diff --git a/apps/android/app/src/test/java/com/reed/editor/LanguageSettingsRegressionTest.kt b/apps/android/app/src/test/java/com/reed/editor/LanguageSettingsRegressionTest.kt
new file mode 100644
index 0000000..e95403d
--- /dev/null
+++ b/apps/android/app/src/test/java/com/reed/editor/LanguageSettingsRegressionTest.kt
@@ -0,0 +1,30 @@
+package com.reed.editor
+
+import java.io.File
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LanguageSettingsRegressionTest {
+ @Test
+ fun androidExposesVersionedCatalogSettingsWithoutNetwork() {
+ val activity = projectFile("app/src/main/java/com/reed/editor/MainActivity.kt").readText()
+ val viewModel = projectFile("app/src/main/java/com/reed/editor/ReedViewModel.kt").readText()
+
+ assertTrue(activity.contains("Workspace.SETTINGS -> ProjectSettingsWorkspace"))
+ assertTrue(activity.contains("GameLanguages.gameMarketPreset()"))
+ assertTrue(activity.contains("Custom BCP 47 tag"))
+ assertTrue(activity.contains("Save language settings"))
+ assertTrue(viewModel.contains("fun updateProjectLanguages(languages: List, defaultLanguage: String)"))
+ assertTrue(viewModel.contains("core.updateProjectLanguages(project.id, languages, defaultLanguage, project.version)"))
+ }
+
+ private fun projectFile(relativePath: String): File {
+ var current = File(requireNotNull(System.getProperty("user.dir")))
+ repeat(6) {
+ val candidate = current.resolve(relativePath)
+ if (candidate.isFile) return candidate
+ current = current.parentFile ?: current
+ }
+ error("Project file not found: $relativePath")
+ }
+}
diff --git a/apps/android/app/src/test/java/com/reed/editor/agent/AndroidMcpDispatcherTest.kt b/apps/android/app/src/test/java/com/reed/editor/agent/AndroidMcpDispatcherTest.kt
index 868a3ae..f558fe2 100644
--- a/apps/android/app/src/test/java/com/reed/editor/agent/AndroidMcpDispatcherTest.kt
+++ b/apps/android/app/src/test/java/com/reed/editor/agent/AndroidMcpDispatcherTest.kt
@@ -39,6 +39,7 @@ class AndroidMcpDispatcherTest {
val tools = dispatcher.dispatch(request(1, "tools/list", "{}"))!!.jsonObject
.getValue("result").jsonObject.getValue("tools").jsonArray
assertTrue(tools.any { it.jsonObject["name"]?.jsonPrimitive?.content == "add_dialogue_node" })
+ assertTrue(tools.any { it.jsonObject["name"]?.jsonPrimitive?.content == "update_project" })
assertFalse(tools.any { it.jsonObject["name"]?.jsonPrimitive?.content?.contains("git") == true })
val snapshot = core.exportSnapshot(core.listProjects().single().id)
@@ -52,6 +53,18 @@ class AndroidMcpDispatcherTest {
assertEquals("Καλημέρα — buongiorno", refreshed.nodes.last().texts.first { it.language == "en" }.text)
}
+ @Test
+ fun agentCanConfigureCanonicalProjectLanguagesThroughCore() {
+ val project = core.exportSnapshot(core.listProjects().single().id).project
+ val response = toolCall(4, "update_project", """{
+ "languages":["en","el","pt_br"],"defaultLanguage":"pt-br","expectedVersion":${project.version}
+ }""")
+ assertFalse(toolPayload(response).getValue("isError").jsonPrimitive.content.toBoolean())
+ val updated = core.projectOverview(project.id).project
+ assertEquals(listOf("en", "el", "pt-BR"), updated.languages)
+ assertEquals("pt-BR", updated.defaultLanguage)
+ }
+
@Test
fun agentCannotPromoteTextToFinal() {
val snapshot = core.exportSnapshot(core.listProjects().single().id)
diff --git a/apps/android/core/src/main/java/com/reed/core/GameLanguages.kt b/apps/android/core/src/main/java/com/reed/core/GameLanguages.kt
new file mode 100644
index 0000000..6c7b078
--- /dev/null
+++ b/apps/android/core/src/main/java/com/reed/core/GameLanguages.kt
@@ -0,0 +1,82 @@
+package com.reed.core
+
+import java.util.Locale
+
+data class GameLanguageDefinition(
+ val code: String,
+ val englishName: String,
+ val nativeName: String,
+ val rightToLeft: Boolean = false,
+ val steamCode: String? = null,
+ val legacy: Boolean = false,
+)
+
+object GameLanguages {
+ val catalog = listOf(
+ GameLanguageDefinition("ar", "Arabic", "العربية", true, "arabic"),
+ GameLanguageDefinition("bg", "Bulgarian", "Български", steamCode = "bulgarian"),
+ GameLanguageDefinition("ca", "Catalan", "Català"),
+ GameLanguageDefinition("zh-Hans", "Chinese (Simplified)", "简体中文", steamCode = "schinese"),
+ GameLanguageDefinition("zh-Hant", "Chinese (Traditional)", "繁體中文", steamCode = "tchinese"),
+ GameLanguageDefinition("hr", "Croatian", "Hrvatski"),
+ GameLanguageDefinition("cs", "Czech", "Čeština", steamCode = "czech"),
+ GameLanguageDefinition("da", "Danish", "Dansk", steamCode = "danish"),
+ GameLanguageDefinition("nl", "Dutch", "Nederlands", steamCode = "dutch"),
+ GameLanguageDefinition("en", "English", "English", steamCode = "english"),
+ GameLanguageDefinition("en-AU", "English (Australia)", "English (Australia)"),
+ GameLanguageDefinition("en-GB", "English (United Kingdom)", "English (United Kingdom)"),
+ GameLanguageDefinition("en-US", "English (United States)", "English (United States)"),
+ GameLanguageDefinition("fil", "Filipino", "Filipino"),
+ GameLanguageDefinition("fi", "Finnish", "Suomi", steamCode = "finnish"),
+ GameLanguageDefinition("fr", "French", "Français", steamCode = "french"),
+ GameLanguageDefinition("fr-CA", "French (Canada)", "Français (Canada)"),
+ GameLanguageDefinition("de", "German", "Deutsch", steamCode = "german"),
+ GameLanguageDefinition("el", "Greek", "Ελληνικά", steamCode = "greek"),
+ GameLanguageDefinition("he", "Hebrew", "עברית", true),
+ GameLanguageDefinition("hi", "Hindi", "हिन्दी"),
+ GameLanguageDefinition("hu", "Hungarian", "Magyar", steamCode = "hungarian"),
+ GameLanguageDefinition("id", "Indonesian", "Bahasa Indonesia", steamCode = "indonesian"),
+ GameLanguageDefinition("it", "Italian", "Italiano", steamCode = "italian"),
+ GameLanguageDefinition("ja", "Japanese", "日本語", steamCode = "japanese"),
+ GameLanguageDefinition("ko", "Korean", "한국어", steamCode = "koreana"),
+ GameLanguageDefinition("ms", "Malay", "Bahasa Melayu", steamCode = "malay"),
+ GameLanguageDefinition("no", "Norwegian", "Norsk", steamCode = "norwegian"),
+ GameLanguageDefinition("fa", "Persian", "فارسی", true),
+ GameLanguageDefinition("pl", "Polish", "Polski", steamCode = "polish"),
+ GameLanguageDefinition("pt-PT", "Portuguese (Portugal)", "Português (Portugal)", steamCode = "portuguese"),
+ GameLanguageDefinition("pt-BR", "Portuguese (Brazil)", "Português (Brasil)", steamCode = "brazilian"),
+ GameLanguageDefinition("ro", "Romanian", "Română", steamCode = "romanian"),
+ GameLanguageDefinition("ru", "Russian", "Русский", steamCode = "russian"),
+ GameLanguageDefinition("sr-Cyrl", "Serbian (Cyrillic)", "Српски"),
+ GameLanguageDefinition("sr-Latn", "Serbian (Latin)", "Srpski"),
+ GameLanguageDefinition("sk", "Slovak", "Slovenčina"),
+ GameLanguageDefinition("sl", "Slovenian", "Slovenščina"),
+ GameLanguageDefinition("es-ES", "Spanish (Spain)", "Español (España)", steamCode = "spanish"),
+ GameLanguageDefinition("es-419", "Spanish (Latin America)", "Español (Latinoamérica)", steamCode = "latam"),
+ GameLanguageDefinition("sv", "Swedish", "Svenska", steamCode = "swedish"),
+ GameLanguageDefinition("th", "Thai", "ไทย", steamCode = "thai"),
+ GameLanguageDefinition("tr", "Turkish", "Türkçe", steamCode = "turkish"),
+ GameLanguageDefinition("uk", "Ukrainian", "Українська", steamCode = "ukrainian"),
+ GameLanguageDefinition("vi", "Vietnamese", "Tiếng Việt", steamCode = "vietnamese"),
+ GameLanguageDefinition("griko", "Griko", "Griko", legacy = true),
+ GameLanguageDefinition("x-griko", "Griko (private-use tag)", "Griko"),
+ )
+
+ private val byCode = catalog.associateBy { it.code.lowercase(Locale.ROOT) }
+ private val tagPattern = Regex("^(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-[A-Za-z0-9]{5,8}|-[0-9][A-Za-z0-9]{3})*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-x(?:-[A-Za-z0-9]{1,8})+)?|x(?:-[A-Za-z0-9]{1,8})+)$")
+
+ fun isValidTag(value: String): Boolean = value.length <= 63 && tagPattern.matches(value)
+
+ fun canonicalize(value: String): String {
+ val normalized = value.trim().replace('_', '-')
+ if (normalized.equals("griko", ignoreCase = true)) return "griko"
+ if (!isValidTag(normalized)) return normalized
+ return Locale.forLanguageTag(normalized).toLanguageTag().takeUnless { it == "und" } ?: normalized
+ }
+
+ fun definition(code: String): GameLanguageDefinition? = byCode[canonicalize(code).lowercase(Locale.ROOT)]
+
+ fun displayName(code: String): String = definition(code)?.englishName ?: code
+
+ fun gameMarketPreset(): List = catalog.filter { it.steamCode != null }.map { it.code }
+}
diff --git a/apps/android/core/src/main/java/com/reed/core/ReedCore.kt b/apps/android/core/src/main/java/com/reed/core/ReedCore.kt
index b2ff8e7..3f9056b 100644
--- a/apps/android/core/src/main/java/com/reed/core/ReedCore.kt
+++ b/apps/android/core/src/main/java/com/reed/core/ReedCore.kt
@@ -68,8 +68,11 @@ class ReedCore(
): ProjectSnapshot {
require(name.isNotBlank()) { "Project name is required" }
require(slug.matches(Regex("^[a-z0-9][a-z0-9-]{1,62}$"))) { "Invalid project slug" }
- require(languages.isNotEmpty() && languages.distinct().size == languages.size) { "Languages must be unique" }
- require(defaultLanguage in languages) { "Default language must be configured" }
+ val normalizedLanguages = languages.map(GameLanguages::canonicalize)
+ val normalizedDefaultLanguage = GameLanguages.canonicalize(defaultLanguage)
+ require(normalizedLanguages.all(GameLanguages::isValidTag)) { "Languages must use valid BCP 47 tags" }
+ require(normalizedLanguages.isNotEmpty() && normalizedLanguages.map(String::lowercase).distinct().size == normalizedLanguages.size) { "Languages must be unique" }
+ require(normalizedDefaultLanguage in normalizedLanguages) { "Default language must be configured" }
val now = Instant.now().toString()
val projectId = uuid()
val levelId = uuid()
@@ -84,8 +87,8 @@ class ReedCore(
id = projectId,
name = name.trim(),
slug = slug,
- languages = languages,
- defaultLanguage = defaultLanguage,
+ languages = normalizedLanguages,
+ defaultLanguage = normalizedDefaultLanguage,
version = 1,
createdAt = now,
updatedAt = now,
@@ -115,14 +118,14 @@ class ReedCore(
lineId = "${prefix}_start",
entryNumericId = 0,
kind = NodeKind.START,
- spokenLanguage = defaultLanguage,
+ spokenLanguage = normalizedDefaultLanguage,
lineStatus = TextStatus.PLACEHOLDER,
voiceStatus = VoiceStatus.NONE,
x = 48.0,
y = 96.0,
createdAt = now,
updatedAt = now,
- texts = languages.map { NodeTextRecord(it, "", null, VerificationStatus.UNVERIFIED, now, now) },
+ texts = normalizedLanguages.map { NodeTextRecord(it, "", null, VerificationStatus.UNVERIFIED, now, now) },
)
)
)
@@ -133,6 +136,50 @@ class ReedCore(
return snapshot
}
+ fun updateProjectLanguages(
+ projectId: String,
+ languages: List,
+ defaultLanguage: String,
+ expectedVersion: Long,
+ source: String = "human",
+ ): MutationResult = transaction { db ->
+ validateSource(source)
+ val normalizedLanguages = languages.map(GameLanguages::canonicalize)
+ val normalizedDefaultLanguage = GameLanguages.canonicalize(defaultLanguage)
+ require(normalizedLanguages.all(GameLanguages::isValidTag)) { "Languages must use valid BCP 47 tags" }
+ require(normalizedLanguages.isNotEmpty()) { "Select at least one project language" }
+ require(normalizedLanguages.map(String::lowercase).distinct().size == normalizedLanguages.size) { "Languages must be unique" }
+ require(normalizedDefaultLanguage in normalizedLanguages) { "Default language must be configured" }
+ val before = readProject(db, projectId)
+ checkVersion(before.version, expectedVersion)
+ val now = Instant.now().toString()
+ db.execSQL(
+ "UPDATE projects SET languages_json = ?, default_language = ? WHERE id = ?",
+ arrayOf(json.encodeToString(normalizedLanguages), normalizedDefaultLanguage, projectId),
+ )
+ val nodeIds = db.rawQuery("SELECT id FROM nodes WHERE project_id = ?", arrayOf(projectId)).use { cursor ->
+ buildList { while (cursor.moveToNext()) add(cursor.getString(0)) }
+ }
+ for (nodeId in nodeIds) for (language in normalizedLanguages) {
+ db.execSQL(
+ "INSERT OR IGNORE INTO node_texts (node_id,language,text_value,menu_text,verification_status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)",
+ arrayOf(nodeId, language, "", null, VerificationStatus.UNVERIFIED.wire, now, now),
+ )
+ }
+ val version = incrementVersion(db, projectId, expectedVersion, now)
+ val after = before.copy(
+ languages = normalizedLanguages,
+ defaultLanguage = normalizedDefaultLanguage,
+ version = version,
+ updatedAt = now,
+ )
+ val sequence = recordMutation(
+ db, projectId, version, "update_project_languages", "project", projectId,
+ json.encodeToString(before), json.encodeToString(after), source,
+ )
+ MutationResult(after, version, sequence)
+ }
+
fun importProjectFile(file: File, source: String = "system"): ProjectSnapshot {
require(file.isFile) { "Project export not found: ${file.path}" }
return importSnapshot(file.readText(Charsets.UTF_8), source)
diff --git a/apps/android/core/src/test/java/com/reed/core/GameLanguagesTest.kt b/apps/android/core/src/test/java/com/reed/core/GameLanguagesTest.kt
new file mode 100644
index 0000000..ce0e79a
--- /dev/null
+++ b/apps/android/core/src/test/java/com/reed/core/GameLanguagesTest.kt
@@ -0,0 +1,18 @@
+package com.reed.core
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GameLanguagesTest {
+ @Test
+ fun catalogCoversCommonGameLocalesAndCanonicalizesTags() {
+ assertTrue(GameLanguages.catalog.size >= 40)
+ assertTrue(GameLanguages.catalog.count { it.steamCode != null } >= 29)
+ assertEquals(GameLanguages.catalog.size, GameLanguages.catalog.map { it.code.lowercase() }.distinct().size)
+ assertEquals("pt-BR", GameLanguages.canonicalize("pt_br"))
+ assertEquals("zh-Hans", GameLanguages.canonicalize("zh-hans"))
+ assertEquals("griko", GameLanguages.canonicalize("GRIKO"))
+ assertTrue(GameLanguages.isValidTag("x-studio-dialect"))
+ }
+}
diff --git a/apps/android/core/src/test/java/com/reed/core/ReedCoreTest.kt b/apps/android/core/src/test/java/com/reed/core/ReedCoreTest.kt
index 991aa86..9987b3d 100644
--- a/apps/android/core/src/test/java/com/reed/core/ReedCoreTest.kt
+++ b/apps/android/core/src/test/java/com/reed/core/ReedCoreTest.kt
@@ -80,6 +80,23 @@ class ReedCoreTest {
assertEquals(names, restored.displayNames)
}
+ @Test
+ fun projectLanguageSettingsAreVersionedCanonicalAndCreateMissingTextRows() {
+ val created = core.createProject("Language Test", "language-test", listOf("en", "el"), "en")
+ val updated = core.updateProjectLanguages(
+ created.project.id,
+ listOf("en", "pt_br", "zh-hans"),
+ "pt-br",
+ created.project.version,
+ )
+
+ assertEquals(listOf("en", "pt-BR", "zh-Hans"), updated.value.languages)
+ assertEquals("pt-BR", updated.value.defaultLanguage)
+ assertEquals(created.project.version + 1, updated.version)
+ val languages = core.conversationGraph(created.conversations.single().id).nodes.single().texts.map { it.language }
+ assertTrue(languages.containsAll(listOf("en", "el", "pt-BR", "zh-Hans")))
+ }
+
@Test
fun mutationsEnforceOptimisticVersionAndRecordedTextReturnsToRework() {
val created = core.createProject("Voice Test", "voice-test")
diff --git a/apps/editor/assets/Reed.ico b/apps/editor/assets/Reed.ico
new file mode 100644
index 0000000..1f3c442
Binary files /dev/null and b/apps/editor/assets/Reed.ico differ
diff --git a/apps/editor/forge.config.ts b/apps/editor/forge.config.ts
index 66c1401..73cfe0b 100644
--- a/apps/editor/forge.config.ts
+++ b/apps/editor/forge.config.ts
@@ -17,8 +17,16 @@ const configDirectory = path.dirname(fileURLToPath(import.meta.url));
const hoistedModules = path.resolve(configDirectory, "../../node_modules");
const dmgStagingDirectory = path.join(tmpdir(), `reed-dmg-${process.pid}`);
const dmgStagingApp = path.join(dmgStagingDirectory, "Reed.app");
-const githubCliSource = [process.env.REED_GH_PATH, "/opt/homebrew/bin/gh", "/usr/local/bin/gh"]
+const signatureValidationDirectory = path.join(tmpdir(), `reed-signature-${process.pid}`);
+const signatureValidationApp = path.join(signatureValidationDirectory, "Reed.app");
+const platformFlag = process.argv.indexOf("--platform");
+const targetPlatform = (platformFlag >= 0 ? process.argv[platformFlag + 1] : process.platform) as NodeJS.Platform;
+const githubCliCandidates = targetPlatform === "win32"
+ ? [process.env.REED_GH_WINDOWS_PATH]
+ : [process.env.REED_GH_PATH, "/opt/homebrew/bin/gh", "/usr/local/bin/gh"];
+const githubCliSource = githubCliCandidates
.find((candidate): candidate is string => Boolean(candidate && existsSync(candidate)));
+const prebuildInstallScript = path.join(hoistedModules, "prebuild-install", "bin.js");
const includeNativeRuntime: NonNullable["afterPrune"] = [
(buildPath, _electronVersion, _platform, _arch, callback) => {
@@ -34,13 +42,25 @@ const includeNativeRuntime: NonNullable["afterPru
const stagedPackage = JSON.parse(await readFile(packagePath, "utf8")) as Record;
stagedPackage.type = "commonjs";
await writeFile(packagePath, `${JSON.stringify(stagedPackage, null, 2)}\n`, "utf8");
- await rebuild({
- buildPath,
- electronVersion: _electronVersion,
- arch: _arch,
- onlyModules: ["better-sqlite3"],
- force: true,
- });
+ if (_platform !== process.platform) {
+ execFileSync(process.execPath, [
+ prebuildInstallScript,
+ "--runtime", "electron",
+ "--target", _electronVersion,
+ "--platform", _platform,
+ "--arch", _arch,
+ "--verbose",
+ ], { cwd: path.join(destination, "better-sqlite3"), stdio: "inherit" });
+ } else {
+ await rebuild({
+ buildPath,
+ electronVersion: _electronVersion,
+ platform: _platform as NodeJS.Platform,
+ arch: _arch,
+ onlyModules: ["better-sqlite3"],
+ force: true,
+ });
+ }
})().then(() => callback(), (error: Error) => callback(error));
},
];
@@ -51,7 +71,7 @@ const config: ForgeConfig = {
appBundleId: "com.reed.dialogue-editor",
appCategoryType: "public.app-category.developer-tools",
name: "Reed",
- icon: path.join(configDirectory, "assets", "Reed.icns"),
+ icon: path.join(configDirectory, "assets", targetPlatform === "win32" ? "Reed.ico" : "Reed.icns"),
extraResource: githubCliSource ? [realpathSync(githubCliSource)] : [],
osxSign: {
identity: "-",
@@ -63,17 +83,34 @@ const config: ForgeConfig = {
afterPrune: includeNativeRuntime,
},
hooks: {
+ postPackage: async (_forgeConfig, packageResult) => {
+ if (packageResult.platform !== "darwin") return;
+ for (const outputPath of packageResult.outputPaths) {
+ const appPath = path.join(outputPath, "Reed.app");
+ execFileSync("/bin/chmod", ["-R", "u+w", appPath]);
+ execFileSync("/usr/bin/xattr", ["-cr", appPath]);
+ execFileSync("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", appPath], { stdio: "inherit" });
+ await rm(signatureValidationDirectory, { recursive: true, force: true });
+ await mkdir(signatureValidationDirectory, { recursive: true });
+ execFileSync("/usr/bin/ditto", ["--noextattr", "--noqtn", appPath, signatureValidationApp]);
+ execFileSync("/bin/chmod", ["-R", "u+w", signatureValidationApp]);
+ execFileSync("/usr/bin/xattr", ["-cr", signatureValidationApp]);
+ execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", signatureValidationApp], { stdio: "inherit" });
+ await rm(signatureValidationDirectory, { recursive: true, force: true });
+ }
+ },
postMake: async (_forgeConfig, makeResults) => {
await rm(dmgStagingDirectory, { recursive: true, force: true });
return makeResults;
},
},
rebuildConfig: {},
- makers: [new MakerZIP({}, ["darwin"]), new MakerDMG({
+ makers: [new MakerZIP({}, ["darwin", "win32"]), new MakerDMG({
format: "ULFO",
contents: (options) => {
mkdirSync(dmgStagingDirectory, { recursive: true });
execFileSync("/usr/bin/ditto", ["--noextattr", "--noqtn", options.appPath, dmgStagingApp]);
+ execFileSync("/bin/chmod", ["-R", "u+w", dmgStagingApp]);
execFileSync("/usr/bin/xattr", ["-cr", dmgStagingApp]);
execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", dmgStagingApp], { stdio: "inherit" });
return [
diff --git a/apps/editor/package.json b/apps/editor/package.json
index d1c990b..0c31914 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -2,7 +2,7 @@
"name": "@reed/editor",
"productName": "Reed",
"version": "0.1.9",
- "description": "Offline branching dialogue editor for Unity and Pixel Crushers",
+ "description": "Offline branching dialogue and localization workspace for game development",
"private": true,
"type": "module",
"main": ".webpack/main",
@@ -10,6 +10,9 @@
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
+ "make:mac": "electron-forge make --platform=darwin --arch=arm64",
+ "make:windows": "electron-forge make --platform=win32 --arch=x64",
+ "make:desktop": "pnpm make:mac && pnpm make:windows",
"build": "electron-forge package",
"typecheck": "tsc --noEmit"
},
@@ -18,8 +21,9 @@
"@fontsource/ibm-plex-sans": "^5.2.6",
"@fontsource/source-serif-4": "^5.2.6",
"@reed/core": "workspace:*",
+ "@reed/schema": "workspace:*",
"@xyflow/react": "^12.11.2",
- "better-sqlite3": "^12.2.0",
+ "better-sqlite3": "12.10.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
@@ -34,7 +38,7 @@
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"css-loader": "^7.1.2",
- "electron": "43.1.0",
+ "electron": "41.10.2",
"node-loader": "^2.1.0",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.2",
diff --git a/apps/editor/src/app.tsx b/apps/editor/src/app.tsx
index c98b669..1961490 100644
--- a/apps/editor/src/app.tsx
+++ b/apps/editor/src/app.tsx
@@ -8,6 +8,7 @@ import type {
CharacterRecord, ConversationGraph, DialogueNodeRecord, MutationRecord, ProjectSnapshot,
ProjectGitHubPullRequest, ProjectGitHubStatus, ProjectGitStatus, ProjectRecord, SceneRecord, SearchResult, ValidationIssue, ValidationReport, VariableRecord,
} from "@reed/core";
+import { GAME_LANGUAGE_CATALOG, GAME_LANGUAGE_PRESET, canonicalizeLanguageCode, getLanguageDefinition, isLanguageCode } from "@reed/schema";
import type { AppearancePreference, AppearanceState, EditorBootstrap, ExportKind, MutationName, SaveStateEvent, WorkspaceId } from "./contract.js";
import type { RecentProject } from "./recent-projects.js";
import { conditionVariableNames, createPickerPosition, evaluatePlaytestCondition, globalShortcut, nextAppearancePreference, nextMenuIndex, nodeKindOptions, nodePositionForConnectionDrop, parseLanguageList, promptValueIsValid, speakerAccent, variableInitialValueLabel, type AddableNodeKind } from "./ui-logic.js";
@@ -294,6 +295,58 @@ function usePrompt(): PromptRequest {
return request;
}
+function languageOptionLabel(code: string): string {
+ const language = getLanguageDefinition(code);
+ if (!language) return code;
+ return language.nativeName === language.englishName
+ ? `${language.englishName} · ${code}`
+ : `${language.englishName} · ${language.nativeName} · ${code}`;
+}
+
+function LanguageConfigurator({ languages, defaultLanguage, onLanguages, onDefaultLanguage, compact = false }: {
+ languages: string[];
+ defaultLanguage: string;
+ onLanguages(languages: string[]): void;
+ onDefaultLanguage(language: string): void;
+ compact?: boolean;
+}) {
+ const [query, setQuery] = useState("");
+ const [customCode, setCustomCode] = useState("");
+ const selected = new Set(languages.map((language) => language.toLowerCase()));
+ const normalizedQuery = query.trim().toLocaleLowerCase();
+ const visible = normalizedQuery
+ ? GAME_LANGUAGE_CATALOG.filter((language) => [language.code, language.englishName, language.nativeName].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)))
+ : GAME_LANGUAGE_CATALOG;
+ const set = (next: string[]) => {
+ onLanguages(next);
+ if (next.length > 0 && !next.includes(defaultLanguage)) onDefaultLanguage(next[0] ?? defaultLanguage);
+ };
+ const toggle = (code: string) => {
+ if (selected.has(code.toLowerCase())) set(languages.filter((language) => language.toLowerCase() !== code.toLowerCase()));
+ else set([...languages, code]);
+ };
+ const customCanonical = canonicalizeLanguageCode(customCode);
+ const customValid = isLanguageCode(customCanonical) && !selected.has(customCanonical.toLowerCase());
+ const addCustom = () => {
+ if (!customValid) return;
+ set([...languages, customCanonical]);
+ setCustomCode("");
+ };
+ const applyGamePreset = () => set([...GAME_LANGUAGE_PRESET]);
+ return
+
Project languages
Use standard BCP 47 tags. The catalog starts with the major game-market languages, and custom tags remain available for dialects and constructed languages.
Used for local export filenames. Permanent Line IDs will not change later.}
+ {mode === "new" &&
New local project
Project identity
Used for local export filenames. Permanent Line IDs will not change later. setLanguageList(next.join(", "))} onDefaultLanguage={setDefaultLanguage} />}
On this Mac
Recent projects
{recentProjects.length > 0 && }
{visibleRecent.length > 0 ?
{visibleRecent.map((project) => )}
:
No other recent projects.
Open a Reed folder and it will stay here for next time.
Unity exports require a validation-clear story so permanent IDs and links remain safe.
{exportRow("pixel-single", "Single dialogue database", "Pixel Crushers JSON + manifest", "One database containing every conversation. Best for most Unity projects.", true)}{exportRow("pixel-partitioned", "Partitioned dialogue databases", "Pixel Crushers JSON files + manifest", "One database per configured partition for larger production pipelines.")}
+
Interchange
Game engines & localization tools
{snapshot.project.languages.length} languages
These tables use permanent Line IDs and include choice menu text as separate .menu keys. They carry localized strings and production context; Reed remains the source of branching graph logic.
{exportRow("unity-localization", "Unity Localization", "String Table Collection CSV", "A wide UTF-8 table with stable keys, numeric IDs, locale columns, and shared comments.")}{exportRow("godot", "Godot", "Localization CSV", "A Godot-ready keys-and-locales table with ignored context columns for speakers and source Line IDs.")}{exportRow("unreal-string-table", "Unreal Engine", "String Table CSV per culture", "One Key / SourceString table per enabled culture, with context preserved as metadata columns.")}{exportRow("yarn-spinner", "Yarn Spinner", "Strings CSV per language", "Line-ID-based strings files with source locations, deterministic locks, and translator comments.")}{exportRow("gettext", "gettext", "PO per language", "Version-control-friendly catalogs for Godot and standard localization tooling.")}{exportRow("xliff-2.0", "XLIFF 2.0", "XLIFF per target language", "Standards-based bilingual exchange for CAT tools and localization vendors.")}
A studio master, a focused PDF for each actor, an editable session tracker, and CSV interchange data. Dialogue is exported only in the selected language; every recording uses its permanent LineID.wav filename.
{voicePreset === "ready" ? "Polished-or-later text with no recording or a required rework." : voicePreset === "pickups" ? "Polished-or-later text marked for recording rework." : "Every authored line, including drafts."}
{voiceSelection.missing.length > 0 &&
Add {voiceLanguage.toUpperCase()} dialogue to {voiceSelection.missing.length} selected {voiceSelection.missing.length === 1 ? "line" : "lines"} before exporting. Reed will not substitute another language in an actor script.
{exportRow("project", "Deterministic project JSON", "Reed JSON", "A stable plain-text snapshot for local Git history, inspection, and archival.")}{exportRow("screenplay", "Linear screenplay", "Markdown", "A readable golden-path script in the project’s default language.")}
+
Portable files
Plain data, story source & reading
{exportRow("game-json", "Plain localization JSON", "Structured JSON dictionary", "Permanent Line IDs mapped to every configured language plus speaker, scene, context, tags, and menu text.")}{exportRow("game-csv", "Plain localization CSV", "Long-form UTF-8 CSV", "One row per Line ID and language for spreadsheets, scripts, databases, and custom engines.")}{exportRow("project", "Deterministic project JSON", "Reed JSON", "A complete graph snapshot for local Git history, inspection, archival, and Reed interchange.")}{exportRow("screenplay", "Linear screenplay", "Markdown", "A readable golden-path script in the project’s default language.")}
{feedback &&