feat: add accent color control and launcher for Open Design (#683)

* feat: add accent color control and launcher for Open Design

* fix: remove launcher binary from PR

* test: cover accent appearance edge cases

---------

Co-authored-by: ferasbusiness666 <ferasbusiness666@users.noreply.github.com>
This commit is contained in:
Feroomon2010 2026-05-06 18:14:21 +03:00 committed by GitHub
parent 9af288652c
commit 576dfed9e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 371 additions and 21 deletions

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ out/
.tmp/ .tmp/
.DS_Store .DS_Store
*.log *.log
*.exe
.vite .vite
.astro/ .astro/
.vscode .vscode

View file

@ -322,6 +322,8 @@ pnpm tools-dev run web
# open the web URL printed by tools-dev # open the web URL printed by tools-dev
``` ```
مشغّل Windows: ابنِ `OpenDesign.exe` بنفسك باتباع التعليمات في `tools/launcher/README.md`، أو نزّله من GitHub Releases. بعد ذلك ضعه في جذر المستودع وانقر عليه مرتين ليشغّل `pnpm install` عند الحاجة ثم يبدأ Open Design عبر `pnpm tools-dev`.
متطلّبات البيئة: Node `~24` و pnpm `10.33.x`. أدوات `nvm`/`fnm` اختيارية فقط؛ إن استخدمت إحداها فشغّل `nvm install 24 && nvm use 24` أو `fnm install 24 && fnm use 24` قبل `pnpm install`. متطلّبات البيئة: Node `~24` و pnpm `10.33.x`. أدوات `nvm`/`fnm` اختيارية فقط؛ إن استخدمت إحداها فشغّل `nvm install 24 && nvm use 24` أو `fnm install 24 && fnm use 24` قبل `pnpm install`.
لتشغيل سطح المكتب / الخلفية، إعادة التشغيل بمنافذ ثابتة، وفحوص dispatcher توليد الوسائط (`OD_BIN`، `OD_DAEMON_URL`، `apps/daemon/dist/cli.js`) راجع [`QUICKSTART.md`](QUICKSTART.md). لتشغيل سطح المكتب / الخلفية، إعادة التشغيل بمنافذ ثابتة، وفحوص dispatcher توليد الوسائط (`OD_BIN`، `OD_DAEMON_URL`، `apps/daemon/dist/cli.js`) راجع [`QUICKSTART.md`](QUICKSTART.md).

View file

@ -319,6 +319,8 @@ pnpm tools-dev run web
# open the web URL printed by tools-dev # open the web URL printed by tools-dev
``` ```
Windows-Launcher: Erstellen Sie `OpenDesign.exe` selbst mit der Anleitung in `tools/launcher/README.md`, oder laden Sie ihn aus GitHub Releases herunter. Legen Sie die Datei danach in den Repository-Stamm und doppelklicken Sie sie, um bei Bedarf `pnpm install` auszuführen und Open Design mit `pnpm tools-dev` zu starten.
Umgebungsanforderungen: Node `~24` und pnpm `10.33.x`. `nvm`/`fnm` sind nur optionale Helfer; wenn Sie eines davon nutzen, führen Sie vor `pnpm install` `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24` aus. Umgebungsanforderungen: Node `~24` und pnpm `10.33.x`. `nvm`/`fnm` sind nur optionale Helfer; wenn Sie eines davon nutzen, führen Sie vor `pnpm install` `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24` aus.
Für Desktop-/Background-Start, Fixed-Port-Restarts und Media-Generation-Dispatcher-Checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`) siehe [`QUICKSTART.de.md`](QUICKSTART.de.md). Für Desktop-/Background-Start, Fixed-Port-Restarts und Media-Generation-Dispatcher-Checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`) siehe [`QUICKSTART.de.md`](QUICKSTART.de.md).

View file

