voicebox
+
diff --git a/app/src/App.tsx b/app/src/App.tsx
index cb57a010..b226eea5 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -5,6 +5,7 @@ import { DictateWindow } from '@/components/DictateWindow/DictateWindow';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
+import { useThemeSync } from '@/hooks/useThemeSync';
import { apiClient } from '@/lib/api/client';
import type { HealthResponse } from '@/lib/api/types';
import { useChordSync } from '@/lib/hooks/useChordSync';
@@ -71,6 +72,8 @@ const LOADING_MESSAGES = [
];
function App() {
+ useThemeSync();
+
// The dictate window runs in a separate Tauri webview that must skip
// server bootstrap (the main window owns that lifecycle) and render only
// the floating recording surface. Split into a sibling component so the
diff --git a/app/src/components/CapturePill/CapturePill.tsx b/app/src/components/CapturePill/CapturePill.tsx
index bc413d21..d8609415 100644
--- a/app/src/components/CapturePill/CapturePill.tsx
+++ b/app/src/components/CapturePill/CapturePill.tsx
@@ -135,8 +135,9 @@ export function CapturePill({
return (
+ {/* Left zoom handle */}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
+
+ {/* Pan area */}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven drag area */}
+
+ {/* Right zoom handle */}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
+
+
+
+
);
diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx
index a7b7f8ef..e9042a57 100644
--- a/app/src/components/VoiceProfiles/ProfileCard.tsx
+++ b/app/src/components/VoiceProfiles/ProfileCard.tsx
@@ -91,7 +91,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
className={cn(
'cursor-pointer transition-all flex flex-col h-[162px]',
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
- isSelected && !disabled && 'ring-2 ring-accent shadow-md',
+ isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
)}
onClick={handleSelect}
tabIndex={0}
diff --git a/app/src/components/ui/badge.tsx b/app/src/components/ui/badge.tsx
index fb60e90d..20c6eec3 100644
--- a/app/src/components/ui/badge.tsx
+++ b/app/src/components/ui/badge.tsx
@@ -9,7 +9,7 @@ const badgeVariants = cva(
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
- 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ 'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
diff --git a/app/src/hooks/useThemeSync.ts b/app/src/hooks/useThemeSync.ts
new file mode 100644
index 00000000..945792a1
--- /dev/null
+++ b/app/src/hooks/useThemeSync.ts
@@ -0,0 +1,22 @@
+import { useEffect } from 'react';
+import { useUIStore } from '@/stores/uiStore';
+
+export function useThemeSync() {
+ const theme = useUIStore((s) => s.theme);
+
+ useEffect(() => {
+ if (theme !== 'system') {
+ document.documentElement.classList.toggle('dark', theme === 'dark');
+ return;
+ }
+
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
+ const apply = () => {
+ document.documentElement.classList.toggle('dark', mq.matches);
+ };
+
+ apply();
+ mq.addEventListener('change', apply);
+ return () => mq.removeEventListener('change', apply);
+ }, [theme]);
+}
diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json
index 9661fcb8..0a7095fb 100644
--- a/app/src/i18n/locales/en/translation.json
+++ b/app/src/i18n/locales/en/translation.json
@@ -551,9 +551,11 @@
"title": "Stories",
"newStory": "New Story",
"loading": "Loading stories…",
+ "searchPlaceholder": "Search stories…",
"empty": {
"title": "No stories yet",
- "hint": "Create your first story to get started"
+ "hint": "Create your first story to get started",
+ "noMatches": "No stories match \"{{query}}\""
},
"row": {
"itemCount_one": "{{count}} item",
@@ -730,6 +732,15 @@
"label": "Language",
"description": "Choose the display language for Voicebox."
},
+ "theme": {
+ "label": "Theme",
+ "description": "Match your system, or pick a fixed light or dark appearance.",
+ "options": {
+ "system": "System",
+ "light": "Light",
+ "dark": "Dark"
+ }
+ },
"general": {
"docs": { "title": "Read the Docs" },
"discord": { "title": "Join the Discord", "subtitle": "Get help & share voices" },
diff --git a/app/src/i18n/locales/ja/translation.json b/app/src/i18n/locales/ja/translation.json
index ca302c0d..aef6e030 100644
--- a/app/src/i18n/locales/ja/translation.json
+++ b/app/src/i18n/locales/ja/translation.json
@@ -551,9 +551,11 @@
"title": "ストーリー",
"newStory": "新しいストーリー",
"loading": "ストーリーを読み込み中…",
+ "searchPlaceholder": "ストーリーを検索…",
"empty": {
"title": "ストーリーがまだありません",
- "hint": "最初のストーリーを作成して始めましょう"
+ "hint": "最初のストーリーを作成して始めましょう",
+ "noMatches": "「{{query}}」に一致するストーリーはありません"
},
"row": {
"itemCount_one": "{{count}} 項目",
@@ -730,6 +732,15 @@
"label": "言語",
"description": "Voicebox の表示言語を選択します。"
},
+ "theme": {
+ "label": "テーマ",
+ "description": "システム設定に合わせるか、ライト / ダークを固定します。",
+ "options": {
+ "system": "システム",
+ "light": "ライト",
+ "dark": "ダーク"
+ }
+ },
"general": {
"docs": { "title": "ドキュメントを読む" },
"discord": { "title": "Discord に参加", "subtitle": "ヘルプやボイスの共有" },
diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json
index a479e469..e8ff71b5 100644
--- a/app/src/i18n/locales/zh-CN/translation.json
+++ b/app/src/i18n/locales/zh-CN/translation.json
@@ -551,9 +551,11 @@
"title": "故事",
"newStory": "新建故事",
"loading": "加载故事中…",
+ "searchPlaceholder": "搜索故事…",
"empty": {
"title": "暂无故事",
- "hint": "创建您的第一个故事以开始"
+ "hint": "创建您的第一个故事以开始",
+ "noMatches": "没有故事匹配 “{{query}}”"
},
"row": {
"itemCount_one": "{{count}} 项",
@@ -730,6 +732,15 @@
"label": "语言",
"description": "选择 Voicebox 的显示语言。"
},
+ "theme": {
+ "label": "主题",
+ "description": "跟随系统外观,或固定为浅色 / 深色模式。",
+ "options": {
+ "system": "跟随系统",
+ "light": "浅色",
+ "dark": "深色"
+ }
+ },
"general": {
"docs": { "title": "阅读文档" },
"discord": { "title": "加入 Discord", "subtitle": "获取帮助 & 分享声音" },
diff --git a/app/src/i18n/locales/zh-TW/translation.json b/app/src/i18n/locales/zh-TW/translation.json
index eed6c07a..f96b75df 100644
--- a/app/src/i18n/locales/zh-TW/translation.json
+++ b/app/src/i18n/locales/zh-TW/translation.json
@@ -551,9 +551,11 @@
"title": "故事",
"newStory": "新增故事",
"loading": "載入故事中…",
+ "searchPlaceholder": "搜尋故事…",
"empty": {
"title": "尚無故事",
- "hint": "建立您的第一個故事以開始"
+ "hint": "建立您的第一個故事以開始",
+ "noMatches": "沒有故事符合「{{query}}」"
},
"row": {
"itemCount_one": "{{count}} 項",
@@ -730,6 +732,15 @@
"label": "語言",
"description": "選擇 Voicebox 的顯示語言。"
},
+ "theme": {
+ "label": "佈景主題",
+ "description": "跟隨系統外觀,或固定為淺色 / 深色模式。",
+ "options": {
+ "system": "跟隨系統",
+ "light": "淺色",
+ "dark": "深色"
+ }
+ },
"general": {
"docs": { "title": "閱讀文件" },
"discord": { "title": "加入 Discord", "subtitle": "取得協助與分享聲音" },
diff --git a/app/src/index.css b/app/src/index.css
index 65c11d84..03b1b294 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -44,24 +44,24 @@
:root {
--background: 0 0% 95%;
- --foreground: 222.2 84% 4.9%;
+ --foreground: 0 0% 5%;
--card: 0 0% 97%;
- --card-foreground: 222.2 84% 4.9%;
+ --card-foreground: 0 0% 5%;
--popover: 0 0% 97%;
- --popover-foreground: 222.2 84% 4.9%;
- --primary: 222.2 47.4% 11.2%;
- --primary-foreground: 210 40% 98%;
- --secondary: 210 40% 92%;
- --secondary-foreground: 222.2 47.4% 11.2%;
- --muted: 210 40% 90%;
- --muted-foreground: 215.4 16.3% 46.9%;
- --accent: 43 50% 50%;
- --accent-foreground: 222.2 47.4% 11.2%;
+ --popover-foreground: 0 0% 5%;
+ --primary: 43 55% 58%;
+ --primary-foreground: 0 0% 100%;
+ --secondary: 0 0% 92%;
+ --secondary-foreground: 0 0% 11%;
+ --muted: 0 0% 90%;
+ --muted-foreground: 0 0% 47%;
+ --accent: 43 55% 58%;
+ --accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
- --destructive-foreground: 210 40% 98%;
- --border: 214.3 31.8% 85%;
- --input: 214.3 31.8% 88%;
- --ring: 222.2 84% 4.9%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 85%;
+ --input: 0 0% 88%;
+ --ring: 0 0% 5%;
--sidebar: 0 0% 92%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
@@ -157,6 +157,11 @@
opacity: 0;
}
+.dark .sidebar-logo {
+ filter: drop-shadow(0 0 6px hsl(var(--accent) / 0.5))
+ drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2));
+}
+
/* react-loaders */
.line-scale-pulse-out-rapid > div,
.line-scale > div {
diff --git a/app/src/stores/uiStore.ts b/app/src/stores/uiStore.ts
index f90fc13d..dfaf6d2f 100644
--- a/app/src/stores/uiStore.ts
+++ b/app/src/stores/uiStore.ts
@@ -1,6 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
+export type Theme = 'light' | 'dark' | 'system';
+
+function resolveTheme(theme: Theme): 'light' | 'dark' {
+ if (theme !== 'system') return theme;
+ if (typeof window === 'undefined') return 'dark';
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+function applyTheme(theme: Theme) {
+ if (typeof document === 'undefined') return;
+ document.documentElement.classList.toggle('dark', resolveTheme(theme) === 'dark');
+}
+
// Draft state for the create voice profile form
export interface ProfileFormDraft {
name: string;
@@ -46,8 +59,8 @@ interface UIStore {
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
// Theme
- theme: 'light' | 'dark';
- setTheme: (theme: 'light' | 'dark') => void;
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
}
export const useUIStore = create()(
@@ -76,15 +89,21 @@ export const useUIStore = create()(
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
- theme: 'light',
+ theme: 'system',
setTheme: (theme) => {
set({ theme });
- document.documentElement.classList.toggle('dark', theme === 'dark');
+ applyTheme(theme);
},
}),
{
name: 'voicebox-ui',
- partialize: (state) => ({ selectedProfileId: state.selectedProfileId }),
+ partialize: (state) => ({
+ selectedProfileId: state.selectedProfileId,
+ theme: state.theme,
+ }),
+ onRehydrateStorage: () => (state) => {
+ if (state) applyTheme(state.theme);
+ },
},
),
);
diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec
index 3e5fac49..7ef6c63d 100644
--- a/backend/voicebox-server.spec
+++ b/backend/voicebox-server.spec
@@ -5,7 +5,7 @@ from PyInstaller.utils.hooks import copy_metadata
datas = []
binaries = []
-hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
+hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.mcp_server', 'backend.mcp_server.server', 'backend.mcp_server.tools', 'backend.mcp_server.context', 'backend.mcp_server.resolve', 'backend.mcp_server.events', 'sse_starlette', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
datas += copy_metadata('qwen-tts')
datas += copy_metadata('requests')
datas += copy_metadata('transformers')
@@ -48,6 +48,10 @@ tmp_ret = collect_all('en_core_web_sm')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('unidic_lite')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
+tmp_ret = collect_all('fastmcp')
+datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
+tmp_ret = collect_all('mcp')
+datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('mlx')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('mlx_audio')
diff --git a/docs/plans/MACOS_NOTARIZATION.md b/docs/plans/MACOS_NOTARIZATION.md
new file mode 100644
index 00000000..d960c381
--- /dev/null
+++ b/docs/plans/MACOS_NOTARIZATION.md
@@ -0,0 +1,102 @@
+# macOS Notarization & Gatekeeper
+
+**Status:** Diagnosis — Homebrew Cask CI rejects v0.4.5 on macOS 15 (Sequoia); fix pending
+**Touches:** `.github/workflows/release.yml`, Tauri bundler config, sidecar signing
+**Last reviewed:** 2026-04-24
+
+## Context
+
+Homebrew Cask PR [#260314](https://github.com/Homebrew/homebrew-cask/pull/260314) adds `brew install --cask voicebox`. CI is green on macOS 14 and macOS 26 (arm + intel) but fails on macOS 15 (arm + intel). The 0.4.3 release added DMG-level stapling to address this, and it didn't move CI — 0.4.5 still fails. A maintainer reproduced the failure in a fresh Sequoia VM.
+
+This document is the working diagnosis plus the ordered fix plan.
+
+## What the failing check actually does
+
+The failing step is `brew audit --cask --online --signing --new voicebox`, not `brew install`. `brew install` succeeds end-to-end in CI (the log shows `Uninstalling Cask voicebox` after the install phase). The `--signing` audit:
+
+1. Downloads the cask's `url`
+2. Mounts the DMG
+3. Runs `spctl --assess -t open --context context:primary-signature` against the `.app` inside
+
+That policy tests the first-launch Gatekeeper path on the extracted bundle. It reads the `.app`'s own code signature and notarization ticket — the DMG wrapper is not involved. The staple added in 0.4.3 covers the DMG, so it has no effect on this audit.
+
+## Why Sequoia and not Sonoma
+
+`spctl -t open` on macOS 15 enforces checks that 14 tolerated:
+
+- Secure timestamp required on hardened-runtime signatures. Untimestamped signatures pass on 14, fail on 15.
+- Deep verification of nested Mach-Os. If any embedded `.dylib` or helper binary carries an ad-hoc signature (or a signature with a different Team ID), 15 rejects the whole bundle; 14 often accepted it.
+- Hardened runtime must be set on every nested executable, not just the top-level app binary. Entitlements declared on the outer app do not propagate.
+
+Local dev machines pass `spctl` because the first-party developer context and cached notarization tickets mask these failures. A fresh Sequoia VM with no prior trust state does not.
+
+## Where the gap is likely to be
+
+Voicebox ships PyInstaller sidecars declared in `tauri.conf.json` under `externalBin`:
+
+- **0.4.x:** `voicebox-server` only (single `--onefile` Mach-O on macOS)
+- **0.5.0+:** `voicebox-server` and `voicebox-mcp` (`voicebox-mcp` is new in 0.5.0)
+
+Tauri's bundler signs each `externalBin` with the configured identity but does not apply `--options=runtime` or `--timestamp` automatically, and does not merge the outer app's entitlements into the sidecar signature. The outer `Voicebox` binary is correctly signed with hardened runtime + `disable-library-validation`; the sidecars likely are not.
+
+Order of likelihood:
+
+1. Sidecar `voicebox-server` lacks hardened runtime or a secure timestamp in its signature.
+2. The sidecar inherits the identity but was signed before tauri-action's final notarization pass, so the notarization ticket doesn't actually cover it.
+3. Something inside the sidecar's PyInstaller archive unpacks to a `.dylib` at runtime that Gatekeeper inspects during assessment.
+
+The 0.5.0 fix must cover both sidecars.
+
+## Diagnostic commands
+
+Run against a freshly downloaded release DMG (not a dev build, and from a machine that has never opened the app before):
+
+```
+hdiutil attach Voicebox_0.4.5_aarch64.dmg
+xcrun stapler validate "/Volumes/Voicebox 0.4.5/Voicebox.app"
+spctl -a -vvv -t open --context context:primary-signature "/Volumes/Voicebox 0.4.5/Voicebox.app"
+codesign --verify --deep --strict --verbose=2 "/Volumes/Voicebox 0.4.5/Voicebox.app"
+codesign -dv --verbose=4 "/Volumes/Voicebox 0.4.5/Voicebox.app/Contents/MacOS/voicebox-server"
+```
+
+The last command is the tell — look for `flags=0x10000(runtime)` and a `Timestamp=` line. If either is missing, the sidecar is the failure.
+
+`spctl -t install` (what 0.4.3 verified with) is a different policy and can pass while `-t open` fails — any future verification should use `-t open --context context:primary-signature` to match what Homebrew's audit runs.
+
+## Phases
+
+### Phase 1 — Confirm the failure mode
+
+Pull the 0.4.5 DMG on a fresh Sequoia environment or a VM snapshot with no trust state. Run the diagnostic block above. Record the exact failing command and its CSSMERR / rejection reason. This disambiguates between the three hypotheses before we change the workflow.
+
+### Phase 2 — Sign sidecars explicitly in the release workflow
+
+Between tauri-action's build step and the DMG-notarization step already in `release.yml`, add a step that re-signs every `externalBin` present under `Voicebox.app/Contents/MacOS/` with:
+
+- `--options=runtime` (hardened runtime)
+- `--timestamp` (secure timestamp)
+- `--entitlements` pointing at `Entitlements.plist` or a sidecar-specific subset
+- The same `APPLE_SIGNING_IDENTITY` the outer app uses
+
+Re-sign the outer `.app` afterward so its seal covers the updated nested signatures.
+
+Covers `voicebox-server` on 0.4.x and both sidecars from 0.5.0 forward.
+
+### Phase 3 — Re-notarize and staple the `.app`
+
+After sidecars are re-signed the outer bundle's notarization ticket is stale. Submit the `.app` (zipped) to `notarytool`, wait, then `xcrun stapler staple Voicebox.app`. This puts the ticket directly on the `.app` so the `spctl -t open` audit passes without any online ticket lookup.
+
+Then rebuild the DMG from the stapled `.app` and keep the existing DMG-level notarize/staple step — it still helps Finder drag-install.
+
+### Phase 4 — CI verification gate in the release workflow
+
+Before upload, run the same four diagnostic commands against the built artifact inside the workflow. If any fail, fail the release job rather than shipping a DMG that Homebrew (and Sequoia Finder users) will reject. This is the check that would have caught the 0.4.3 and 0.4.5 attempts before they cost PR review cycles.
+
+### Phase 5 — Re-request Homebrew CI
+
+Once a tagged release passes Phase 4 locally, push a cask update to #260314. Expect `test voicebox (macos-15, arm)` and `test voicebox (macos-15-intel, intel)` to go green.
+
+## Open questions
+
+- Does tauri-action v0.6 pass `APPLE_API_KEY_PATH` to the bundler's notarize path, or does it rely on the `~/.appstoreconnect/private_keys/AuthKey_*.p8` auto-discovery the staple step already sets up? If the former isn't working, tauri may be signing but never notarizing the `.app`, which would make the ticket absent entirely rather than stale. Worth a `grep -i notariz` on a full release job log.
+- If Phase 2 resolves the macOS 15 failure, revisit whether the 0.4.3 DMG staple step is still needed. It's cheap to keep and helps the Finder-open case, so default to leaving it.
diff --git a/landing/src/components/Personalities.tsx b/landing/src/components/Personalities.tsx
index 07e2ec3a..296fc8bd 100644
--- a/landing/src/components/Personalities.tsx
+++ b/landing/src/components/Personalities.tsx
@@ -1,36 +1,27 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
-import { ArrowRight, MessageSquareReply, PenLine, Sparkles } from 'lucide-react';
+import { ArrowRight, Dices, Wand2 } from 'lucide-react';
import { useEffect, useState } from 'react';
// ─── Modes ──────────────────────────────────────────────────────────────────
type Mode = {
- id: 'compose' | 'rewrite' | 'respond';
+ id: 'compose' | 'rewrite';
label: string;
- icon: typeof Sparkles;
- inputLabel: string;
+ icon: typeof Dices;
outputLabel: string;
- input: string;
output: string;
-};
+} & (
+ | { inputLabel: string; input: string }
+ | { inputLabel?: undefined; input?: undefined }
+);
const MODES: Mode[] = [
- {
- id: 'compose',
- label: 'Compose',
- icon: Sparkles,
- inputLabel: 'Prompt',
- outputLabel: "Marlowe, in character",
- input: 'celebrate the deploy going green',
- output:
- "She came through clean. Not a single test casting a shadow. In this town, that's usually when you start worrying.",
- },
{
id: 'rewrite',
label: 'Rewrite',
- icon: PenLine,
+ icon: Wand2,
inputLabel: 'Your text',
outputLabel: "Marlowe, in character",
input: 'the build is done and we shipped to production',
@@ -38,14 +29,12 @@ const MODES: Mode[] = [
"Build's wrapped, ship's left the dock. Another stack of code makes its way into prod, another row of green checks lining the wall.",
},
{
- id: 'respond',
- label: 'Respond',
- icon: MessageSquareReply,
- inputLabel: 'Question',
+ id: 'compose',
+ label: 'Compose',
+ icon: Dices,
outputLabel: "Marlowe, in character",
- input: 'should I refactor this before merging or do it after?',
output:
- "Listen, kid. You can polish the brass on the door, or you can open it. Open the door — refactor in daylight.",
+ "She came through clean. Not a single test casting a shadow. In this town, that's usually when you start worrying.",
},
];
@@ -116,14 +105,26 @@ function ModeDemo({ mode, cycleKey }: { mode: Mode; cycleKey: number }) {
className="flex flex-col gap-4 flex-1"
>
{/* Input */}
-
-
- {mode.inputLabel}
+ {mode.input ? (
+
+
+ {mode.inputLabel}
+
+
+ {mode.input}
+
-
- {mode.input}
+ ) : (
+
+
+ No input
+
+
+
+ Click Compose — the character improvises a fresh line.
+
-
+ )}
{/* Arrow */}
@@ -151,22 +152,16 @@ function ModeDemo({ mode, cycleKey }: { mode: Mode; cycleKey: number }) {
const BULLETS = [
{
- icon: Sparkles,
- title: 'Compose',
- description:
- 'Generate a fresh utterance in the character’s voice from a short prompt. Useful for game dialogue, narration cues, or character barks.',
- },
- {
- icon: PenLine,
+ icon: Wand2,
title: 'Rewrite',
description:
'Restate your text in their voice while preserving every idea. Same content, their delivery — for scripts, dubs, and consistent character voice across long-form work.',
},
{
- icon: MessageSquareReply,
- title: 'Respond',
+ icon: Dices,
+ title: 'Compose',
description:
- 'Treat your text as a prompt and produce the character’s reply. The persona half of the dictation → speak loop.',
+ 'No input needed — hit the button and the character improvises a fresh line of their own. Roll again for another take. Useful for game dialogue, narration cues, or character barks.',
},
];
@@ -197,9 +192,8 @@ export function Personalities() {
Give any voice profile a free-form personality. Then{' '}
- Compose,{' '}
- Rewrite, or{' '}
- Respond — your cloned voice, in full character.
+ Rewrite your text in their voice, or let them{' '}
+ Compose a fresh line of their own — your cloned voice, in full character.
@@ -210,7 +204,7 @@ export function Personalities() {