@ -310,6 +310,8 @@ pnpm tools-dev run web
# open the web URL printed by tools-dev # open the web URL printed by tools-dev
``` ```
Lanzador de Windows: compila `OpenDesign.exe` con las instrucciones de `tools/launcher/README.md` o descárgalo desde GitHub Releases. Después colócalo en la raíz del repo y haz doble clic para ejecutar `pnpm install` si hace falta e iniciar Open Design con `pnpm tools-dev`.
Requisitos de entorno: Node `~24` y pnpm `10.33.x`. `nvm`/`fnm` son helpers opcionales; si usas uno, ejecuta `nvm install 24 && nvm use 24` o `fnm install 24 && fnm use 24` antes de `pnpm install`. Requisitos de entorno: Node `~24` y pnpm `10.33.x`. `nvm`/`fnm` son helpers opcionales; si usas uno, ejecuta `nvm install 24 && nvm use 24` o `fnm install 24 && fnm use 24` antes de `pnpm install`.
Para arranque desktop/background, reinicios con puerto fijo y checks del dispatcher de media generation (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), consulta [`QUICKSTART.md`](QUICKSTART.md). Para arranque desktop/background, reinicios con puerto fijo y checks del dispatcher de media generation (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), consulta [`QUICKSTART.md`](QUICKSTART.md).

View file

@ -320,6 +320,8 @@ pnpm tools-dev run web
# open the web URL printed by tools-dev # open the web URL printed by tools-dev
``` ```
Lanceur Windows : compilez `OpenDesign.exe` avec les instructions de `tools/launcher/README.md`, ou téléchargez-le depuis GitHub Releases. Placez-le ensuite à la racine du dépôt et double-cliquez dessus pour lancer `pnpm install` si nécessaire, puis démarrer Open Design avec `pnpm tools-dev`.
Prérequis : Node `~24` et pnpm `10.33.x`. `nvm` / `fnm` ne sont que des aides facultatives ; si vous en utilisez un, lancez `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` avant `pnpm install`. Prérequis : Node `~24` et pnpm `10.33.x`. `nvm` / `fnm` ne sont que des aides facultatives ; si vous en utilisez un, lancez `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` avant `pnpm install`.
Pour le démarrage desktop/background, les redémarrages sur ports fixes et les checks du dispatcher de génération média (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), voir [`QUICKSTART.fr.md`](QUICKSTART.fr.md). Pour le démarrage desktop/background, les redémarrages sur ports fixes et les checks du dispatcher de génération média (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), voir [`QUICKSTART.fr.md`](QUICKSTART.fr.md).

View file

@ -320,6 +320,8 @@ pnpm tools-dev run web
# tools-dev が出力した Web URL を開く # tools-dev が出力した Web URL を開く
``` ```
Windows ランチャー: `tools/launcher/README.md` の手順で `OpenDesign.exe` を自分でビルドするか、GitHub Releases からダウンロードします。その後、リポジトリのルートに置いてダブルクリックすると、必要に応じて `pnpm install` を実行し、`pnpm tools-dev` で Open Design を起動します。
環境要件Node `~24`、pnpm `10.33.x`。`nvm` / `fnm` はあくまでオプションのヘルパーです。使用する場合は `pnpm install` の前に `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。 環境要件Node `~24`、pnpm `10.33.x`。`nvm` / `fnm` はあくまでオプションのヘルパーです。使用する場合は `pnpm install` の前に `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。
デスクトップ / バックグラウンド起動、固定ポート再起動、メディア生成ディスパッチャの確認(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)は [`QUICKSTART.ja-JP.md`](QUICKSTART.ja-JP.md) を参照。 デスクトップ / バックグラウンド起動、固定ポート再起動、メディア生成ディスパッチャの確認(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)は [`QUICKSTART.ja-JP.md`](QUICKSTART.ja-JP.md) を参照。

View file

@ -319,6 +319,8 @@ pnpm tools-dev run web
# tools-dev가 출력한 web URL을 여세요 # tools-dev가 출력한 web URL을 여세요
``` ```
Windows 런처: `tools/launcher/README.md`의 안내에 따라 `OpenDesign.exe`를 직접 빌드하거나 GitHub Releases에서 다운로드하세요. 그런 다음 저장소 루트에 두고 두 번 클릭하면 필요할 때 `pnpm install`을 실행한 뒤 `pnpm tools-dev`로 Open Design을 시작합니다.
환경 요구사항: Node `~24`와 pnpm `10.33.x`. `nvm` / `fnm`은 선택적 보조 도구일 뿐입니다; 사용한다면 `pnpm install` 전에 `nvm install 24 && nvm use 24` 또는 `fnm install 24 && fnm use 24`를 실행하세요. 환경 요구사항: Node `~24`와 pnpm `10.33.x`. `nvm` / `fnm`은 선택적 보조 도구일 뿐입니다; 사용한다면 `pnpm install` 전에 `nvm install 24 && nvm use 24` 또는 `fnm install 24 && fnm use 24`를 실행하세요.
첫 번째 로드 시: 첫 번째 로드 시:

View file

@ -320,6 +320,8 @@ pnpm tools-dev run web
# open the web URL printed by tools-dev # open the web URL printed by tools-dev
``` ```
Windows launcher: build `OpenDesign.exe` yourself with the instructions in `tools/launcher/README.md`, or download it from GitHub Releases. Then place it in the repo root and double-click it to run `pnpm install` if needed and start Open Design with `pnpm tools-dev`.
Environment requirements: Node `~24` and pnpm `10.33.x`. `nvm`/`fnm` are optional helpers only; if you use one, run `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` before `pnpm install`. Environment requirements: Node `~24` and pnpm `10.33.x`. `nvm`/`fnm` are optional helpers only; if you use one, run `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` before `pnpm install`.
For desktop/background startup, fixed-port restarts, and media generation dispatcher checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), see [`QUICKSTART.md`](QUICKSTART.md). For desktop/background startup, fixed-port restarts, and media generation dispatcher checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), see [`QUICKSTART.md`](QUICKSTART.md).

View file

@ -320,6 +320,8 @@ pnpm tools-dev run web
# open the web URL printed by tools-dev # open the web URL printed by tools-dev
``` ```
Inicializador do Windows: compile `OpenDesign.exe` com as instruções em `tools/launcher/README.md` ou baixe-o pelo GitHub Releases. Depois coloque-o na raiz do repo e dê dois cliques para executar `pnpm install` se necessário e iniciar o Open Design com `pnpm tools-dev`.
Requisitos de ambiente: Node `~24` e pnpm `10.33.x`. `nvm`/`fnm` são apenas helpers opcionais; se você usa um, rode `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` antes do `pnpm install`. Requisitos de ambiente: Node `~24` e pnpm `10.33.x`. `nvm`/`fnm` são apenas helpers opcionais; se você usa um, rode `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` antes do `pnpm install`.
Para startup desktop/background, restart com porta fixa e checagens do dispatcher de geração de mídia (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), veja [`QUICKSTART.pt-BR.md`](QUICKSTART.pt-BR.md). Para startup desktop/background, restart com porta fixa e checagens do dispatcher de geração de mídia (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), veja [`QUICKSTART.pt-BR.md`](QUICKSTART.pt-BR.md).

View file

@ -320,6 +320,8 @@ pnpm tools-dev run web
# откройте web URL, который напечатает tools-dev # откройте web URL, который напечатает tools-dev
``` ```
Лаунчер Windows: соберите `OpenDesign.exe` самостоятельно по инструкции в `tools/launcher/README.md` или скачайте его из GitHub Releases. Затем поместите файл в корень репозитория и дважды щёлкните его, чтобы при необходимости выполнить `pnpm install` и запустить Open Design через `pnpm tools-dev`.
Требования к окружению: Node `~24` и pnpm `10.33.x`. `nvm`/`fnm` — только вспомогательные инструменты; если вы ими пользуетесь, выполните `nvm install 24 && nvm use 24` или `fnm install 24 && fnm use 24` перед `pnpm install`. Требования к окружению: Node `~24` и pnpm `10.33.x`. `nvm`/`fnm` — только вспомогательные инструменты; если вы ими пользуетесь, выполните `nvm install 24 && nvm use 24` или `fnm install 24 && fnm use 24` перед `pnpm install`.
Для desktop/background startup, перезапуска на фиксированных портах и проверки dispatcherа media generation (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`) смотрите [`QUICKSTART.md`](QUICKSTART.md). Для desktop/background startup, перезапуска на фиксированных портах и проверки dispatcherа media generation (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`) смотрите [`QUICKSTART.md`](QUICKSTART.md).

View file

@ -320,6 +320,8 @@ pnpm tools-dev run web
# відкрийте URL у браузері, який виведе tools-dev # відкрийте URL у браузері, який виведе tools-dev
``` ```
Лаунчер Windows: зберіть `OpenDesign.exe` самостійно за інструкцією в `tools/launcher/README.md` або завантажте його з GitHub Releases. Потім покладіть файл у корінь репозиторію й двічі клацніть його, щоб за потреби виконати `pnpm install` і запустити Open Design через `pnpm tools-dev`.
Вимоги до середовища: Node `~24` та pnpm `10.33.x`. `nvm`/`fnm` є лише додатковими помічниками; якщо ви використовуєте один з них, запустіть `nvm install 24 && nvm use 24` або `fnm install 24 && fnm use 24` перед `pnpm install`. Вимоги до середовища: Node `~24` та pnpm `10.33.x`. `nvm`/`fnm` є лише додатковими помічниками; якщо ви використовуєте один з них, запустіть `nvm install 24 && nvm use 24` або `fnm install 24 && fnm use 24` перед `pnpm install`.
Для запуску desktop/background, перезапусків з фіксованими портами та перевірок диспетчера генерації медіа (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), див. [`QUICKSTART.md`](QUICKSTART.md). Для запуску desktop/background, перезапусків з фіксованими портами та перевірок диспетчера генерації медіа (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), див. [`QUICKSTART.md`](QUICKSTART.md).

View file

@ -319,6 +319,8 @@ pnpm tools-dev run web
# 打开 tools-dev 输出的 web URL # 打开 tools-dev 输出的 web URL
``` ```
Windows 启动器:请按照 `tools/launcher/README.md` 中的说明自行构建 `OpenDesign.exe`,或从 GitHub Releases 下载。然后将它放到仓库根目录并双击;它会在需要时运行 `pnpm install`,再用 `pnpm tools-dev` 启动 Open Design。
环境要求Node `~24`pnpm `10.33.x`。`nvm` / `fnm` 只是可选辅助工具,不是项目必需步骤;如果使用它们,先执行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`,再运行 `pnpm install` 环境要求Node `~24`pnpm `10.33.x`。`nvm` / `fnm` 只是可选辅助工具,不是项目必需步骤;如果使用它们,先执行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`,再运行 `pnpm install`
桌面端/后台启动、固定端口重启,以及 media 生成派发器检查(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)见 [`QUICKSTART.zh-CN.md`](QUICKSTART.zh-CN.md)。 桌面端/后台启动、固定端口重启,以及 media 生成派发器检查(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)见 [`QUICKSTART.zh-CN.md`](QUICKSTART.zh-CN.md)。

View file

@ -317,6 +317,8 @@ pnpm tools-dev run web
# 開啟 tools-dev 輸出的 web URL # 開啟 tools-dev 輸出的 web URL
``` ```
Windows 啟動器:請依照 `tools/launcher/README.md` 的說明自行建置 `OpenDesign.exe`,或從 GitHub Releases 下載。接著將它放到 repo 根目錄並雙擊;它會在需要時執行 `pnpm install`,再用 `pnpm tools-dev` 啟動 Open Design。
環境要求Node `~24`pnpm `10.33.x`。`nvm` / `fnm` 只是可選輔助工具,不是專案必需步驟;如果使用它們,先執行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`,再執行 `pnpm install` 環境要求Node `~24`pnpm `10.33.x`。`nvm` / `fnm` 只是可選輔助工具,不是專案必需步驟;如果使用它們,先執行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`,再執行 `pnpm install`
桌面版/後臺啟動、固定埠重啟,以及 media 生成派發器檢查(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)見 [`QUICKSTART.md`](QUICKSTART.md)。 桌面版/後臺啟動、固定埠重啟,以及 media 生成派發器檢查(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)見 [`QUICKSTART.md`](QUICKSTART.md)。

View file

@ -23,8 +23,10 @@ export const viewport: Viewport = {
* preference without a flash of unstyled content. It reads the same * preference without a flash of unstyled content. It reads the same
* localStorage key used by `state/config.ts` and sets `data-theme` on * localStorage key used by `state/config.ts` and sets `data-theme` on
* `<html>` immediately before any CSS or React paint. * `<html>` immediately before any CSS or React paint.
* Keep the accent variable mix ratios in sync with `accentVars()` in
* `src/state/appearance.ts`; this script cannot import application modules.
*/ */
const themeInitScript = `(function(){try{var t=JSON.parse(localStorage.getItem('open-design:config')||'{}').theme;if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`; const themeInitScript = `(function(){try{var c=JSON.parse(localStorage.getItem('open-design:config')||'{}');var t=c.theme;if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t);var a=typeof c.accentColor==='string'&&/^#[0-9a-fA-F]{6}$/.test(c.accentColor.trim())?c.accentColor.trim().toLowerCase():'';if(a){var s=document.documentElement.style;s.setProperty('--accent',a);s.setProperty('--accent-strong','color-mix(in srgb, '+a+' 86%, var(--text-strong))');s.setProperty('--accent-soft','color-mix(in srgb, '+a+' 22%, var(--bg-panel))');s.setProperty('--accent-tint','color-mix(in srgb, '+a+' 12%, var(--bg-panel))');s.setProperty('--accent-hover','color-mix(in srgb, '+a+' 90%, var(--text-strong))');}}catch(e){}})();`;
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (

View file

@ -29,6 +29,7 @@ import {
syncConfigToDaemon, syncConfigToDaemon,
syncMediaProvidersToDaemon, syncMediaProvidersToDaemon,
} from './state/config'; } from './state/config';
import { applyAppearanceToDocument } from './state/appearance';
import { import {
createProject, createProject,
deleteProject as deleteProjectApi, deleteProject as deleteProjectApi,
@ -90,13 +91,11 @@ export function App() {
// live theme switch in Settings applies atomically — no 1-frame flash of // live theme switch in Settings applies atomically — no 1-frame flash of
// the old theme. Safe here because the component tree is ssr:false. // the old theme. Safe here because the component tree is ssr:false.
useLayoutEffect(() => { useLayoutEffect(() => {
const theme = config.theme ?? 'system'; applyAppearanceToDocument({
if (theme === 'system') { theme: config.theme ?? 'system',
document.documentElement.removeAttribute('data-theme'); accentColor: config.accentColor,
} else { });
document.documentElement.setAttribute('data-theme', theme); }, [config.theme, config.accentColor]);
}
}, [config.theme]);
// Tell the daemon what the user is currently looking at, so the MCP // Tell the daemon what the user is currently looking at, so the MCP
// server can surface it as `get_active_context` to a coding agent in // server can surface it as `get_active_context` to a coding agent in

View file

@ -21,6 +21,10 @@ import { MEDIA_PROVIDERS } from '../media/models';
import type { MediaProvider } from '../media/models'; import type { MediaProvider } from '../media/models';
import { PetSettings } from './pet/PetSettings'; import { PetSettings } from './pet/PetSettings';
import { LibrarySection } from './LibrarySection'; import { LibrarySection } from './LibrarySection';
import {
applyAppearanceToDocument,
normalizeAccentColor,
} from '../state/appearance';
import { import {
FAILURE_SOUNDS, FAILURE_SOUNDS,
SUCCESS_SOUNDS, SUCCESS_SOUNDS,
@ -372,15 +376,13 @@ export function SettingsDialog({
// On Save, App's useLayoutEffect fires after unmount and applies the new // On Save, App's useLayoutEffect fires after unmount and applies the new
// saved theme, so this cleanup is effectively a no-op in that path. // saved theme, so this cleanup is effectively a no-op in that path.
useLayoutEffect(() => { useLayoutEffect(() => {
const saved = initial.theme ?? 'system';
return () => { return () => {
if (saved === 'system') { applyAppearanceToDocument({
document.documentElement.removeAttribute('data-theme'); theme: initial.theme ?? 'system',
} else { accentColor: initial.accentColor,
document.documentElement.setAttribute('data-theme', saved); });
}
}; };
}, [initial.theme]); }, [initial.theme, initial.accentColor]);
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [languageOpen, setLanguageOpen] = useState(false); const [languageOpen, setLanguageOpen] = useState(false);
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection); const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection);
@ -2003,6 +2005,18 @@ const THEMES: Array<{ value: AppTheme; labelKey: 'settings.themeSystem' | 'setti
{ value: 'dark', labelKey: 'settings.themeDark' }, { value: 'dark', labelKey: 'settings.themeDark' },
]; ];
const DEFAULT_ACCENT_COLOR = '#c96442';
const ACCENT_SWATCHES = [
DEFAULT_ACCENT_COLOR,
'#2563eb',
'#7c3aed',
'#059669',
'#dc2626',
'#d97706',
'#0891b2',
'#db2777',
] as const;
function AppearanceSection({ function AppearanceSection({
cfg, cfg,
setCfg, setCfg,
@ -2012,16 +2026,20 @@ function AppearanceSection({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const current = cfg.theme ?? 'system'; const current = cfg.theme ?? 'system';
const currentAccent = normalizeAccentColor(cfg.accentColor) ?? DEFAULT_ACCENT_COLOR;
// Apply the draft theme immediately so the user sees a live preview // Apply the draft theme immediately so the user sees a live preview
// before hitting Save. SettingsDialog's cleanup reverts this on cancel. // before hitting Save. SettingsDialog's cleanup reverts this on cancel.
useLayoutEffect(() => { useLayoutEffect(() => {
if (current === 'system') { applyAppearanceToDocument({
document.documentElement.removeAttribute('data-theme'); theme: current,
} else { accentColor: cfg.accentColor,
document.documentElement.setAttribute('data-theme', current); });
} }, [current, cfg.accentColor]);
}, [current]);
const setAccentColor = (color: string | undefined) => {
setCfg((c) => ({ ...c, accentColor: color ? normalizeAccentColor(color) ?? c.accentColor : undefined }));
};
return ( return (
<section className="settings-section"> <section className="settings-section">
@ -2044,6 +2062,33 @@ function AppearanceSection({
</button> </button>
))} ))}
</div> </div>
<div className="field">
<span className="field-label">Accent color</span>
<div className="pet-swatches" role="radiogroup" aria-label="Accent color">
{ACCENT_SWATCHES.map((color) => {
const active = currentAccent === color;
return (
<button
key={color}
type="button"
className={`pet-swatch${active ? ' active' : ''}`}
style={{ background: color }}
aria-label={color === DEFAULT_ACCENT_COLOR ? 'Default accent color' : color}
aria-checked={active}
role="radio"
onClick={() => setAccentColor(color === DEFAULT_ACCENT_COLOR ? undefined : color)}
/>
);
})}
<input
type="color"
aria-label="Custom accent color"
className="pet-swatch-picker"
value={currentAccent}
onChange={(e) => setAccentColor(e.target.value)}
/>
</div>
</div>
</section> </section>
); );
} }

View file

@ -0,0 +1,52 @@
import type { AppTheme } from '../types';
const ACCENT_VARS = [
'--accent',
'--accent-strong',
'--accent-soft',
'--accent-tint',
'--accent-hover',
] as const;
export function normalizeAccentColor(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
}
function accentVars(accentColor: string): Record<(typeof ACCENT_VARS)[number], string> {
return {
'--accent': accentColor,
// Keep these mix ratios in sync with the pre-hydration script in app/layout.tsx.
'--accent-strong': `color-mix(in srgb, ${accentColor} 86%, var(--text-strong))`,
'--accent-soft': `color-mix(in srgb, ${accentColor} 22%, var(--bg-panel))`,
'--accent-tint': `color-mix(in srgb, ${accentColor} 12%, var(--bg-panel))`,
'--accent-hover': `color-mix(in srgb, ${accentColor} 90%, var(--text-strong))`,
};
}
export function applyAppearanceToDocument({
theme,
accentColor,
}: {
theme?: AppTheme;
accentColor?: string;
}): void {
const root = document.documentElement;
if (theme === 'light' || theme === 'dark') {
root.setAttribute('data-theme', theme);
} else {
root.removeAttribute('data-theme');
}
const normalized = normalizeAccentColor(accentColor);
if (!normalized) {
for (const name of ACCENT_VARS) root.style.removeProperty(name);
return;
}
const vars = accentVars(normalized);
for (const name of ACCENT_VARS) {
root.style.setProperty(name, vars[name]);
}
}

View file

@ -7,6 +7,7 @@ import type {
NotificationsConfig, NotificationsConfig,
PetConfig, PetConfig,
} from '../types'; } from '../types';
import { normalizeAccentColor } from './appearance';
import { import {
DEFAULT_FAILURE_SOUND_ID, DEFAULT_FAILURE_SOUND_ID,
DEFAULT_SUCCESS_SOUND_ID, DEFAULT_SUCCESS_SOUND_ID,
@ -237,6 +238,7 @@ export function loadConfig(): AppConfig {
composio: { ...(parsed.composio ?? {}) }, composio: { ...(parsed.composio ?? {}) },
agentModels: { ...(parsed.agentModels ?? {}) }, agentModels: { ...(parsed.agentModels ?? {}) },
agentCliEnv: { ...(parsed.agentCliEnv ?? {}) }, agentCliEnv: { ...(parsed.agentCliEnv ?? {}) },
accentColor: normalizeAccentColor(parsed.accentColor) ?? DEFAULT_CONFIG.accentColor,
pet: normalizePet(parsed.pet), pet: normalizePet(parsed.pet),
notifications: normalizeNotifications(parsed.notifications), notifications: normalizeNotifications(parsed.notifications),
}; };

View file

@ -251,6 +251,7 @@ export interface AppConfig {
skillId: string | null; skillId: string | null;
designSystemId: string | null; designSystemId: string | null;
theme?: AppTheme; theme?: AppTheme;
accentColor?: string;
// True once the user has been through the welcome onboarding modal at // True once the user has been through the welcome onboarding modal at
// least once (saved or skipped). Bootstrap skips the auto-popup when // least once (saved or skipped). Bootstrap skips the auto-popup when
// this is set so refreshing the page doesn't re-prompt. // this is set so refreshing the page doesn't re-prompt.

View file

@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import {
applyAppearanceToDocument,
normalizeAccentColor,
} from '../../src/state/appearance';
describe('normalizeAccentColor', () => {
it('accepts six-digit hex colors and normalizes casing', () => {
expect(normalizeAccentColor(' #4F46E5 ')).toBe('#4f46e5');
});
it('rejects invalid accent colors', () => {
expect(normalizeAccentColor('blue')).toBeNull();
expect(normalizeAccentColor('#123')).toBeNull();
expect(normalizeAccentColor('#12345g')).toBeNull();
});
});
describe('applyAppearanceToDocument', () => {
afterEach(() => {
document.documentElement.removeAttribute('data-theme');
document.documentElement.style.removeProperty('--accent');
document.documentElement.style.removeProperty('--accent-strong');
document.documentElement.style.removeProperty('--accent-soft');
document.documentElement.style.removeProperty('--accent-tint');
document.documentElement.style.removeProperty('--accent-hover');
});
it('applies the saved theme and accent variables to the root element', () => {
applyAppearanceToDocument({ theme: 'dark', accentColor: '#4F46E5' });
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#4f46e5');
expect(document.documentElement.style.getPropertyValue('--accent-hover')).toContain('#4f46e5');
});
it('applies accent variables while clearing an explicit theme for system mode', () => {
document.documentElement.setAttribute('data-theme', 'dark');
applyAppearanceToDocument({ theme: 'system', accentColor: '#10B981' });
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#10b981');
expect(document.documentElement.style.getPropertyValue('--accent-strong')).toContain('#10b981');
expect(document.documentElement.style.getPropertyValue('--accent-soft')).toContain('#10b981');
expect(document.documentElement.style.getPropertyValue('--accent-tint')).toContain('#10b981');
expect(document.documentElement.style.getPropertyValue('--accent-hover')).toContain('#10b981');
});
it('replaces existing accent variables when the saved color changes', () => {
applyAppearanceToDocument({ theme: 'light', accentColor: '#4F46E5' });
applyAppearanceToDocument({ theme: 'light', accentColor: '#EF4444' });
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#ef4444');
expect(document.documentElement.style.getPropertyValue('--accent-strong')).toContain('#ef4444');
expect(document.documentElement.style.getPropertyValue('--accent-strong')).not.toContain('#4f46e5');
expect(document.documentElement.style.getPropertyValue('--accent-soft')).toContain('#ef4444');
expect(document.documentElement.style.getPropertyValue('--accent-tint')).toContain('#ef4444');
expect(document.documentElement.style.getPropertyValue('--accent-hover')).toContain('#ef4444');
});
it('clears accent overrides when no valid accent is configured', () => {
document.documentElement.style.setProperty('--accent', '#4f46e5');
applyAppearanceToDocument({ theme: 'system', accentColor: 'not-a-color' });
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('');
});
});

View file

@ -237,6 +237,28 @@ describe('loadConfig', () => {
expect(config.apiProtocol).toBe('anthropic'); expect(config.apiProtocol).toBe('anthropic');
}); });
it('preserves a valid saved accent color', () => {
const savedConfig: Partial<AppConfig> = {
theme: 'dark',
accentColor: '#4F46E5',
};
store.set('open-design:config', JSON.stringify(savedConfig));
const config = loadConfig();
expect(config.theme).toBe('dark');
expect(config.accentColor).toBe('#4f46e5');
});
it('falls back to the default accent color for malformed saved colors', () => {
const savedConfig: Partial<AppConfig> = {
accentColor: 'blue',
};
store.set('open-design:config', JSON.stringify(savedConfig));
expect(loadConfig().accentColor).toBe(DEFAULT_CONFIG.accentColor);
});
it('returns defaults for malformed localStorage JSON', () => { it('returns defaults for malformed localStorage JSON', () => {
store.set('open-design:config', '{broken-json'); store.set('open-design:config', '{broken-json');

View file

@ -0,0 +1,95 @@
using System;
using System.Diagnostics;
using System.IO;
namespace OpenDesignLauncher
{
internal static class Program
{
private static int Main()
{
Console.Title = "Open Design Launcher";
Console.WriteLine("Open Design launcher");
Console.WriteLine();
string repoRoot = FindRepoRoot(AppDomain.CurrentDomain.BaseDirectory);
if (repoRoot == null)
{
Console.Error.WriteLine("Could not find the Open Design repository root.");
Console.Error.WriteLine("Place OpenDesign.exe in the repository root next to package.json.");
Pause();
return 1;
}
Console.WriteLine("Repository: " + repoRoot);
if (!Directory.Exists(Path.Combine(repoRoot, "node_modules", ".pnpm")))
{
Console.WriteLine("Dependencies are missing. Running pnpm install first...");
// Requires corepack (bundled with Node 16.9+) so future maintainers know the dependency.
int installExit = RunCommand(repoRoot, "corepack pnpm install");
if (installExit != 0)
{
Console.Error.WriteLine("pnpm install failed with exit code " + installExit + ".");
Pause();
return installExit;
}
}
Console.WriteLine("Starting Open Design with pnpm tools-dev...");
Console.WriteLine();
int exitCode = RunCommand(repoRoot, "corepack pnpm tools-dev");
if (exitCode != 0)
{
Console.Error.WriteLine();
Console.Error.WriteLine("Open Design exited with code " + exitCode + ".");
Pause();
}
else
{
Console.WriteLine();
Console.WriteLine("Open Design command completed. Press any key to close this window.");
Console.ReadKey(true);
}
return exitCode;
}
private static string FindRepoRoot(string startDirectory)
{
DirectoryInfo current = new DirectoryInfo(startDirectory);
while (current != null)
{
string packageJson = Path.Combine(current.FullName, "package.json");
string workspace = Path.Combine(current.FullName, "pnpm-workspace.yaml");
if (File.Exists(packageJson) && File.Exists(workspace))
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
private static int RunCommand(string workingDirectory, string command)
{
ProcessStartInfo info = new ProcessStartInfo();
info.FileName = "cmd.exe";
info.Arguments = "/d /c \"" + command + "\"";
info.WorkingDirectory = workingDirectory;
info.UseShellExecute = false;
using (Process process = Process.Start(info))
{
process.WaitForExit();
return process.ExitCode;
}
}
private static void Pause()
{
Console.WriteLine("Press any key to close this window.");
Console.ReadKey(true);
}
}
}

34
tools/launcher/README.md Normal file
View file

@ -0,0 +1,34 @@
# Open Design Windows Launcher
`OpenDesignLauncher.cs` builds a small Windows console executable that starts the
local development app without typing the normal commands by hand.
The compiled `OpenDesign.exe` is intentionally not committed to git. Build it
locally when you want the shortcut, or download a trusted build from GitHub
Releases if the project publishes one.
## Build
From the repository root on Windows:
```powershell
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe /target:exe /out:OpenDesign.exe /win32icon:tools\pack\resources\win\icon.ico tools\launcher\OpenDesignLauncher.cs
```
If your Windows installation only has the 32-bit .NET Framework compiler, use:
```powershell
C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe /target:exe /out:OpenDesign.exe /win32icon:tools\pack\resources\win\icon.ico tools\launcher\OpenDesignLauncher.cs
```
## Run
Place the built `OpenDesign.exe` in the repository root next to `package.json`,
then double-click it.
The launcher checks for dependencies and runs:
```powershell
corepack pnpm install
corepack pnpm tools-dev
```