diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index f202dcb..e591f90 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -10,7 +10,7 @@ body:
id: styler-version
attributes:
label: Codex Styler version
- placeholder: 0.2.0-beta.6
+ placeholder: 0.2.0-beta.8
validations:
required: true
- type: input
diff --git a/.github/ISSUE_TEMPLATE/companion-package-bug.yml b/.github/ISSUE_TEMPLATE/companion-package-bug.yml
index f003a2d..8ea42a1 100644
--- a/.github/ISSUE_TEMPLATE/companion-package-bug.yml
+++ b/.github/ISSUE_TEMPLATE/companion-package-bug.yml
@@ -7,7 +7,7 @@ body:
id: styler-version
attributes:
label: Codex Styler version
- placeholder: 0.2.0-beta.6
+ placeholder: 0.2.0-beta.8
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/windows-compatibility.yml b/.github/ISSUE_TEMPLATE/windows-compatibility.yml
index 1cd5b00..2b01471 100644
--- a/.github/ISSUE_TEMPLATE/windows-compatibility.yml
+++ b/.github/ISSUE_TEMPLATE/windows-compatibility.yml
@@ -10,7 +10,7 @@ body:
id: styler-version
attributes:
label: Codex Styler version
- placeholder: 0.2.0-beta.6
+ placeholder: 0.2.0-beta.8
validations:
required: true
- type: input
diff --git a/.github/release-notes/v0.2.0-beta.8.json b/.github/release-notes/v0.2.0-beta.8.json
new file mode 100644
index 0000000..f3b76dd
--- /dev/null
+++ b/.github/release-notes/v0.2.0-beta.8.json
@@ -0,0 +1,39 @@
+{
+ "format": "codex-styler-release-notes-v1",
+ "version": "0.2.0-beta.8",
+ "defaultLocale": "en",
+ "locales": {
+ "en": {
+ "summary": "Five polished Composer Interactions now choose, confirm, apply, and verify a complete real Codex loadout while first-launch reliability and current-menu compatibility are strengthened.",
+ "highlights": [
+ "Triple Drop, Capsule Crane, Loadout Forge, Three-axis Console, and Mission Map each bind their visible play action to model, reasoning effort, and response speed.",
+ "Every result shows a complete three-field diff, labels unchanged values, waits for confirmation, and verifies the native Codex state after writing.",
+ "Original claw, capsule, marble, and beacon artwork adapts to the active theme palette, material, motion, frame-rate, and reduced-motion preference.",
+ "The bilingual README and documentation site now include current English manager, live interaction, and complete-result screenshots."
+ ],
+ "fixes": [
+ "Prevented the official configuration panel from flashing before the selected interaction appears.",
+ "Fixed model outcomes that were previously presented without being written and verified alongside reasoning effort and speed.",
+ "Fixed first managed launch applying to Electron's transient bootstrap renderer and requiring repeated Codex restarts.",
+ "Fixed interaction preferences failing to resynchronize after the desktop manager reconnects.",
+ "Fixed incomplete preview clipping and native switches inheriting geometry-changing theme styles."
+ ]
+ },
+ "zh-CN": {
+ "summary": "五种精修后的交互玩法现在可以选择、确认、写入并验证完整的真实 Codex 配置,同时强化首次启动可靠性与当前菜单兼容性。",
+ "highlights": [
+ "三段落珠、配置胶囊机、三环锻造、三轴控制台和任务航图都会把可见操作直接绑定到模型、推理强度和响应速度。",
+ "每个结果都展示完整的三项差异,标记保持不变的字段,等待用户确认,并在写入后验证 Codex 原生状态。",
+ "原创抓钩、胶囊、滚珠和信标素材会适配当前主题的配色、材质、动态、帧率和减少动态偏好。",
+ "中英文 README 与文档站新增当前英文管理器、真实玩法和完整结果截图。"
+ ],
+ "fixes": [
+ "修复所选玩法出现前短暂闪现官方配置面板的问题。",
+ "修复模型结果只展示却没有与推理强度、速度一同写入和验证的问题。",
+ "修复首次受控启动把效果应用到 Electron 临时启动渲染器、需要重复重启 Codex 才生效的问题。",
+ "修复桌面管理器重新连接后没有同步交互玩法偏好的问题。",
+ "修复预览圆角裁切不完整,以及主题样式改变原生开关几何形态的问题。"
+ ]
+ }
+ }
+}
diff --git a/.github/release-notes/v0.2.0-beta.8.md b/.github/release-notes/v0.2.0-beta.8.md
new file mode 100644
index 0000000..b753e11
--- /dev/null
+++ b/.github/release-notes/v0.2.0-beta.8.md
@@ -0,0 +1,91 @@
+# Codex Styler 0.2.0-beta.8 — Verified Composer Interactions
+
+Beta 8 turns Composer Interactions from decorative experiments into five purposeful ways to choose a real Codex loadout. Every play now produces a model, reasoning effort, and response speed from the options visible in Codex, shows the exact diff, waits for confirmation, writes all three fields, and reads them back before reporting success. The release also closes a first-launch renderer race and carries the latest theme, manager, and compact-layout refinements.
+
+Release tag: `v0.2.0-beta.8`
+
+## Download and install
+
+### macOS 13+ on Apple Silicon
+
+1. Download `Codex-Styler_0.2.0-beta.8_aarch64-unsigned.dmg`.
+2. Compare its SHA-256 digest with `SHA256SUMS.txt`.
+3. Open the DMG and drag Codex Styler to Applications.
+4. On first launch, Control-click Codex Styler and choose **Open**.
+
+The macOS app is ad-hoc signed, does not have an Apple Developer ID signature, and is not notarized. Do not disable Gatekeeper globally.
+
+### Windows 11 x64 — community-validation Beta
+
+1. Download `Codex-Styler_0.2.0-beta.8_x64-unsigned-setup.exe`.
+2. Compare its SHA-256 digest with `SHA256SUMS.txt`.
+3. Review the GitHub build attestation, then run the per-user installer.
+
+The Windows installer is not Authenticode-signed, so SmartScreen may show an unknown-publisher warning. It is built and structurally verified on GitHub-hosted Windows runners. Microsoft Store launch, managed restart, update, restore, and uninstall still require independent real-device evidence before RC.
+
+## Five interactions with real outcomes
+
+- **Triple Drop** lands one configuration marble for model, reasoning effort, and speed; each landing is the real field result.
+- **Capsule Crane** exposes complete loadouts before capture, so skill selects a visible configuration instead of an unrelated hidden roll.
+- **Loadout Forge** spins and locks three independent rings before forging their combined loadout.
+- **Three-axis Console** directly selects the model engine and a reasoning-effort × response-speed cell.
+- **Mission Map** combines a visible model engine with a transparent task route that determines reasoning and speed.
+
+Every outcome contains all three settings. An unchanged value is labelled **Keep current** rather than being silently omitted. Nothing reads, changes, or submits prompt content.
+
+## Native control integration
+
+- The selected interaction replaces Codex's native configuration control in place; no extra composer launcher is added.
+- The current localized Radix menu is opened through its keyboard contract, kept visually concealed while inspected, and fully closed before the interaction appears.
+- English and Simplified Chinese field labels are recognized without ASCII-only word-boundary assumptions.
+- Apply configuration writes model, reasoning effort, and speed, then verifies all three against the live Codex state.
+- If the native structure cannot be interpreted reliably, Styler never guesses a click or silently changes a setting and retains an explicit route to the official control.
+
+## Visual and accessibility quality
+
+- Original claw, capsule, marble, and route-beacon artwork is embedded locally as transparent WebP and adapts to the active theme palette.
+- Interaction surfaces inherit theme material, opacity, blur, radius, motion intensity, and frame-rate budget.
+- Keyboard play, Escape recovery, focus return, explicit confirmation, compact result reflow, and deterministic reduced-motion output are covered by regression tests.
+- The manager now explains the complete model/reasoning/speed contract before an interaction is enabled.
+
+## Reliability and product polish
+
+- Managed launch waits for three stable ready-renderer samples before connecting.
+- Application verifies that the injected runtime survived Electron's startup renderer handoff and retries once in the same session when needed.
+- Interaction preferences resynchronize after reconnecting without rebuilding the active theme or companion.
+- Native Refined uses a clearer porcelain, graphite, and mineral-blue palette.
+- Compact resource details, Home preview proportions, preview clipping, themed native switches, and desktop feature boundaries have been refined.
+
+## Release validation
+
+- The current macOS Codex session completed a real apply → read-back verification → restore pass without restarting Codex.
+- The official configuration menu remained visually absent while the interaction was active.
+- Desktop, theme-core, Rust, browser E2E, production builds, dependency audits, and release-version synchronization are release gates.
+- The release workflow independently builds and structurally verifies the Apple Silicon DMG and Windows x64 NSIS installer before assembling the draft release.
+
+## Release evidence
+
+- `SHA256SUMS.txt` covers both installers and the macOS updater archive.
+- `Codex-Styler_0.2.0-beta.8.spdx.json` exports the dependency graph as SPDX 2.3.
+- GitHub artifact attestations cover both installers; platform updater artifacts carry Tauri signatures.
+- `latest.json` contains macOS Apple Silicon and Windows x64 update entries.
+- `release-notes.json` contains validated English and Simplified Chinese summaries for the pre-download update panel.
+
+Verify installers with GitHub CLI:
+
+```bash
+gh attestation verify Codex-Styler_0.2.0-beta.8_aarch64-unsigned.dmg \
+ --repo xuhuanstudio/codex-styler
+
+gh attestation verify Codex-Styler_0.2.0-beta.8_x64-unsigned-setup.exe \
+ --repo xuhuanstudio/codex-styler
+```
+
+## Beta limitations
+
+- Windows Store and non-Store startup are structurally tested in CI but still require independent real-device reports before RC.
+- Composer Interactions depend on Codex's native configuration semantics and may require an adapter update after a substantial upstream control redesign.
+- Complex-background automatic segmentation, video backgrounds, multi-entity scenes, 3D/WebGL, macOS Intel, Windows ARM64, and Linux remain outside 0.2.
+- macOS notarization and Windows Authenticode signing remain required before v1 Stable.
+
+Codex Styler is unofficial and is not affiliated with, endorsed by, or sponsored by OpenAI.
diff --git a/ASSET_LICENSES.md b/ASSET_LICENSES.md
index c22a2b7..ef7b9ad 100644
--- a/ASSET_LICENSES.md
+++ b/ASSET_LICENSES.md
@@ -3,22 +3,24 @@
Original artwork shipped in the built-in Codex Styler themes is distributed
under Creative Commons Attribution 4.0 International (CC BY 4.0).
-| Asset | Origin | Dimensions | License |
-| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -----------------------------------------------------: | --------- |
-| Codex Styler CS orbit logo, dark and light appearances | Generated specifically for this project, 2026-07-16 | 1254×1254 sources; platform exports generated by Tauri | CC BY 4.0 |
-| Nocturne Studio background | Generated specifically for this project, 2026-07-16 | 1600×1000 | CC BY 4.0 |
-| Quiet Garden background | Generated specifically for this project, 2026-07-16 | 1600×1000 | CC BY 4.0 |
-| Gilded Grandeur background | Generated specifically for this project with the built-in image generation workflow, 2026-07-17 | 1586×992 | CC BY 4.0 |
-| Merry Big Top background | Generated specifically for this project with the built-in image generation workflow, 2026-07-17 | 1586×992 | CC BY 4.0 |
-| Moss chameleon calibrated direction atlas | Generated specifically for this project; non-linear direction calibration, shared cropping, chroma-key removal, and exact-alpha WebP encoding, 2026-07-16 | 181 frames across 4 pages | CC BY 4.0 |
-| Pico parrot calibrated direction atlas | Generated specifically for this project; natural pose frames retained, background and watermark excluded, 2026-07-17 | 189 frames across 4 pages | CC BY 4.0 |
-| Puddle frog calibrated direction atlas | Generated specifically for this project; natural pose frames retained, non-linear direction calibration, background and watermark excluded, 2026-07-17 | 211 frames across 5 pages | CC BY 4.0 |
-| Mochi cat calibrated direction atlas | Generated specifically for this project; adaptive matte, shared ground line, and seamless loop closure, 2026-07-17 | 175 frames across 4 pages | CC BY 4.0 |
-| Token Thief calibrated direction atlas | User-provided generated video; dual-corner watermark exclusion, gray-speck cleanup, shared ground line, and non-linear direction calibration, 2026-07-17 | 101 frames across 3 pages | CC BY 4.0 |
-| Reset God calibrated direction atlas | User-provided generated video; dual-corner watermark exclusion, gray-speck cleanup, shared ground line, and non-linear direction calibration, 2026-07-17 | 100 frames across 3 pages | CC BY 4.0 |
-| README and desktop companion portraits | Representative frames derived from the six calibrated direction atlases; exact cell crop, normalized transparent canvas, and alpha cleanup, 2026-07-17 | 6 images at 480×480 | CC BY 4.0 |
-| Codex Styler manager screenshots | Captured from the English manager UI in matching light and dark appearances, 2026-07-17 | 2 images at 1320×840 | CC BY 4.0 |
-| Companion Creator H.264 test fixture | Synthetic half-second color-frame video generated specifically for local media decoding and E2E regression tests, 2026-07-17 | 160×90, 12 fps, 6 frames | CC BY 4.0 |
+| Asset | Origin | Dimensions | License |
+| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -----------------------------------------------------: | ----------------------------------------- |
+| Codex Styler CS orbit logo, dark and light appearances | Generated specifically for this project, 2026-07-16 | 1254×1254 sources; platform exports generated by Tauri | CC BY 4.0 |
+| Nocturne Studio background | Generated specifically for this project, 2026-07-16 | 1600×1000 | CC BY 4.0 |
+| Quiet Garden background | Generated specifically for this project, 2026-07-16 | 1600×1000 | CC BY 4.0 |
+| Gilded Grandeur background | Generated specifically for this project with the built-in image generation workflow, 2026-07-17 | 1586×992 | CC BY 4.0 |
+| Merry Big Top background | Generated specifically for this project with the built-in image generation workflow, 2026-07-17 | 1586×992 | CC BY 4.0 |
+| Moss chameleon calibrated direction atlas | Generated specifically for this project; non-linear direction calibration, shared cropping, chroma-key removal, and exact-alpha WebP encoding, 2026-07-16 | 181 frames across 4 pages | CC BY 4.0 |
+| Pico parrot calibrated direction atlas | Generated specifically for this project; natural pose frames retained, background and watermark excluded, 2026-07-17 | 189 frames across 4 pages | CC BY 4.0 |
+| Puddle frog calibrated direction atlas | Generated specifically for this project; natural pose frames retained, non-linear direction calibration, background and watermark excluded, 2026-07-17 | 211 frames across 5 pages | CC BY 4.0 |
+| Mochi cat calibrated direction atlas | Generated specifically for this project; adaptive matte, shared ground line, and seamless loop closure, 2026-07-17 | 175 frames across 4 pages | CC BY 4.0 |
+| Token Thief calibrated direction atlas | User-provided generated video; dual-corner watermark exclusion, gray-speck cleanup, shared ground line, and non-linear direction calibration, 2026-07-17 | 101 frames across 3 pages | CC BY 4.0 |
+| Reset God calibrated direction atlas | User-provided generated video; dual-corner watermark exclusion, gray-speck cleanup, shared ground line, and non-linear direction calibration, 2026-07-17 | 100 frames across 3 pages | CC BY 4.0 |
+| README and desktop companion portraits | Representative frames derived from the six calibrated direction atlases; exact cell crop, normalized transparent canvas, and alpha cleanup, 2026-07-17 | 6 images at 480×480 | CC BY 4.0 |
+| Codex Styler manager screenshots | Captured from the English manager UI in matching light and dark appearances, 2026-07-17 | 2 images at 1320×840 | CC BY 4.0 |
+| Composer game object set | Generated specifically for Triple Drop, Capsule Crane, and Mission Map; chroma-key removal and lossless-alpha WebP encoding, 2026-07-20 | 4 transparent images at 160×160 | CC BY 4.0 |
+| Composer Interactions documentation screenshots | Captured from the English manager and a live local Codex configuration session, 2026-07-20 | 1280×720 and 1474×538 WebP files | Documentation evidence; Codex UI excluded |
+| Companion Creator H.264 test fixture | Synthetic half-second color-frame video generated specifically for local media decoding and E2E regression tests, 2026-07-17 | 160×90, 12 fps, 6 frames | CC BY 4.0 |
No source code, image, video, sprite, logo, or other asset from
Fei-Away/Codex-Dream-Skin or xplol/KaMenisACat is redistributed by this
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f50a6fc..441851e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,54 @@ All notable changes will be documented here. The project follows Semantic Versio
## [Unreleased]
+## [0.2.0-beta.8] - 2026-07-20
+
+### Added
+
+- Five Composer Interactions that replace Codex's native configuration control
+ in place: Triple Drop, Capsule Crane, Loadout Forge, Three-axis Console, and
+ Mission Map.
+- Complete real-setting proposals covering model, reasoning effort, and
+ response speed, with a field-by-field confirmation step and post-write
+ verification against the live Codex state.
+- Original transparent claw, capsule, marble, and beacon artwork embedded as
+ local WebP data URLs and adapted to the active theme's palette, material,
+ motion intensity, frame-rate budget, and reduced-motion preference.
+- A bilingual Composer Interactions guide, current English manager and live
+ interaction screenshots, release documentation, and regression coverage for
+ every interaction mode.
+
+### Changed
+
+- Interaction outcomes are now determined by the visible play action rather
+ than an unrelated preset roll. Unchanged fields are explicitly labelled
+ Keep current.
+- Native Refined uses a clearer porcelain, graphite, and restrained mineral-blue
+ palette instead of the previous muddy warm-gray and green cast.
+- Compact resource libraries share a keyboard-complete list-to-detail flow,
+ while Home gives large windows more useful preview space.
+- Desktop orchestration, dialogs, theme creation, and companion-creator helpers
+ are split into smaller feature boundaries without changing their public data
+ formats.
+
+### Fixed
+
+- The current localized Radix configuration menu is opened, read, concealed,
+ written, and closed without briefly flashing the official panel before an
+ interaction appears.
+- Model changes are applied and verified alongside reasoning effort and speed;
+ a play result can no longer report a field that has no effect on Codex.
+- First managed launch waits for a stable, ready Codex renderer and verifies
+ that the injected runtime survived Electron's bootstrap handoff, retrying in
+ the same controlled session instead of requiring repeated restarts.
+- Interaction preferences resynchronize after reconnecting without rebuilding
+ the active theme, background, or companion.
+- Preview containers use complete four-corner clipping, and themed native
+ switches retain Codex-owned geometry, thumb motion, and interaction states.
+- Theme and companion details reflow by usable container width and height,
+ avoiding clipped controls, split radii, and fixed-dock overlap in compact
+ windows.
+
## [0.2.0-beta.7] - 2026-07-20
### Added
diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index 65645fc..4e08888 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -2,7 +2,7 @@
Compatibility is claimed only after real-device validation.
-| Platform | Architecture | Beta 7 status | RC requirement |
+| Platform | Architecture | Beta 8 status | RC requirement |
| ---------- | ------------- | ----------------------------------------------------------------------- | ----------------------------------------- |
| macOS 13+ | Apple Silicon | Ad-hoc signed Beta; locally built, installed, and exercised | Two complete real-device runs |
| Windows 11 | x64 | CI-built unsigned Beta; lifecycle fixes await community device evidence | Two complete independent real-device runs |
diff --git a/README.md b/README.md
index a73843c..7a1ad7d 100644
--- a/README.md
+++ b/README.md
@@ -15,21 +15,21 @@
简体中文 ·
- Download Beta ·
+ Download Beta ·
Website ·
Documentation
-
+
> [!IMPORTANT]
-> Codex Styler 0.2 is a Creator Beta. The macOS DMG is ad-hoc signed but not notarized, and the Windows EXE is not Authenticode-signed. Windows Beta 7 retains the rebuilt Store launch and bounded restart lifecycle and still awaits community real-device verification; v1 Stable remains gated on complete evidence and platform code signing.
+> Codex Styler 0.2 is a Creator Beta. The macOS DMG is ad-hoc signed but not notarized, and the Windows EXE is not Authenticode-signed. Windows Beta 8 retains the rebuilt Store launch and bounded restart lifecycle and still awaits community real-device verification; v1 Stable remains gated on complete evidence and platform code signing.

@@ -43,6 +43,23 @@ Codex Styler treats a Codex theme as a coordinated visual system—not a backgro
Merry Big Top + Token Thief — the same Codex skeleton, rebuilt with a different color, material, motion, and companion direction.
+## Play with real Codex settings
+
+Composer Interactions replace Codex's native configuration control in place with a short, theme-aware experience. Every play returns one complete loadout—model, reasoning effort, and response speed—from the options currently visible in Codex. Nothing reads or changes prompt content, and every field is shown for confirmation before it is written and verified.
+
+
+
+Choose **Triple Drop**, **Capsule Crane**, **Loadout Forge**, **Three-axis Console**, or **Mission Map**. The action itself determines the three real settings: there is no decorative score or disconnected reward.
+
+
+
+ Every capsule exposes its model, reasoning, and speed before capture.
+ A field-by-field diff appears before Codex settings are changed.
+
+
+
+The interaction uses Codex's real native options, labels unchanged values as **Keep current**, applies all three fields only after confirmation, then reads them back to verify the result. If the native control cannot be interpreted reliably, Styler never guesses. See the [Composer Interactions guide](https://xuhuanstudio.github.io/codex-styler/docs/composer-interactions/).
+
## Meet the companions
Companions are draggable, pointer-aware, and independent from themes. A built-in theme can recommend a pairing, but you can combine any companion with any visual system—or use no companion at all.
@@ -67,6 +84,7 @@ OpenAI Codex Desktop already includes useful appearance controls and Pets. Codex
- **Reversible runtime:** launches Codex with a temporary loopback CDP session; never edits app.asar, application resources, or signatures.
- **Image-adaptive creator:** import a local PNG, JPEG, or WebP and Styler derives luminance, dominant color, accent, and contrast before fitting the image to one of four curated visual systems. You can then refine layout, icon treatment, details, surfaces, radius, motion, and companion placement without authoring CSS.
- **Companion Studio:** turn a still image, naturally sorted sequence, short video, or existing atlas into calibrated pointer directions and pose-aware idle motion through a visual timeline and direction dial—without editing atlas coordinates or JSON.
+- **Composer Interactions:** replace the native configuration control with five short, theme-aware ways to choose and verify a complete model, reasoning, and speed loadout.
- **Open scene model:** themes declare layers[], entities[], a renderer, and behaviors instead of reaching into Codex DOM internals.
- **Data-only packages:** local raster assets and JSON; no scripts, arbitrary CSS, SVG, video, remote URLs, or executable fonts.
- **Local-first:** no account, telemetry, cloud sync, or online store; optional update checks only contact GitHub Releases.
@@ -97,14 +115,14 @@ Companions are selected independently from themes. Native Refined, Nocturne Stud
Every shipped image and sprite is original project artwork. The reference repositories are studied for ideas only; their assets and source are not redistributed.
-## Download Creator Beta 7
+## Download Creator Beta 8
-- **[macOS 13+ / Apple Silicon — DMG](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.7/Codex-Styler_0.2.0-beta.7_aarch64-unsigned.dmg)**
-- **[Windows 11 / x64 — installer EXE](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.7/Codex-Styler_0.2.0-beta.7_x64-unsigned-setup.exe)**
+- **[macOS 13+ / Apple Silicon — DMG](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.8/Codex-Styler_0.2.0-beta.8_aarch64-unsigned.dmg)**
+- **[Windows 11 / x64 — installer EXE](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.8/Codex-Styler_0.2.0-beta.8_x64-unsigned-setup.exe)**
On macOS, open the DMG, drag Codex Styler to Applications, then Control-click the app and choose **Open** on first launch. On Windows, SmartScreen may warn because this Beta does not yet have an Authenticode certificate; inspect the published checksum and provenance before continuing. Never disable Gatekeeper or SmartScreen globally.
-The [pre-release page](https://github.com/xuhuanstudio/codex-styler/releases/tag/v0.2.0-beta.7) includes SHA-256 checksums, an SPDX SBOM, build attestations, updater artifacts, tested scope, and known limitations. Intel macOS is not included. Windows Beta 7 is CI-built and structurally verified but remains marked as awaiting community real-device validation.
+The [pre-release page](https://github.com/xuhuanstudio/codex-styler/releases/tag/v0.2.0-beta.8) includes SHA-256 checksums, an SPDX SBOM, build attestations, updater artifacts, tested scope, and known limitations. Intel macOS is not included. Windows Beta 8 is CI-built and structurally verified but remains marked as awaiting community real-device validation.
## Run from source
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 84d169b..9f0a615 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -15,13 +15,13 @@
English ·
- 下载 Beta ·
+ 下载 Beta ·
项目网站 ·
使用文档
> [!IMPORTANT]
-> Codex Styler 0.2 当前处于创作器 Beta 阶段。macOS DMG 采用临时 ad-hoc 签名但尚未公证,Windows EXE 尚未进行 Authenticode 代码签名;Windows Beta 7 延续已重构的 Store 启动与有界重启链路,但仍等待社区实机验证,完成证据闭环和双平台签名前不会标记为稳定版。
+> Codex Styler 0.2 当前处于创作器 Beta 阶段。macOS DMG 采用临时 ad-hoc 签名但尚未公证,Windows EXE 尚未进行 Authenticode 代码签名;Windows Beta 8 延续已重构的 Store 启动与有界重启链路,但仍等待社区实机验证,完成证据闭环和双平台签名前不会标记为稳定版。

@@ -35,6 +35,23 @@ Codex Styler 把 Codex 主题抽象为完整、协调的视觉系统,而不是
滑稽马戏团 + Token Thief ——相同的 Codex 骨架,不同的配色、材质、动态与伙伴方向。
+## 用玩法选择真实 Codex 设置
+
+交互玩法会在原位置替换 Codex 原生配置控件,提供短时、主题自适应的配置体验。每局都会从 Codex 当前真实可选项中产生一套完整配置——模型、推理强度和响应速度;玩法不会读取或修改输入内容,写入前会展示所有字段供用户确认。
+
+
+
+目前可选择**三段落珠**、**配置胶囊机**、**三环锻造**、**三轴控制台**和**任务航图**。操作过程本身决定三项真实设置,不存在与结果脱节的装饰分数或奖励。
+
+
+
+ 抓取前即可看到每枚胶囊对应的模型、推理强度和速度。
+ 修改 Codex 前会逐项展示配置差异。
+
+
+
+玩法只使用 Codex 真实原生选项;未变化的字段会明确显示“保持不变”。用户确认后才写入三项设置,并再次读取验证结果。如果无法可靠识别原生控件,Styler 不会猜测操作。详见[交互玩法文档](https://xuhuanstudio.github.io/codex-styler/zh-cn/docs/composer-interactions/)。
+
## 认识互动伙伴
伙伴可以拖拽、会响应光标,并与主题保持独立。内置主题可以推荐默认搭配,但你仍可把任意伙伴与任意主题组合,也可以完全不使用伙伴。
@@ -59,6 +76,7 @@ OpenAI Codex Desktop 已经提供基础外观设置和 Pets。Codex Styler 专
- **可逆运行时:** 使用临时本机回环 CDP 会话启动 Codex,不修改 app.asar、应用资源或签名。
- **图片自适应创作:** 导入本地 PNG、JPEG 或 WebP 后,Styler 会分析明暗、主色、强调色与对比度,再匹配四套经过设计的视觉体系;随后可继续微调布局、图标处理、细节、表面、圆角、动态和伙伴位置,无需编写 CSS。
- **伙伴工作室:** 把静态图、自然排序的图片序列、短视频或已有图集统一转换为逻辑帧,通过可视化时间轴与方向圆盘校准光标方向和姿态关联的小动作,无需理解图集坐标或编辑 JSON。
+- **交互玩法:** 用五种短时、主题自适应的交互替换原生配置控件,选择并验证完整的模型、推理强度和速度配置。
- **开放场景模型:** 主题声明 layers[]、entities[]、渲染器和行为,不直接依赖 Codex 内部 DOM。
- **纯数据主题包:** 仅允许本地位图和 JSON,不允许脚本、任意 CSS、SVG、视频、远程 URL 或可执行字体。
- **本地优先:** 无账号、无遥测、无云同步、无在线商店;可选的更新检查只访问 GitHub Releases。
@@ -89,14 +107,14 @@ Codex Styler 管理器可以跟随系统,也可以固定使用亮色或暗色
所有随项目分发的图片和序列帧都是原创资源。参考仓库只用于研究思路,不复制或重新分发其代码与素材。
-## 下载创作器 Beta 7
+## 下载创作器 Beta 8
-- **[macOS 13+ / Apple Silicon — DMG](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.7/Codex-Styler_0.2.0-beta.7_aarch64-unsigned.dmg)**
-- **[Windows 11 / x64 — 安装 EXE](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.7/Codex-Styler_0.2.0-beta.7_x64-unsigned-setup.exe)**
+- **[macOS 13+ / Apple Silicon — DMG](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.8/Codex-Styler_0.2.0-beta.8_aarch64-unsigned.dmg)**
+- **[Windows 11 / x64 — 安装 EXE](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.8/Codex-Styler_0.2.0-beta.8_x64-unsigned-setup.exe)**
在 macOS 上打开 DMG,把 Codex Styler 拖入“应用程序”,首次启动时按住 Control 点击应用并选择“打开”。Windows 版因为当前 Beta 尚无 Authenticode 证书,SmartScreen 可能显示提醒;继续前请先核对 Release 中的校验和与构建证明。不要全局关闭 Gatekeeper 或 SmartScreen。
-[预发布页面](https://github.com/xuhuanstudio/codex-styler/releases/tag/v0.2.0-beta.7)同时提供 SHA-256 校验文件、SPDX SBOM、构建证明、更新包、已验证范围和已知限制。本次不提供 Intel Mac 安装包;Windows Beta 7 由 CI 构建并完成结构校验,但仍明确标记为等待社区实机验证。
+[预发布页面](https://github.com/xuhuanstudio/codex-styler/releases/tag/v0.2.0-beta.8)同时提供 SHA-256 校验文件、SPDX SBOM、构建证明、更新包、已验证范围和已知限制。本次不提供 Intel Mac 安装包;Windows Beta 8 由 CI 构建并完成结构校验,但仍明确标记为等待社区实机验证。
## 从源码运行
diff --git a/ROADMAP.md b/ROADMAP.md
index 258c55f..fd07c64 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -106,8 +106,9 @@ depends on an undocumented manual restart or hidden application state.
5. `v0.2.0-beta.5`: theme-aware text contrast across actual UI surfaces, targeted readability recovery, and conservative fallback only after repair or structural health failure.
6. `v0.2.0-beta.6`: ordinary-user setup journey, one application model, compact resource details, and readability/accessibility convergence.
7. `v0.2.0-beta.7`: creator task completion, observable validation, and full error/recovery coverage without new formats.
-8. `v0.2.0-rc.1`: at least two independent complete macOS and Windows device runs; no P0/P1; migration, upgrade, and recovery pass.
-9. `v0.2.0`: only when RC evidence supports it.
+8. `v0.2.0-beta.8`: verified composer interactions, current-Codex compatibility recovery, polished generated interaction assets, and documentation-complete release evidence.
+9. `v0.2.0-rc.1`: at least two independent complete macOS and Windows device runs; no P0/P1; migration, upgrade, and recovery pass.
+10. `v0.2.0`: only when RC evidence supports it.
## v1 Stable
diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-dark-1600x1000.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-dark-1600x1000.png
index dfc1f3f..0a54b9b 100644
Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-dark-1600x1000.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-dark-1600x1000.png differ
diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-light-1320x840.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-light-1320x840.png
index cf0d63e..b0c7785 100644
Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-light-1320x840.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-light-1320x840.png differ
diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-zh-light-1320x840.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-zh-light-1320x840.png
index cf9267c..095dd21 100644
Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-zh-light-1320x840.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-zh-light-1320x840.png differ
diff --git a/apps/desktop/e2e/manager.spec.ts b/apps/desktop/e2e/manager.spec.ts
index c0fd4cc..92a5c45 100644
--- a/apps/desktop/e2e/manager.spec.ts
+++ b/apps/desktop/e2e/manager.spec.ts
@@ -61,16 +61,37 @@ test("English dark home keeps a balanced preview in a tall window", async ({
await expect(currentSetup).toBeVisible();
const dimensions = await currentSetup.evaluate((element) => {
const bounds = element.getBoundingClientRect();
- const preview = element
- .querySelector(".home-current__preview")!
- .getBoundingClientRect();
+ const previewElement = element.querySelector(
+ ".home-current__preview",
+ )!;
+ const preview = previewElement.getBoundingClientRect();
+ const previewStyle = getComputedStyle(previewElement);
+ const workspaceStyle = getComputedStyle(
+ previewElement.querySelector(".workspace-preview")!,
+ );
return {
height: bounds.height,
previewAspectRatio: preview.width / preview.height,
+ previewRadii: [
+ previewStyle.borderTopLeftRadius,
+ previewStyle.borderTopRightRadius,
+ previewStyle.borderBottomRightRadius,
+ previewStyle.borderBottomLeftRadius,
+ ],
+ workspaceRadii: [
+ workspaceStyle.borderTopLeftRadius,
+ workspaceStyle.borderTopRightRadius,
+ workspaceStyle.borderBottomRightRadius,
+ workspaceStyle.borderBottomLeftRadius,
+ ],
};
});
expect(dimensions.height).toBeGreaterThanOrEqual(450);
expect(dimensions.previewAspectRatio).toBeLessThan(2.05);
+ expect(new Set(dimensions.previewRadii).size).toBe(1);
+ expect(dimensions.previewRadii[0]).not.toBe("0px");
+ expect(new Set(dimensions.workspaceRadii).size).toBe(1);
+ expect(dimensions.workspaceRadii[0]).not.toBe("0px");
await expect(quickActions.first()).toHaveCSS("min-height", "100px");
await expect(page).toHaveScreenshot("manager-en-dark-1600x1000.png", {
fullPage: true,
@@ -624,6 +645,36 @@ test("settings controls share a stable professional layout", async ({
await expect(page).toHaveScreenshot("settings-en-dark-960x680.png");
});
+test("composer interactions use one dedicated, keyboard-safe selection", async ({
+ page,
+}) => {
+ await openManager(page, "en", "dark", { width: 960, height: 680 });
+ await page.getByRole("button", { name: "Interactions" }).click();
+
+ const official = page.getByRole("option", {
+ name: /Use the official control/,
+ });
+ await official.focus();
+ await page.keyboard.press("ArrowDown");
+
+ const orbit = page.getByRole("option", { name: /Triple Drop/ });
+ await expect(orbit).toBeFocused();
+ await expect(orbit).toHaveAttribute("aria-selected", "true");
+ await expect(
+ page.getByText("Replaces the native control in place"),
+ ).toBeVisible();
+ expect(
+ await page.evaluate(() =>
+ JSON.parse(localStorage.getItem("codex-styler.settings.v1") ?? "{}"),
+ ),
+ ).toMatchObject({ composerInteractionMode: "marbles" });
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
+ ),
+ ).toBe(true);
+});
+
test("Simplified Chinese light layout remains stable", async ({ page }) => {
await openManager(page, "zh-CN", "light", { width: 1320, height: 840 });
await expect(page).toHaveScreenshot("manager-zh-light-1320x840.png", {
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index baf6c2f..1abbe1b 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@codex-styler/desktop",
- "version": "0.2.0-beta.7",
+ "version": "0.2.0-beta.8",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/desktop/public/themes/native-refined/previews/native-refined.webp b/apps/desktop/public/themes/native-refined/previews/native-refined.webp
index cb47e21..90a1dc2 100644
Binary files a/apps/desktop/public/themes/native-refined/previews/native-refined.webp and b/apps/desktop/public/themes/native-refined/previews/native-refined.webp differ
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 04c16f7..b944ae1 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -392,8 +392,9 @@ dependencies = [
[[package]]
name = "codex-styler"
-version = "0.2.0-beta.7"
+version = "0.2.0-beta.8"
dependencies = [
+ "base64 0.22.1",
"futures-util",
"reqwest",
"semver",
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index e282fed..bc8f813 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "codex-styler"
-version = "0.2.0-beta.7"
+version = "0.2.0-beta.8"
description = "Unofficial local-first theme and interactive scene editor for OpenAI Codex Desktop"
authors = ["Codex Styler contributors"]
license = "Apache-2.0"
@@ -15,6 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
+base64 = "0.22"
futures-util = "0.3.32"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
semver = "1.0"
diff --git a/apps/desktop/src-tauri/assets/interactions/beacon.webp b/apps/desktop/src-tauri/assets/interactions/beacon.webp
new file mode 100644
index 0000000..78fb444
Binary files /dev/null and b/apps/desktop/src-tauri/assets/interactions/beacon.webp differ
diff --git a/apps/desktop/src-tauri/assets/interactions/capsule.webp b/apps/desktop/src-tauri/assets/interactions/capsule.webp
new file mode 100644
index 0000000..57cb7df
Binary files /dev/null and b/apps/desktop/src-tauri/assets/interactions/capsule.webp differ
diff --git a/apps/desktop/src-tauri/assets/interactions/claw.webp b/apps/desktop/src-tauri/assets/interactions/claw.webp
new file mode 100644
index 0000000..59e1116
Binary files /dev/null and b/apps/desktop/src-tauri/assets/interactions/claw.webp differ
diff --git a/apps/desktop/src-tauri/assets/interactions/marble.webp b/apps/desktop/src-tauri/assets/interactions/marble.webp
new file mode 100644
index 0000000..111daf5
Binary files /dev/null and b/apps/desktop/src-tauri/assets/interactions/marble.webp differ
diff --git a/apps/desktop/src-tauri/src/cdp.rs b/apps/desktop/src-tauri/src/cdp.rs
index 9bec1cd..7a50981 100644
--- a/apps/desktop/src-tauri/src/cdp.rs
+++ b/apps/desktop/src-tauri/src/cdp.rs
@@ -118,24 +118,48 @@ pub async fn launch_and_connect(install_path: &str) -> Result Result {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(900))
.build()?;
let endpoint = format!("http://127.0.0.1:{port}/json/list");
- for _ in 0..50 {
+ let mut stable_websocket = None;
+ let mut stable_samples = 0;
+ for _ in 0..attempts {
if let Ok(response) = client.get(&endpoint).send().await
&& let Ok(targets) = response.json::>().await
&& let Some(target) = targets.into_iter().find(is_trusted_codex_target)
&& let Some(websocket_url) = target.web_socket_debugger_url
&& is_loopback_debugger_url(&websocket_url, port)
{
- return Ok(CdpSession {
- port,
- websocket_url,
- child_id,
- });
+ if renderer_is_ready(&websocket_url).await {
+ if stable_websocket.as_deref() == Some(websocket_url.as_str()) {
+ stable_samples += 1;
+ } else {
+ stable_websocket = Some(websocket_url.clone());
+ stable_samples = 1;
+ }
+ // Electron can expose a trusted page before the React shell has
+ // finished replacing its bootstrap renderer. Requiring three
+ // consecutive ready samples prevents applying to that transient
+ // page and losing the runtime during the first launch.
+ if stable_samples >= 3 {
+ return Ok(websocket_url);
+ }
+ } else {
+ stable_websocket = None;
+ stable_samples = 0;
+ }
}
sleep(Duration::from_millis(200)).await;
}
@@ -143,6 +167,25 @@ pub async fn launch_and_connect(install_path: &str) -> Result bool {
+ let expression = r#"(() => {
+ const root = document.querySelector('#root') || document.body?.firstElementChild;
+ const surface = document.querySelector(
+ 'main.main-surface, aside.app-shell-left-panel, .composer-surface-chrome, [data-testid="composer"], [role="main"]'
+ );
+ return document.readyState !== 'loading' && Boolean(root && surface);
+ })()"#;
+ evaluate(websocket_url, expression)
+ .await
+ .ok()
+ .and_then(|response| {
+ response
+ .pointer("/result/result/value")
+ .and_then(Value::as_bool)
+ })
+ == Some(true)
+}
+
pub async fn evaluate(websocket_url: &str, expression: &str) -> Result {
let (mut socket, _) = connect_async(websocket_url).await?;
let request = json!({
diff --git a/apps/desktop/src-tauri/src/composer-moments.js b/apps/desktop/src-tauri/src/composer-moments.js
new file mode 100644
index 0000000..1655038
--- /dev/null
+++ b/apps/desktop/src-tauri/src/composer-moments.js
@@ -0,0 +1,2441 @@
+(() => {
+ const FACTORY_VERSION = 7;
+ if (
+ window.__CODEX_STYLER_CREATE_COMPOSER_MOMENTS__?.version === FACTORY_VERSION
+ ) {
+ return;
+ }
+
+ const ROOT_ID = "codex-styler-composer-moments";
+ const STYLE_ID = "codex-styler-composer-moments-style";
+ const GAME_IDS = new Set(["marbles", "claw", "toss", "balance", "route"]);
+ const GAME_ASSET_URLS = window.__CODEX_STYLER_COMPOSER_ASSETS__ || {};
+ const gameAssetImages = new Map();
+ let runtimeLocale = "en";
+
+ const copy = () => {
+ const chinese = runtimeLocale === "zh-CN";
+ return chinese
+ ? {
+ title: "配置玩法",
+ trigger: "打开配置玩法",
+ close: "关闭",
+ marbles: "三段落珠",
+ marblesDetail: "依次为模型、推理强度与速度落下一颗配置珠",
+ claw: "配置胶囊机",
+ clawDetail: "瞄准并抓取一枚写明三项真实参数的胶囊",
+ toss: "三环锻造",
+ tossDetail: "分别锁定模型、推理强度与速度,再锻造成配置",
+ balance: "三轴控制台",
+ balanceDetail: "直接调节模型、推理强度与响应速度",
+ route: "任务航图",
+ routeDetail: "先选模型引擎,再选择与任务意图匹配的航线",
+ modePhysics: "物理",
+ modeSkill: "操作",
+ modeChance: "随机",
+ modeTune: "调校",
+ modeChoose: "选择",
+ marbleHint: "第 1 / 3 段 · 选择模型落点",
+ clawHint: "移动抓钩;每枚胶囊都对应完整的三项配置",
+ tossHint: "点击转动三环;再次点击依次锁定,回车锻造",
+ balanceHint:
+ "点击上方选择模型;方向键调推理与速度,M 切换模型,回车确认",
+ routeHint: "上下方向键选择模型,左右方向键选择任务航线,回车确认",
+ stepModel: "模型落点",
+ stepReasoning: "推理落点",
+ stepSpeed: "速度落点",
+ drop: "落珠",
+ spin: "转动三环",
+ lock: "锁定",
+ forge: "锻造配置",
+ selectModel: "选择模型引擎",
+ selectedLoadout: "实时配置",
+ reduced: "已按减少动态生成方案",
+ caught: "已抓取配置",
+ rolled: "配置结果",
+ unavailable: "未识别到可安全调整的 Codex 配置",
+ unavailableDetail:
+ "未能安全读取当前配置。玩法不会猜测性修改,你可以明确选择改用官方设置。",
+ preparing: "正在读取当前配置",
+ preparingDetail: "玩法面板已接管入口,正在校准模型、推理强度与速度。",
+ useOfficial: "改用官方设置",
+ proposal: "配置结果",
+ model: "模型",
+ reasoning: "推理强度",
+ speed: "速度",
+ unchanged: "保持不变",
+ apply: "应用这套配置",
+ applying: "正在应用并验证…",
+ applied: "配置已应用并验证",
+ applyFailed: "未能验证配置,已停止继续修改",
+ retry: "再玩一次",
+ cancel: "取消",
+ presetQuick: "快速检查",
+ presetBalanced: "日常处理",
+ presetDeep: "深度诊断",
+ presetPrecision: "审慎复核",
+ presetAdaptive: "代码构建",
+ }
+ : {
+ title: "Configuration plays",
+ trigger: "Open configuration plays",
+ close: "Close",
+ marbles: "Triple Drop",
+ marblesDetail:
+ "Drop one configuration marble for model, reasoning, and speed",
+ claw: "Capsule Crane",
+ clawDetail: "Aim for a capsule labelled with all three real settings",
+ toss: "Loadout Forge",
+ tossDetail:
+ "Lock model, reasoning, and speed rings, then forge the result",
+ balance: "Three-axis Console",
+ balanceDetail:
+ "Directly tune model, reasoning effort, and response speed",
+ route: "Mission Map",
+ routeDetail:
+ "Choose an engine, then a reasoning-and-speed mission route",
+ modePhysics: "Physics",
+ modeSkill: "Skill",
+ modeChance: "Chance",
+ modeTune: "Tune",
+ modeChoose: "Choose",
+ marbleHint: "Stage 1 of 3 · Choose the model landing",
+ clawHint: "Move the crane; every capsule contains a complete loadout",
+ tossHint:
+ "Spin three rings; click again to lock each, then press Enter",
+ balanceHint:
+ "Choose a model above; use arrows to tune, M to cycle models, Enter to confirm",
+ routeHint:
+ "Use up/down for model, left/right for mission route, then press Enter",
+ stepModel: "Model landing",
+ stepReasoning: "Reasoning landing",
+ stepSpeed: "Speed landing",
+ drop: "Drop",
+ spin: "Spin rings",
+ lock: "Lock",
+ forge: "Forge loadout",
+ selectModel: "Choose model engine",
+ selectedLoadout: "Live loadout",
+ reduced: "Configuration prepared without motion",
+ caught: "Configuration caught",
+ rolled: "Configuration result",
+ unavailable: "No safely adjustable Codex configuration was found",
+ unavailableDetail:
+ "The current configuration could not be read safely. Nothing was guessed or changed.",
+ preparing: "Reading current configuration",
+ preparingDetail:
+ "The play panel has taken over while model, reasoning, and speed are calibrated.",
+ useOfficial: "Use official settings",
+ proposal: "Configuration result",
+ model: "Model",
+ reasoning: "Reasoning effort",
+ speed: "Speed",
+ unchanged: "Keep current",
+ apply: "Apply configuration",
+ applying: "Applying and verifying…",
+ applied: "Configuration applied and verified",
+ applyFailed:
+ "Could not verify the configuration; no further changes were made",
+ retry: "Play again",
+ cancel: "Cancel",
+ presetQuick: "Quick check",
+ presetBalanced: "Everyday task",
+ presetDeep: "Deep diagnosis",
+ presetPrecision: "Careful review",
+ presetAdaptive: "Code build",
+ };
+ };
+
+ const clamp = (value, minimum, maximum) =>
+ Math.max(minimum, Math.min(maximum, value));
+
+ const parseHex = (color) => {
+ const match = /^#([0-9a-f]{6})$/i.exec(color || "");
+ if (!match) return { r: 90, g: 132, b: 246 };
+ return {
+ r: parseInt(match[1].slice(0, 2), 16),
+ g: parseInt(match[1].slice(2, 4), 16),
+ b: parseInt(match[1].slice(4, 6), 16),
+ };
+ };
+
+ const mix = (left, right, amount) => {
+ const a = parseHex(left);
+ const b = parseHex(right);
+ const channel = (key) =>
+ Math.round(a[key] * (1 - amount) + b[key] * amount)
+ .toString(16)
+ .padStart(2, "0");
+ return "#" + channel("r") + channel("g") + channel("b");
+ };
+
+ const rgba = (color, alpha) => {
+ const value = parseHex(color);
+ return (
+ "rgba(" + value.r + ", " + value.g + ", " + value.b + ", " + alpha + ")"
+ );
+ };
+
+ const colorHue = (color) => {
+ const { r, g, b } = parseHex(color);
+ const red = r / 255;
+ const green = g / 255;
+ const blue = b / 255;
+ const maximum = Math.max(red, green, blue);
+ const minimum = Math.min(red, green, blue);
+ const delta = maximum - minimum;
+ if (delta === 0) return 210;
+ const hue =
+ maximum === red
+ ? ((green - blue) / delta) % 6
+ : maximum === green
+ ? (blue - red) / delta + 2
+ : (red - green) / delta + 4;
+ return (hue * 60 + 360) % 360;
+ };
+
+ const gameAsset = (name) => {
+ if (!GAME_ASSET_URLS[name] || typeof Image === "undefined") return null;
+ if (!gameAssetImages.has(name)) {
+ const image = new Image();
+ image.decoding = "async";
+ image.src = GAME_ASSET_URLS[name];
+ gameAssetImages.set(name, image);
+ }
+ const image = gameAssetImages.get(name);
+ return image?.complete && image.naturalWidth > 0 ? image : null;
+ };
+
+ const drawGameAsset = (
+ context,
+ name,
+ x,
+ y,
+ width,
+ height,
+ tint,
+ alpha = 1,
+ ) => {
+ const image = gameAsset(name);
+ if (!image) return false;
+ const rotation = Math.round(colorHue(tint) - 210);
+ context.save();
+ context.globalAlpha = alpha;
+ context.filter = `hue-rotate(${rotation}deg) saturate(.82)`;
+ context.shadowColor = rgba(tint, 0.24 * alpha);
+ context.shadowBlur = Math.min(width, height) * 0.22;
+ context.drawImage(image, x, y, width, height);
+ context.restore();
+ return true;
+ };
+
+ const roundedRect = (context, x, y, width, height, radius) => {
+ const r = Math.min(radius, width / 2, height / 2);
+ context.beginPath();
+ context.moveTo(x + r, y);
+ context.arcTo(x + width, y, x + width, y + height, r);
+ context.arcTo(x + width, y + height, x, y + height, r);
+ context.arcTo(x, y + height, x, y, r);
+ context.arcTo(x, y, x + width, y, r);
+ context.closePath();
+ };
+
+ const installStyles = () => {
+ if (document.getElementById(STYLE_ID)) return;
+ const style = document.createElement("style");
+ style.id = STYLE_ID;
+ style.textContent = `
+ #@@{ROOT_ID} {
+ --csm-accent: #5a84f6;
+ --csm-surface: #171a21;
+ --csm-text: #f4f6fb;
+ --csm-muted: #a7adba;
+ --csm-border: #3b4250;
+ --csm-radius: 16px;
+ --csm-surface-opacity: 94%;
+ --csm-blur: 16px;
+ position: fixed;
+ inset: 0;
+ z-index: 10;
+ pointer-events: none;
+ font-family: inherit;
+ color: var(--csm-text);
+ }
+ #@@{ROOT_ID} [hidden] { display: none !important; }
+ #@@{ROOT_ID} button {
+ box-sizing: border-box;
+ font: inherit;
+ }
+ #@@{ROOT_ID} .csm-close:focus-visible,
+ #@@{ROOT_ID} .csm-proposal-actions button:focus-visible {
+ outline: 2px solid color-mix(in srgb, var(--csm-accent) 76%, white);
+ outline-offset: 2px;
+ }
+ #@@{ROOT_ID} .csm-stage {
+ position: fixed;
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--csm-accent) 38%, var(--csm-border));
+ border-radius: calc(var(--csm-radius) + 2px);
+ color: var(--csm-text);
+ background:
+ radial-gradient(circle at 78% 18%, color-mix(in srgb, var(--csm-accent) 14%, transparent), transparent 34%),
+ linear-gradient(150deg, color-mix(in srgb, var(--csm-surface) var(--csm-surface-opacity), transparent), color-mix(in srgb, var(--csm-accent) 6%, var(--csm-surface)));
+ box-shadow:
+ inset 0 1px color-mix(in srgb, white 8%, transparent),
+ 0 18px 48px rgb(0 0 0 / 20%);
+ backdrop-filter: blur(var(--csm-blur)) saturate(1.12);
+ pointer-events: auto;
+ touch-action: none;
+ }
+ #@@{ROOT_ID} .csm-stage canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+ #@@{ROOT_ID} .csm-stage-state {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-content: center;
+ align-items: center;
+ gap: 14px;
+ padding: 22px 48px 20px 22px;
+ background:
+ radial-gradient(circle at 18% 50%, color-mix(in srgb, var(--csm-accent) 18%, transparent), transparent 34%),
+ linear-gradient(135deg, color-mix(in srgb, var(--csm-surface) 96%, transparent), color-mix(in srgb, var(--csm-accent) 7%, var(--csm-surface)));
+ }
+ #@@{ROOT_ID} .csm-stage-state-mark {
+ position: relative;
+ width: 38px;
+ height: 38px;
+ border: 1px solid color-mix(in srgb, var(--csm-accent) 52%, var(--csm-border));
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--csm-accent) 12%, var(--csm-surface));
+ box-shadow: inset 0 1px color-mix(in srgb, white 12%, transparent);
+ }
+ #@@{ROOT_ID} .csm-stage-state-mark::before,
+ #@@{ROOT_ID} .csm-stage-state-mark::after {
+ position: absolute;
+ content: "";
+ }
+ #@@{ROOT_ID} .csm-stage-state-mark::before {
+ inset: 7px;
+ border: 2px solid color-mix(in srgb, var(--csm-accent) 24%, transparent);
+ border-top-color: color-mix(in srgb, var(--csm-accent) 90%, white);
+ border-radius: 50%;
+ animation: csm-calibrate 900ms linear infinite;
+ }
+ #@@{ROOT_ID} .csm-stage-state-mark::after {
+ inset: 16px;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--csm-accent) 78%, white);
+ box-shadow: 0 0 12px color-mix(in srgb, var(--csm-accent) 64%, transparent);
+ }
+ #@@{ROOT_ID} .csm-stage-state-copy { min-width: 0; }
+ #@@{ROOT_ID} .csm-stage-state h3,
+ #@@{ROOT_ID} .csm-stage-state p { margin: 0; }
+ #@@{ROOT_ID} .csm-stage-state h3 {
+ color: var(--csm-text);
+ font-size: 14px;
+ font-weight: 680;
+ letter-spacing: -.01em;
+ }
+ #@@{ROOT_ID} .csm-stage-state p {
+ max-width: 48ch;
+ margin-top: 5px;
+ color: var(--csm-muted);
+ font-size: 11px;
+ line-height: 1.45;
+ }
+ #@@{ROOT_ID} .csm-stage-state-actions {
+ grid-column: 2;
+ display: flex;
+ gap: 7px;
+ margin-top: 3px;
+ }
+ #@@{ROOT_ID} .csm-stage-state-actions button {
+ min-height: 30px;
+ padding: 0 11px;
+ border: 1px solid color-mix(in srgb, var(--csm-border) 76%, transparent);
+ border-radius: max(9px, calc(var(--csm-radius) - 6px));
+ color: var(--csm-text);
+ background: color-mix(in srgb, var(--csm-text) 5%, transparent);
+ cursor: pointer;
+ }
+ #@@{ROOT_ID} .csm-stage--unavailable .csm-stage-state-mark::before {
+ inset: 13px 8px;
+ border: 0;
+ border-top: 2px solid var(--csm-muted);
+ border-bottom: 2px solid var(--csm-muted);
+ border-radius: 0;
+ animation: none;
+ transform: rotate(45deg);
+ }
+ #@@{ROOT_ID} .csm-stage--unavailable .csm-stage-state-mark::after {
+ display: none;
+ }
+ @keyframes csm-calibrate { to { transform: rotate(360deg); } }
+ #@@{ROOT_ID} .csm-stage-label {
+ position: absolute;
+ left: 12px;
+ top: 10px;
+ max-width: calc(100% - 64px);
+ padding: 5px 8px;
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--csm-border) 70%, transparent);
+ border-radius: 999px;
+ color: var(--csm-muted);
+ background: color-mix(in srgb, var(--csm-surface) 78%, transparent);
+ font-size: 10px;
+ line-height: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: none;
+ }
+ #@@{ROOT_ID} .csm-close {
+ position: absolute;
+ right: 8px;
+ top: 7px;
+ display: grid;
+ place-items: center;
+ width: 25px;
+ height: 25px;
+ padding: 0;
+ border: 1px solid color-mix(in srgb, var(--csm-border) 72%, transparent);
+ border-radius: 50%;
+ color: var(--csm-muted);
+ background: color-mix(in srgb, var(--csm-surface) 82%, transparent);
+ cursor: pointer;
+ }
+ #@@{ROOT_ID} .csm-close:hover {
+ color: var(--csm-text);
+ background: color-mix(in srgb, var(--csm-accent) 13%, var(--csm-surface));
+ }
+ #@@{ROOT_ID} .csm-static-result {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-content: center;
+ gap: 7px;
+ text-align: center;
+ }
+ #@@{ROOT_ID} .csm-static-result b {
+ color: color-mix(in srgb, var(--csm-accent) 68%, var(--csm-text));
+ font-size: 32px;
+ line-height: 1;
+ }
+ #@@{ROOT_ID} .csm-static-result span {
+ color: var(--csm-muted);
+ font-size: 11px;
+ }
+ #@@{ROOT_ID} .csm-proposal {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ align-content: center;
+ gap: 10px;
+ padding: 18px 44px 16px 18px;
+ background: color-mix(in srgb, var(--csm-surface) 94%, transparent);
+ }
+ #@@{ROOT_ID} .csm-proposal h3,
+ #@@{ROOT_ID} .csm-proposal p {
+ margin: 0;
+ }
+ #@@{ROOT_ID} .csm-proposal h3 {
+ font-size: 14px;
+ line-height: 1.25;
+ }
+ #@@{ROOT_ID} .csm-proposal-name {
+ color: color-mix(in srgb, var(--csm-accent) 70%, var(--csm-text));
+ }
+ #@@{ROOT_ID} .csm-proposal-status {
+ color: var(--csm-muted);
+ font-size: 11px;
+ line-height: 1.4;
+ }
+ #@@{ROOT_ID} .csm-config-diff {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ margin: 0;
+ }
+ #@@{ROOT_ID} .csm-config-diff div {
+ min-width: 0;
+ padding: 7px 8px;
+ border: 1px solid color-mix(in srgb, var(--csm-border) 72%, transparent);
+ border-radius: max(9px, calc(var(--csm-radius) - 6px));
+ background: color-mix(in srgb, var(--csm-text) 4%, transparent);
+ }
+ #@@{ROOT_ID} .csm-config-diff dt,
+ #@@{ROOT_ID} .csm-config-diff dd {
+ overflow: hidden;
+ margin: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ #@@{ROOT_ID} .csm-config-diff dt {
+ color: var(--csm-muted);
+ font-size: 9px;
+ }
+ #@@{ROOT_ID} .csm-config-diff dd {
+ margin-top: 3px;
+ font-size: 11px;
+ font-weight: 650;
+ }
+ #@@{ROOT_ID} .csm-config-diff s {
+ margin-right: 4px;
+ color: var(--csm-muted);
+ font-weight: 450;
+ text-decoration-thickness: 1px;
+ }
+ #@@{ROOT_ID} .csm-proposal-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ #@@{ROOT_ID} .csm-proposal-actions button {
+ min-height: 30px;
+ padding: 0 11px;
+ border: 1px solid color-mix(in srgb, var(--csm-border) 76%, transparent);
+ border-radius: max(9px, calc(var(--csm-radius) - 6px));
+ color: var(--csm-text);
+ background: color-mix(in srgb, var(--csm-text) 5%, transparent);
+ cursor: pointer;
+ }
+ #@@{ROOT_ID} .csm-proposal-actions button[data-primary='true'] {
+ border-color: color-mix(in srgb, var(--csm-accent) 64%, var(--csm-border));
+ color: color-mix(in srgb, var(--csm-accent) 16%, var(--csm-text));
+ background: color-mix(in srgb, var(--csm-accent) 24%, var(--csm-surface));
+ }
+ #@@{ROOT_ID} .csm-proposal-actions button:disabled {
+ cursor: wait;
+ opacity: .58;
+ }
+ @media (max-width: 520px) {
+ #@@{ROOT_ID} .csm-config-diff { grid-template-columns: 1fr; }
+ #@@{ROOT_ID} .csm-config-diff div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ }
+ #@@{ROOT_ID} .csm-config-diff dd { margin-top: 0; }
+ }
+ @media (prefers-reduced-motion: reduce) {
+ #@@{ROOT_ID} .csm-stage-state-mark::before { animation: none; }
+ }
+ `.replaceAll("@@{ROOT_ID}", ROOT_ID);
+ document.head.appendChild(style);
+ };
+
+ const createFactory = ({ resolveComposer, settingsAdapter }) => {
+ let preferences = {
+ mode: "disabled",
+ reduceMotion: false,
+ palette: {
+ accent: "#5a84f6",
+ surface: "#171a21",
+ text: "#f4f6fb",
+ mutedText: "#a7adba",
+ border: "#3b4250",
+ radius: 16,
+ surfaceOpacity: 0.94,
+ focusBlur: 16,
+ warning: "#e7ad52",
+ success: "#58b88a",
+ },
+ motionIntensity: 0.5,
+ targetFps: 30,
+ };
+ let root = null;
+ let trigger = null;
+ let stage = null;
+ let canvas = null;
+ let stageLabel = null;
+ let currentGame = null;
+ let currentSnapshot = null;
+ let currentPresets = [];
+ let currentGameId = null;
+ let launchRevision = 0;
+ let frameRequest = null;
+ const timers = new Set();
+ let composer = null;
+ let resizeObserver = null;
+
+ const palette = () => preferences.palette;
+ const motionScale = () => 0.72 + preferences.motionIntensity * 0.56;
+
+ const presetName = (id) => {
+ const labels = copy();
+ return {
+ quick: labels.presetQuick,
+ balanced: labels.presetBalanced,
+ deep: labels.presetDeep,
+ precision: labels.presetPrecision,
+ adaptive: labels.presetAdaptive,
+ }[id];
+ };
+
+ const optionAt = (options, progress) =>
+ options[Math.round((options.length - 1) * clamp(progress, 0, 1))] || null;
+
+ const fieldOptions = (snapshot, field) => {
+ const options = snapshot?.[field]?.options || [];
+ if (options.length) return options;
+ return [snapshot?.[field]?.current].filter(Boolean);
+ };
+
+ const currentOptionIndex = (snapshot, field) => {
+ const options = fieldOptions(snapshot, field);
+ const label = snapshot?.[field]?.current?.label;
+ const index = options.findIndex((option) => option.label === label);
+ return Math.max(0, index);
+ };
+
+ const outcomeFromIndices = (indices, name = null, source = "direct") => {
+ const modelOptions = fieldOptions(currentSnapshot, "model");
+ const reasoningOptions = fieldOptions(currentSnapshot, "reasoning");
+ const speedOptions = fieldOptions(currentSnapshot, "speed");
+ const model =
+ modelOptions[clamp(indices.model, 0, modelOptions.length - 1)];
+ const reasoning =
+ reasoningOptions[
+ clamp(indices.reasoning, 0, reasoningOptions.length - 1)
+ ];
+ const speed =
+ speedOptions[clamp(indices.speed, 0, speedOptions.length - 1)];
+ if (!model || !reasoning || !speed) return null;
+ return {
+ id: source,
+ name:
+ name ||
+ [model.label, reasoning.label, speed.label]
+ .filter(Boolean)
+ .join(" · "),
+ source,
+ configuration: { model, reasoning, speed },
+ };
+ };
+
+ const buildPresets = (snapshot) => {
+ const models = fieldOptions(snapshot, "model");
+ const reasoning = fieldOptions(snapshot, "reasoning");
+ const speeds = fieldOptions(snapshot, "speed");
+ if (!models.length || !reasoning.length || !speeds.length) return [];
+ const currentModel = currentOptionIndex(snapshot, "model");
+ const definitions = [
+ { id: "quick", effort: 0, speed: 1 },
+ { id: "balanced", effort: 0.34, speed: 0 },
+ { id: "adaptive", effort: 0.58, speed: 1 },
+ { id: "precision", effort: 0.78, speed: 0 },
+ { id: "deep", effort: 1, speed: 0 },
+ ];
+ const seen = new Set();
+ return definitions
+ .map((definition, index) => {
+ const model = models[(currentModel + index) % models.length];
+ const targetReasoning = optionAt(reasoning, definition.effort);
+ const targetSpeed = optionAt(speeds, definition.speed);
+ const signature = [
+ model?.label,
+ targetReasoning?.label,
+ targetSpeed?.label,
+ ].join("|");
+ if (
+ !model ||
+ !targetReasoning ||
+ !targetSpeed ||
+ seen.has(signature)
+ ) {
+ return null;
+ }
+ seen.add(signature);
+ return {
+ id: definition.id,
+ name: presetName(definition.id),
+ source: "profile",
+ configuration: {
+ model,
+ reasoning: targetReasoning,
+ speed: targetSpeed,
+ },
+ };
+ })
+ .filter(Boolean);
+ };
+
+ const selectPreset = (index) =>
+ currentPresets[
+ ((Math.round(index) % currentPresets.length) + currentPresets.length) %
+ currentPresets.length
+ ] || null;
+
+ const stopFrame = () => {
+ if (frameRequest !== null) cancelAnimationFrame(frameRequest);
+ frameRequest = null;
+ timers.forEach((timer) => clearTimeout(timer));
+ timers.clear();
+ };
+
+ const schedule = (callback, delay) => {
+ const timer = window.setTimeout(() => {
+ timers.delete(timer);
+ callback();
+ }, delay);
+ timers.add(timer);
+ return timer;
+ };
+
+ const createFrameScheduler = () => {
+ let lastFrameAt = 0;
+ const interval = 1000 / clamp(preferences.targetFps, 24, 60);
+ const queue = (callback) => {
+ frameRequest = requestAnimationFrame((time) => {
+ if (lastFrameAt && time - lastFrameAt < interval - 0.5) {
+ queue(callback);
+ return;
+ }
+ lastFrameAt = time;
+ callback(time);
+ });
+ };
+ return queue;
+ };
+
+ const closeStage = (restoreFocus = true, closeNativeMenu = true) => {
+ launchRevision += 1;
+ stopFrame();
+ currentGame?.destroy?.();
+ currentGame = null;
+ stage?.remove();
+ stage = null;
+ canvas = null;
+ stageLabel = null;
+ currentSnapshot = null;
+ currentPresets = [];
+ currentGameId = null;
+ if (closeNativeMenu) void settingsAdapter?.close?.();
+ if (restoreFocus) trigger?.focus({ preventScroll: true });
+ };
+
+ const position = () => {
+ if (!root) return;
+ const nextTrigger = settingsAdapter?.resolveTrigger?.();
+ if (nextTrigger instanceof HTMLElement) trigger = nextTrigger;
+ const nextComposer = resolveComposer();
+ if (nextComposer !== composer) {
+ resizeObserver?.disconnect();
+ composer = nextComposer;
+ if (composer) resizeObserver?.observe(composer);
+ }
+ if (!composer?.isConnected) {
+ closeStage(false);
+ return;
+ }
+ const bounds = composer.getBoundingClientRect();
+
+ if (stage) {
+ const resultHeight = stage.classList.contains("csm-stage--result")
+ ? window.innerWidth <= 520
+ ? 286
+ : 222
+ : 0;
+ const stateHeight =
+ stage.classList.contains("csm-stage--loading") ||
+ stage.classList.contains("csm-stage--unavailable")
+ ? 148
+ : 0;
+ const minimumHeight = Math.max(
+ resultHeight,
+ stateHeight,
+ Math.min(292, Math.max(236, window.innerHeight * 0.31)),
+ );
+ const height = Math.max(bounds.height, minimumHeight);
+ const top = clamp(
+ bounds.bottom - height,
+ 8,
+ Math.max(8, window.innerHeight - height - 8),
+ );
+ const left = clamp(bounds.left, 8, window.innerWidth - 8);
+ const width = Math.max(
+ 120,
+ Math.min(bounds.width, window.innerWidth - left - 8),
+ );
+ stage.style.left = left + "px";
+ stage.style.top = top + "px";
+ stage.style.width = width + "px";
+ stage.style.height = height + "px";
+ currentGame?.resize?.();
+ }
+ };
+
+ const updateRootPalette = () => {
+ if (!root) return;
+ const colors = palette();
+ root.style.setProperty("--csm-accent", colors.accent);
+ root.style.setProperty("--csm-surface", colors.surface);
+ root.style.setProperty("--csm-text", colors.text);
+ root.style.setProperty("--csm-muted", colors.mutedText);
+ root.style.setProperty("--csm-border", colors.border);
+ root.style.setProperty(
+ "--csm-radius",
+ clamp(colors.radius || 16, 10, 24) + "px",
+ );
+ root.style.setProperty(
+ "--csm-surface-opacity",
+ Math.round(clamp(colors.surfaceOpacity || 0.94, 0.76, 1) * 100) + "%",
+ );
+ root.style.setProperty(
+ "--csm-blur",
+ clamp(colors.focusBlur || 0, 0, 24) + "px",
+ );
+ };
+
+ const ensureRoot = () => {
+ if (root?.isConnected) return;
+ installStyles();
+ root = document.createElement("div");
+ root.id = ROOT_ID;
+ root.dataset.version = String(FACTORY_VERSION);
+ document.body.appendChild(root);
+ updateRootPalette();
+ window.addEventListener("resize", position, { passive: true });
+ document.addEventListener("scroll", position, {
+ capture: true,
+ passive: true,
+ });
+ document.addEventListener("keydown", onKeyDown, true);
+ document.addEventListener(
+ "pointerdown",
+ onNativeTriggerPointerDown,
+ true,
+ );
+ document.addEventListener("click", onNativeTriggerClick, true);
+ document.addEventListener("visibilitychange", onVisibilityChange);
+ resizeObserver =
+ typeof ResizeObserver === "undefined"
+ ? null
+ : new ResizeObserver(position);
+ position();
+ };
+
+ const setStageLabel = (text) => {
+ if (stageLabel) stageLabel.textContent = text;
+ };
+
+ const gameName = (gameId) => {
+ const labels = copy();
+ return {
+ marbles: labels.marbles,
+ claw: labels.claw,
+ toss: labels.toss,
+ balance: labels.balance,
+ route: labels.route,
+ }[gameId];
+ };
+
+ const gameHint = (gameId) => {
+ const labels = copy();
+ return {
+ marbles: labels.marbleHint,
+ claw: labels.clawHint,
+ toss: labels.tossHint,
+ balance: labels.balanceHint,
+ route: labels.routeHint,
+ }[gameId];
+ };
+
+ const mountStage = (gameId) => {
+ if (!root) return null;
+ const labels = copy();
+ stage = document.createElement("section");
+ stage.className = "csm-stage csm-stage--loading";
+ stage.setAttribute("role", "application");
+ stage.tabIndex = 0;
+ stage.setAttribute("aria-label", gameName(gameId));
+ const state = document.createElement("div");
+ state.className = "csm-stage-state";
+ state.setAttribute("role", "status");
+ const mark = document.createElement("span");
+ mark.className = "csm-stage-state-mark";
+ mark.setAttribute("aria-hidden", "true");
+ const stateCopy = document.createElement("div");
+ stateCopy.className = "csm-stage-state-copy";
+ const title = document.createElement("h3");
+ title.textContent = labels.preparing;
+ const detail = document.createElement("p");
+ detail.textContent = labels.preparingDetail;
+ stateCopy.append(title, detail);
+ state.append(mark, stateCopy);
+ const close = document.createElement("button");
+ close.type = "button";
+ close.className = "csm-close";
+ close.setAttribute("aria-label", labels.close);
+ close.textContent = "×";
+ close.addEventListener("click", () => closeStage());
+ stage.append(state, close);
+ root.appendChild(stage);
+ position();
+ stage.focus({ preventScroll: true });
+ return stage;
+ };
+
+ const showUnavailable = () => {
+ if (!stage) return;
+ const labels = copy();
+ stage.classList.remove("csm-stage--loading");
+ stage.classList.add("csm-stage--unavailable");
+ const state = stage.querySelector(".csm-stage-state");
+ const title = state?.querySelector("h3");
+ const detail = state?.querySelector("p");
+ if (title) title.textContent = labels.unavailable;
+ if (detail) detail.textContent = labels.unavailableDetail;
+ const actions = document.createElement("div");
+ actions.className = "csm-stage-state-actions";
+ const official = document.createElement("button");
+ official.type = "button";
+ official.textContent = labels.useOfficial;
+ official.addEventListener("click", async () => {
+ closeStage(false, false);
+ await settingsAdapter?.openOfficialMenu?.();
+ });
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.textContent = labels.cancel;
+ cancel.addEventListener("click", () => closeStage());
+ actions.append(official, cancel);
+ state?.append(actions);
+ position();
+ official.focus({ preventScroll: true });
+ };
+
+ const prepareCanvas = () => {
+ if (!stage || !canvas) return null;
+ const bounds = stage.getBoundingClientRect();
+ const dpr = clamp(window.devicePixelRatio || 1, 1, 2.5);
+ const width = Math.max(1, Math.round(bounds.width));
+ const height = Math.max(1, Math.round(bounds.height));
+ canvas.width = Math.round(width * dpr);
+ canvas.height = Math.round(height * dpr);
+ canvas.style.width = width + "px";
+ canvas.style.height = height + "px";
+ const context = canvas.getContext("2d");
+ if (!context) return null;
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
+ return { context, width, height, dpr };
+ };
+
+ const drawPlayfield = (context, width, height, emphasis = 0.5) => {
+ const colors = palette();
+ const background = context.createLinearGradient(0, 0, width, height);
+ background.addColorStop(0, mix(colors.surface, colors.accent, 0.035));
+ background.addColorStop(
+ 0.58,
+ mix(colors.surface, colors.accent, 0.075 + emphasis * 0.035),
+ );
+ background.addColorStop(1, mix(colors.surface, "#000000", 0.14));
+ context.beginPath();
+ context.rect?.(0, 0, width, height);
+ if (!context.rect) roundedRect(context, 0, 0, width, height, 0);
+ context.fillStyle = background;
+ context.fill();
+
+ const spacing = Math.max(28, Math.min(48, width / 12));
+ context.save();
+ context.strokeStyle = rgba(colors.text, 0.025 + emphasis * 0.016);
+ context.lineWidth = 1;
+ for (let x = spacing; x < width; x += spacing) {
+ context.beginPath();
+ context.moveTo(x, 0);
+ context.lineTo(x, height);
+ context.stroke();
+ }
+ for (let y = spacing; y < height; y += spacing) {
+ context.beginPath();
+ context.moveTo(0, y);
+ context.lineTo(width, y);
+ context.stroke();
+ }
+ context.restore();
+
+ const secondaryGlow = context.createRadialGradient(
+ width * 0.14,
+ height * 0.9,
+ 4,
+ width * 0.14,
+ height * 0.9,
+ Math.max(width, height) * 0.42,
+ );
+ secondaryGlow.addColorStop(0, rgba(colors.success, 0.08));
+ secondaryGlow.addColorStop(1, rgba(colors.success, 0));
+ context.fillStyle = secondaryGlow;
+ context.beginPath();
+ context.rect?.(0, 0, width, height);
+ if (!context.rect) roundedRect(context, 0, 0, width, height, 0);
+ context.fill();
+
+ const glow = context.createRadialGradient(
+ width * 0.78,
+ height * 0.18,
+ 2,
+ width * 0.78,
+ height * 0.18,
+ Math.max(width, height) * 0.52,
+ );
+ glow.addColorStop(0, rgba(colors.accent, 0.14 + emphasis * 0.05));
+ glow.addColorStop(1, rgba(colors.accent, 0));
+ context.beginPath();
+ context.arc(
+ width * 0.78,
+ height * 0.18,
+ Math.max(width, height) * 0.52,
+ 0,
+ Math.PI * 2,
+ );
+ context.fillStyle = glow;
+ context.fill();
+
+ roundedRect(context, 6.5, 6.5, width - 13, height - 13, 14);
+ context.strokeStyle = rgba(colors.text, 0.075);
+ context.lineWidth = 1;
+ context.stroke();
+
+ context.save();
+ context.globalAlpha = 0.18;
+ context.fillStyle = colors.accent;
+ for (let x = 18; x < width - 16; x += 36) {
+ context.beginPath();
+ context.arc(x, height - 9, 0.7, 0, Math.PI * 2);
+ context.fill();
+ }
+ context.restore();
+ };
+
+ const defaultIndices = () => ({
+ model: currentOptionIndex(currentSnapshot, "model"),
+ reasoning: currentOptionIndex(currentSnapshot, "reasoning"),
+ speed: currentOptionIndex(currentSnapshot, "speed"),
+ });
+
+ const indicesForConfiguration = (configuration = {}) => {
+ const indices = {};
+ ["model", "reasoning", "speed"].forEach((field) => {
+ const options = fieldOptions(currentSnapshot, field);
+ const target = configuration[field]?.label;
+ indices[field] = Math.max(
+ 0,
+ options.findIndex((option) => option.label === target),
+ );
+ });
+ return indices;
+ };
+
+ const drawLoadoutStrip = (
+ context,
+ width,
+ indices,
+ activeField = null,
+ top = 43,
+ ) => {
+ const colors = palette();
+ const labels = copy();
+ const fields = ["model", "reasoning", "speed"];
+ const names = {
+ model: labels.model,
+ reasoning: labels.reasoning,
+ speed: labels.speed,
+ };
+ const gap = 7;
+ const left = 14;
+ const available = Math.max(120, width - left * 2);
+ const cellWidth = (available - gap * 2) / 3;
+ fields.forEach((field, index) => {
+ const options = fieldOptions(currentSnapshot, field);
+ const selected =
+ options[clamp(indices[field], 0, Math.max(0, options.length - 1))];
+ const x = left + index * (cellWidth + gap);
+ const active = field === activeField;
+ roundedRect(context, x, top, cellWidth, 38, 10);
+ context.fillStyle = active
+ ? mix(colors.accent, colors.surface, 0.72)
+ : rgba(colors.text, 0.045);
+ context.fill();
+ context.strokeStyle = active
+ ? rgba(colors.accent, 0.84)
+ : rgba(colors.border, 0.56);
+ context.lineWidth = active ? 1.4 : 1;
+ context.stroke();
+ context.fillStyle = rgba(colors.mutedText, 0.9);
+ context.font = "600 8px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "left";
+ context.textBaseline = "alphabetic";
+ context.fillText(names[field], x + 9, top + 13, cellWidth - 18);
+ context.fillStyle = rgba(colors.text, active ? 0.98 : 0.88);
+ context.font = active
+ ? "700 10px ui-sans-serif, system-ui, sans-serif"
+ : "620 10px ui-sans-serif, system-ui, sans-serif";
+ context.fillText(
+ selected?.label || "—",
+ x + 9,
+ top + 29,
+ cellWidth - 18,
+ );
+ });
+ };
+
+ const nearestOptionIndex = (options, progress) =>
+ clamp(
+ Math.round(clamp(progress, 0, 1) * Math.max(0, options.length - 1)),
+ 0,
+ Math.max(0, options.length - 1),
+ );
+
+ const fieldDiff = (term, current, target) => {
+ const wrapper = document.createElement("div");
+ const name = document.createElement("dt");
+ name.textContent = term;
+ const value = document.createElement("dd");
+ if (!target?.label || target.label === current?.label) {
+ value.textContent = current?.label
+ ? `${copy().unchanged} · ${current.label}`
+ : copy().unchanged;
+ } else {
+ const before = document.createElement("s");
+ before.textContent = current?.label || "—";
+ value.append(before, document.createTextNode(target.label));
+ }
+ wrapper.append(name, value);
+ return wrapper;
+ };
+
+ const showProposal = (preset) => {
+ if (!stage || !preset) return;
+ stage.classList.add("csm-stage--result");
+ position();
+ stopFrame();
+ currentGame?.destroy?.();
+ currentGame = null;
+ if (canvas) canvas.hidden = true;
+ stage.querySelector(".csm-proposal, .csm-static-result")?.remove();
+ const labels = copy();
+ setStageLabel(labels.proposal);
+ const result = document.createElement("div");
+ result.className = "csm-proposal";
+ result.setAttribute("role", "group");
+ result.setAttribute("aria-label", labels.proposal);
+ const title = document.createElement("h3");
+ const lead = document.createTextNode(labels.proposal + " · ");
+ const presetLabel = document.createElement("span");
+ presetLabel.className = "csm-proposal-name";
+ presetLabel.textContent = preset.name;
+ title.append(lead, presetLabel);
+ const diff = document.createElement("dl");
+ diff.className = "csm-config-diff";
+ diff.append(
+ fieldDiff(
+ labels.model,
+ currentSnapshot?.model?.current,
+ preset.configuration.model,
+ ),
+ fieldDiff(
+ labels.reasoning,
+ currentSnapshot?.reasoning?.current,
+ preset.configuration.reasoning,
+ ),
+ fieldDiff(
+ labels.speed,
+ currentSnapshot?.speed?.current,
+ preset.configuration.speed,
+ ),
+ );
+ const status = document.createElement("p");
+ status.className = "csm-proposal-status";
+ status.setAttribute("role", "status");
+ const actions = document.createElement("div");
+ actions.className = "csm-proposal-actions";
+ const apply = document.createElement("button");
+ apply.type = "button";
+ apply.dataset.primary = "true";
+ apply.textContent = labels.apply;
+ const retry = document.createElement("button");
+ retry.type = "button";
+ retry.textContent = labels.retry;
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.textContent = labels.cancel;
+ apply.addEventListener("click", async () => {
+ const buttons = [apply, retry, cancel];
+ buttons.forEach((button) => {
+ button.disabled = true;
+ });
+ status.textContent = labels.applying;
+ const outcome = await settingsAdapter?.apply?.(preset.configuration);
+ status.textContent = outcome?.ok ? labels.applied : labels.applyFailed;
+ if (outcome?.ok) {
+ apply.textContent = labels.applied;
+ schedule(() => closeStage(), 1050);
+ } else {
+ buttons.forEach((button) => {
+ button.disabled = false;
+ });
+ apply.focus({ preventScroll: true });
+ }
+ });
+ retry.addEventListener("click", () => {
+ const gameId = currentGameId;
+ closeStage(false);
+ start(gameId);
+ });
+ cancel.addEventListener("click", () => closeStage());
+ actions.append(apply, retry, cancel);
+ result.append(title, diff, status, actions);
+ stage.appendChild(result);
+ apply.focus({ preventScroll: true });
+ };
+
+ const staticResult = () => {
+ if (!stage) return;
+ canvas.hidden = true;
+ setStageLabel(copy().reduced);
+ const indices = defaultIndices();
+ ["model", "reasoning", "speed"].forEach((field) => {
+ const options = fieldOptions(currentSnapshot, field);
+ if (options.length > 1)
+ indices[field] = (indices[field] + 1) % options.length;
+ });
+ showProposal(
+ outcomeFromIndices(indices, copy().selectedLoadout, "reduced-motion"),
+ );
+ };
+
+ const drawBall = (context, ball) => {
+ const colors = palette();
+ if (
+ drawGameAsset(
+ context,
+ "marble",
+ ball.x - ball.radius * 1.18,
+ ball.y - ball.radius * 1.18,
+ ball.radius * 2.36,
+ ball.radius * 2.36,
+ ball.color,
+ )
+ ) {
+ return;
+ }
+ const gradient = context.createRadialGradient(
+ ball.x - ball.radius * 0.32,
+ ball.y - ball.radius * 0.38,
+ 1,
+ ball.x,
+ ball.y,
+ ball.radius,
+ );
+ gradient.addColorStop(0, mix(ball.color, "#ffffff", 0.62));
+ gradient.addColorStop(0.34, ball.color);
+ gradient.addColorStop(1, mix(ball.color, "#000000", 0.34));
+ context.beginPath();
+ context.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
+ context.fillStyle = gradient;
+ context.shadowColor = rgba(colors.accent, 0.28);
+ context.shadowBlur = 10;
+ context.fill();
+ context.shadowBlur = 0;
+ context.strokeStyle = rgba(colors.text, 0.25);
+ context.lineWidth = 1;
+ context.stroke();
+ };
+
+ const createMarbles = () => {
+ let viewport = prepareCanvas();
+ const colors = palette();
+ const scale = motionScale();
+ const nextFrame = createFrameScheduler();
+ const fields = ["model", "reasoning", "speed"];
+ const selected = defaultIndices();
+ let activeStep = 0;
+ let ball = null;
+ let aimProgress =
+ (selected.model + 0.5) /
+ Math.max(1, fieldOptions(currentSnapshot, "model").length);
+ let lastTime = performance.now();
+ let settled = false;
+
+ const activeField = () => fields[activeStep];
+ const activeOptions = () => fieldOptions(currentSnapshot, activeField());
+ const stepLabel = () => {
+ const labels = copy();
+ return [labels.stepModel, labels.stepReasoning, labels.stepSpeed][
+ activeStep
+ ];
+ };
+ const updateHint = () => {
+ setStageLabel(`${activeStep + 1} / 3 · ${stepLabel()}`);
+ };
+ updateHint();
+
+ const drop = (progress) => {
+ if (!viewport || ball) return;
+ const options = activeOptions();
+ aimProgress = clamp(progress, 0.035, 0.965);
+ ball = {
+ x: viewport.width * aimProgress,
+ y: 101,
+ radius: 11,
+ vx: (aimProgress - 0.5) * 18 * scale,
+ vy: 26 * scale,
+ color:
+ activeStep === 0
+ ? colors.accent
+ : activeStep === 1
+ ? colors.warning
+ : colors.success,
+ };
+ settled = false;
+ };
+ const completeStep = () => {
+ if (settled) return;
+ settled = true;
+ const options = activeOptions();
+ selected[activeField()] = nearestOptionIndex(
+ options,
+ ball.x / Math.max(1, viewport.width),
+ );
+ if (activeStep < 2) {
+ schedule(() => {
+ activeStep += 1;
+ ball = null;
+ aimProgress =
+ (selected[activeField()] + 0.5) /
+ Math.max(1, activeOptions().length);
+ settled = false;
+ updateHint();
+ }, 220);
+ return;
+ }
+ const outcome = outcomeFromIndices(
+ selected,
+ copy().selectedLoadout,
+ "triple-drop",
+ );
+ schedule(() => showProposal(outcome), 300);
+ };
+ const resize = () => {
+ viewport = prepareCanvas();
+ };
+ const pointerMove = (event) => {
+ if (!viewport || ball) return;
+ const bounds = canvas.getBoundingClientRect();
+ const options = activeOptions();
+ aimProgress = clamp(
+ (event.clientX - bounds.left) / Math.max(1, bounds.width),
+ 0.035,
+ 0.965,
+ );
+ selected[activeField()] = nearestOptionIndex(options, aimProgress);
+ };
+ const pointer = (event) => {
+ const bounds = canvas.getBoundingClientRect();
+ drop((event.clientX - bounds.left) / Math.max(1, bounds.width));
+ };
+ const keyboard = (key) => {
+ const field = activeField();
+ const options = activeOptions();
+ if (key === "ArrowLeft" || key === "ArrowRight") {
+ selected[field] = clamp(
+ selected[field] + (key === "ArrowLeft" ? -1 : 1),
+ 0,
+ options.length - 1,
+ );
+ aimProgress = (selected[field] + 0.5) / Math.max(1, options.length);
+ return true;
+ }
+ if (key === "Enter" || key === " ") {
+ drop(selected[field] / Math.max(1, options.length - 1));
+ return true;
+ }
+ return false;
+ };
+ const frame = (time) => {
+ if (!viewport || !stage) return;
+ const delta = Math.min(0.032, (time - lastTime) / 1000);
+ lastTime = time;
+ const { context, width, height } = viewport;
+ context.clearRect(0, 0, width, height);
+ drawPlayfield(context, width, height, 0.72);
+ drawLoadoutStrip(context, width, selected, activeField());
+ const options = activeOptions();
+ const top = 94;
+ const floor = height - 30;
+ const laneWidth = width / Math.max(1, options.length);
+ const boardTop = top + 34;
+ const boardHeight = Math.max(36, floor - boardTop - 12);
+ const pegRows = Math.max(2, Math.min(4, Math.floor(boardHeight / 31)));
+ const pegs = [];
+ for (let row = 0; row < pegRows; row += 1) {
+ const offset = row % 2 === 0 ? 0.5 : 1;
+ const columns = row % 2 === 0 ? options.length : options.length - 1;
+ for (let column = 0; column < columns; column += 1) {
+ pegs.push({
+ x: laneWidth * (column + offset),
+ y: boardTop + ((row + 1) / (pegRows + 1)) * boardHeight,
+ row,
+ column,
+ });
+ }
+ }
+ options.forEach((option, index) => {
+ const x = index * laneWidth;
+ const active = selected[activeField()] === index;
+ roundedRect(
+ context,
+ x + 5,
+ boardTop - 6,
+ Math.max(8, laneWidth - 10),
+ Math.max(28, floor - boardTop + 6),
+ 11,
+ );
+ context.fillStyle = active
+ ? rgba(colors.accent, 0.14)
+ : rgba(colors.text, 0.025);
+ context.fill();
+ context.strokeStyle = active
+ ? rgba(colors.accent, 0.68)
+ : rgba(colors.border, 0.38);
+ context.stroke();
+ context.fillStyle = rgba(colors.text, active ? 0.94 : 0.62);
+ context.font = active
+ ? "700 9px ui-sans-serif, system-ui, sans-serif"
+ : "560 8px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "center";
+ context.textBaseline = "alphabetic";
+ context.fillText(
+ option.label,
+ x + laneWidth / 2,
+ height - 11,
+ Math.max(28, laneWidth - 16),
+ );
+ });
+ pegs.forEach((peg) => {
+ context.beginPath();
+ context.arc(peg.x, peg.y, 3.2, 0, Math.PI * 2);
+ context.fillStyle = rgba(colors.text, 0.36);
+ context.shadowColor = rgba(colors.accent, 0.3);
+ context.shadowBlur = 5;
+ context.fill();
+ context.shadowBlur = 0;
+ context.strokeStyle = rgba(colors.border, 0.72);
+ context.stroke();
+ });
+ if (ball) {
+ ball.vy += 620 * scale * delta;
+ ball.x += ball.vx * delta;
+ ball.y += ball.vy * delta;
+ pegs.forEach((peg) => {
+ const dx = ball.x - peg.x;
+ const dy = ball.y - peg.y;
+ const distance = Math.hypot(dx, dy);
+ const minimum = ball.radius + 3.2;
+ if (distance >= minimum || distance === 0 || ball.vy <= 0) return;
+ const nx = dx / distance;
+ const ny = dy / distance;
+ ball.x = peg.x + nx * minimum;
+ ball.y = peg.y + ny * minimum;
+ const direction = (peg.row + peg.column + activeStep) % 2 ? 1 : -1;
+ ball.vx = ball.vx * 0.72 + (nx * 92 + direction * 18) * scale;
+ ball.vy = Math.max(42, Math.abs(ball.vy) * 0.54);
+ });
+ if (ball.x <= ball.radius || ball.x >= width - ball.radius) {
+ ball.x = clamp(ball.x, ball.radius, width - ball.radius);
+ ball.vx *= -0.58;
+ }
+ if (ball.y >= floor - ball.radius) {
+ ball.y = floor - ball.radius;
+ ball.vy *= -0.34;
+ if (Math.abs(ball.vy) < 30) {
+ ball.vy = 0;
+ completeStep();
+ }
+ }
+ drawBall(context, ball);
+ } else {
+ const preview = {
+ x: width * aimProgress,
+ y: 105,
+ radius: 10,
+ color:
+ activeStep === 0
+ ? colors.accent
+ : activeStep === 1
+ ? colors.warning
+ : colors.success,
+ };
+ drawBall(context, preview);
+ context.beginPath();
+ context.moveTo(preview.x, preview.y + preview.radius + 4);
+ context.lineTo(preview.x, boardTop - 7);
+ context.strokeStyle = rgba(preview.color, 0.48);
+ context.setLineDash([3, 4]);
+ context.stroke();
+ context.setLineDash([]);
+ }
+ nextFrame(frame);
+ };
+ nextFrame(frame);
+ return { resize, pointer, pointerMove, keyboard, destroy: () => {} };
+ };
+
+ const createClaw = () => {
+ let viewport = prepareCanvas();
+ const colors = palette();
+ const scale = motionScale();
+ const nextFrame = createFrameScheduler();
+ const prizes = currentPresets.map((preset, index) => ({
+ preset,
+ x: (index + 1) / (currentPresets.length + 1),
+ y: 0.67 + (index % 2) * 0.055,
+ color:
+ index % 3 === 0
+ ? colors.warning
+ : index % 3 === 1
+ ? colors.accent
+ : colors.success,
+ }));
+ let clawX = 0.5;
+ let targetX = 0.5;
+ let drop = 0;
+ let phase = "aim";
+ let captured = null;
+
+ const resize = () => {
+ viewport = prepareCanvas();
+ };
+ const pointerMove = (event) => {
+ if (!viewport || phase !== "aim") return;
+ const bounds = canvas.getBoundingClientRect();
+ targetX = clamp(
+ (event.clientX - bounds.left) / bounds.width,
+ 0.09,
+ 0.91,
+ );
+ };
+ const pointer = () => {
+ if (phase === "aim") phase = "down";
+ };
+ const keyboard = (key) => {
+ if (phase !== "aim") return false;
+ if (key === "ArrowLeft" || key === "ArrowRight") {
+ targetX = clamp(
+ targetX + (key === "ArrowLeft" ? -0.1 : 0.1),
+ 0.09,
+ 0.91,
+ );
+ return true;
+ }
+ if (key === "Enter" || key === " ") {
+ phase = "down";
+ return true;
+ }
+ return false;
+ };
+ const drawPrize = (context, x, y, color, selected, preset) => {
+ const width = selected ? 76 : 68;
+ const height = selected ? 36 : 32;
+ context.save();
+ context.translate(x, y);
+ const assetSize = selected ? 58 : 50;
+ const renderedAsset = drawGameAsset(
+ context,
+ "capsule",
+ -assetSize / 2,
+ -assetSize / 2 - 7,
+ assetSize,
+ assetSize,
+ color,
+ selected ? 1 : 0.9,
+ );
+ if (!renderedAsset) {
+ roundedRect(
+ context,
+ -width / 2,
+ -height / 2,
+ width,
+ height,
+ height / 2,
+ );
+ const capsule = context.createLinearGradient(
+ -width / 2,
+ 0,
+ width / 2,
+ 0,
+ );
+ capsule.addColorStop(0, mix(color, colors.surface, 0.1));
+ capsule.addColorStop(0.5, mix(color, "#ffffff", 0.18));
+ capsule.addColorStop(0.501, mix(color, colors.surface, 0.32));
+ capsule.addColorStop(1, mix(color, "#000000", 0.18));
+ context.fillStyle = capsule;
+ context.shadowColor = rgba(color, selected ? 0.54 : 0.2);
+ context.shadowBlur = selected ? 14 : 6;
+ context.fill();
+ context.shadowBlur = 0;
+ context.strokeStyle = rgba(colors.text, 0.24);
+ context.stroke();
+ }
+ context.fillStyle = rgba(colors.text, 0.9);
+ context.font = "700 8px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+ context.fillText(preset.name, 0, renderedAsset ? 26 : -5, width + 8);
+ context.fillStyle = rgba(colors.text, 0.68);
+ context.font = "560 7px ui-sans-serif, system-ui, sans-serif";
+ context.fillText(
+ [
+ preset.configuration.model?.label,
+ preset.configuration.reasoning?.label,
+ preset.configuration.speed?.label,
+ ]
+ .filter(Boolean)
+ .join(" · "),
+ 0,
+ renderedAsset ? 37 : 7,
+ width + 14,
+ );
+ context.restore();
+ };
+ const frame = (time) => {
+ if (!viewport || !stage) return;
+ const { context, width, height } = viewport;
+ context.clearRect(0, 0, width, height);
+ drawPlayfield(context, width, height, 0.58);
+ const nearest = prizes.reduce((best, item) =>
+ Math.abs(item.x - clawX) < Math.abs(best.x - clawX) ? item : best,
+ );
+ drawLoadoutStrip(
+ context,
+ width,
+ indicesForConfiguration(nearest.preset.configuration),
+ null,
+ 42,
+ );
+ if (phase === "aim") {
+ clawX += (targetX - clawX) * clamp(0.1 + scale * 0.06, 0.12, 0.19);
+ } else if (phase === "down") {
+ drop = Math.min(1, drop + 0.035 * scale);
+ if (drop >= 1) {
+ captured = prizes.reduce((best, item) =>
+ Math.abs(item.x - clawX) < Math.abs(best.x - clawX) ? item : best,
+ );
+ phase = "up";
+ }
+ } else if (phase === "up") {
+ drop = Math.max(0, drop - 0.03 * scale);
+ if (drop <= 0) {
+ phase = "done";
+ setStageLabel(copy().caught);
+ const capturedIndex = Math.max(0, prizes.indexOf(captured));
+ schedule(() => showProposal(selectPreset(capturedIndex)), 420);
+ }
+ }
+ const railY = 94;
+ const clawTop = railY + 10;
+ const clawBottom = clawTop + Math.max(48, height - railY - 58) * drop;
+ context.strokeStyle = rgba(colors.border, 0.62);
+ context.lineWidth = 2;
+ context.beginPath();
+ context.moveTo(12, railY);
+ context.lineTo(width - 12, railY);
+ context.stroke();
+ context.strokeStyle = colors.accent;
+ context.lineWidth = 2.4;
+ context.beginPath();
+ context.moveTo(width * clawX, railY);
+ context.lineTo(width * clawX, clawBottom - 11);
+ context.stroke();
+ if (
+ !drawGameAsset(
+ context,
+ "claw",
+ width * clawX - 29,
+ clawBottom - 15,
+ 58,
+ 58,
+ colors.accent,
+ )
+ ) {
+ context.beginPath();
+ context.arc(width * clawX - 7, clawBottom + 6, 8, -0.3, 1.3);
+ context.arc(width * clawX + 7, clawBottom + 6, 8, 1.84, 3.45);
+ context.stroke();
+ }
+ prizes.forEach((item) => {
+ if (item === captured && phase !== "down") return;
+ drawPrize(
+ context,
+ width * item.x,
+ height * item.y,
+ item.color,
+ false,
+ item.preset,
+ );
+ });
+ if (captured) {
+ drawPrize(
+ context,
+ width * clawX,
+ Math.min(height - 48, clawBottom + 34),
+ captured.color,
+ true,
+ captured.preset,
+ );
+ }
+ if (phase !== "done") nextFrame(frame);
+ };
+ nextFrame(frame);
+ return { resize, pointer, pointerMove, keyboard, destroy: () => {} };
+ };
+
+ const createToss = () => {
+ let viewport = prepareCanvas();
+ const colors = palette();
+ const nextFrame = createFrameScheduler();
+ const fields = ["model", "reasoning", "speed"];
+ const selected = defaultIndices();
+ const locked = { model: false, reasoning: false, speed: false };
+ let spinning = false;
+ let phase = 0;
+
+ const resize = () => {
+ viewport = prepareCanvas();
+ };
+ const finish = () => {
+ const outcome = outcomeFromIndices(
+ selected,
+ copy().selectedLoadout,
+ "loadout-forge",
+ );
+ setStageLabel(copy().forge);
+ schedule(() => showProposal(outcome), 320);
+ };
+ const advance = () => {
+ if (!spinning) {
+ spinning = true;
+ setStageLabel(copy().tossHint);
+ return;
+ }
+ const field = fields.find((candidate) => !locked[candidate]);
+ if (!field) return;
+ locked[field] = true;
+ const next = fields.find((candidate) => !locked[candidate]);
+ setStageLabel(next ? `${copy().lock} · ${copy()[next]}` : copy().forge);
+ if (!next) {
+ spinning = false;
+ finish();
+ }
+ };
+ const pointer = () => advance();
+ const keyboard = (key) => {
+ if (key === "ArrowLeft" || key === "ArrowRight") {
+ const field =
+ fields.find((candidate) => !locked[candidate]) || "model";
+ const options = fieldOptions(currentSnapshot, field);
+ selected[field] =
+ (selected[field] +
+ (key === "ArrowLeft" ? -1 : 1) +
+ options.length) %
+ options.length;
+ return true;
+ }
+ if (key === "Enter" || key === " ") {
+ advance();
+ return true;
+ }
+ return false;
+ };
+ const frame = (time) => {
+ if (!viewport || !stage) return;
+ const { context, width, height } = viewport;
+ context.clearRect(0, 0, width, height);
+ drawPlayfield(context, width, height, 0.8);
+ if (spinning) {
+ phase += 1;
+ fields.forEach((field, index) => {
+ if (locked[field]) return;
+ const options = fieldOptions(currentSnapshot, field);
+ const cadence = [4, 6, 8][index];
+ if (phase % cadence === 0) {
+ selected[field] = (selected[field] + 1) % options.length;
+ }
+ });
+ }
+ drawLoadoutStrip(
+ context,
+ width,
+ selected,
+ fields.find((field) => !locked[field]) || null,
+ );
+ const labels = copy();
+ const names = {
+ model: labels.model,
+ reasoning: labels.reasoning,
+ speed: labels.speed,
+ };
+ const top = 98;
+ const gap = 8;
+ const rowHeight = Math.max(34, (height - top - 18 - gap * 2) / 3);
+ fields.forEach((field, index) => {
+ const options = fieldOptions(currentSnapshot, field);
+ const option = options[selected[field]];
+ const y = top + index * (rowHeight + gap);
+ const isLocked = locked[field];
+ roundedRect(context, 18, y, width - 36, rowHeight, 12);
+ const reel = context.createLinearGradient(18, y, width - 18, y);
+ reel.addColorStop(0, rgba(colors.text, 0.035));
+ reel.addColorStop(0.5, rgba(colors.accent, isLocked ? 0.2 : 0.1));
+ reel.addColorStop(1, rgba(colors.text, 0.035));
+ context.fillStyle = reel;
+ context.fill();
+ context.strokeStyle = isLocked
+ ? rgba(colors.success, 0.78)
+ : rgba(colors.border, 0.56);
+ context.lineWidth = isLocked ? 1.5 : 1;
+ context.stroke();
+ context.fillStyle = rgba(colors.mutedText, 0.84);
+ context.font = "620 9px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "left";
+ context.textBaseline = "middle";
+ context.fillText(names[field], 30, y + rowHeight / 2, 72);
+ context.fillStyle = rgba(colors.text, 0.96);
+ context.font = "720 12px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "center";
+ context.fillText(option?.label || "—", width / 2, y + rowHeight / 2);
+ context.fillStyle = isLocked
+ ? rgba(colors.success, 0.96)
+ : rgba(colors.accent, 0.9);
+ context.font = "680 8px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "right";
+ context.fillText(
+ isLocked ? labels.lock : spinning ? "•••" : labels.spin,
+ width - 30,
+ y + rowHeight / 2,
+ 78,
+ );
+ });
+ nextFrame(frame);
+ };
+ nextFrame(frame);
+ return { resize, pointer, keyboard, destroy: () => {} };
+ };
+
+ const createBalance = () => {
+ let viewport = prepareCanvas();
+ const colors = palette();
+ const nextFrame = createFrameScheduler();
+ const modelOptions = fieldOptions(currentSnapshot, "model");
+ const reasoningOptions = fieldOptions(currentSnapshot, "reasoning");
+ const speedOptions = fieldOptions(currentSnapshot, "speed");
+ let reasoningIndex = Math.max(
+ 0,
+ reasoningOptions.findIndex(
+ (option) =>
+ option.label === currentSnapshot?.reasoning?.current?.label,
+ ),
+ );
+ let speedIndex = Math.max(
+ 0,
+ speedOptions.findIndex(
+ (option) => option.label === currentSnapshot?.speed?.current?.label,
+ ),
+ );
+ let modelIndex = currentOptionIndex(currentSnapshot, "model");
+ const resize = () => {
+ viewport = prepareCanvas();
+ };
+ const choose = (clientX, clientY) => {
+ if (!viewport) return;
+ const bounds = canvas.getBoundingClientRect();
+ const localY = clientY - bounds.top;
+ if (localY >= 91 && localY <= 132) {
+ modelIndex = nearestOptionIndex(
+ modelOptions,
+ (clientX - bounds.left - 18) / Math.max(1, bounds.width - 36),
+ );
+ return;
+ }
+ const insetX = 56;
+ const gridRight = 22;
+ reasoningIndex = clamp(
+ Math.round(
+ clamp(
+ (clientX - bounds.left - insetX) /
+ Math.max(1, bounds.width - insetX - gridRight),
+ 0,
+ 1,
+ ) *
+ (reasoningOptions.length - 1),
+ ),
+ 0,
+ reasoningOptions.length - 1,
+ );
+ speedIndex = clamp(
+ Math.round(
+ clamp((localY - 145) / Math.max(1, bounds.height - 175), 0, 1) *
+ (speedOptions.length - 1),
+ ),
+ 0,
+ speedOptions.length - 1,
+ );
+ };
+ const tunedPreset = () => {
+ const reasoning = reasoningOptions[reasoningIndex];
+ const speed =
+ speedOptions[speedIndex] || currentSnapshot?.speed?.current;
+ return {
+ id: "tuned",
+ name: copy().selectedLoadout,
+ source: "three-axis-console",
+ configuration: { model: modelOptions[modelIndex], reasoning, speed },
+ };
+ };
+ const pointerMove = (event) => choose(event.clientX, event.clientY);
+ const pointer = (event) => {
+ choose(event.clientX, event.clientY);
+ const bounds = canvas.getBoundingClientRect();
+ if (event.clientY - bounds.top > 132) showProposal(tunedPreset());
+ };
+ const keyboard = (key) => {
+ if (key === "ArrowLeft" || key === "ArrowRight") {
+ reasoningIndex = clamp(
+ reasoningIndex + (key === "ArrowLeft" ? -1 : 1),
+ 0,
+ reasoningOptions.length - 1,
+ );
+ return true;
+ }
+ if (key === "ArrowUp" || key === "ArrowDown") {
+ speedIndex = clamp(
+ speedIndex + (key === "ArrowUp" ? -1 : 1),
+ 0,
+ speedOptions.length - 1,
+ );
+ return true;
+ }
+ if (key === "m" || key === "M") {
+ modelIndex = (modelIndex + 1) % modelOptions.length;
+ return true;
+ }
+ if (key === "Enter" || key === " ") {
+ showProposal(tunedPreset());
+ return true;
+ }
+ return false;
+ };
+ const frame = () => {
+ if (!viewport || !stage) return;
+ const { context, width, height } = viewport;
+ context.clearRect(0, 0, width, height);
+ drawPlayfield(context, width, height, 0.44);
+ drawLoadoutStrip(
+ context,
+ width,
+ { model: modelIndex, reasoning: reasoningIndex, speed: speedIndex },
+ null,
+ );
+ const modelLeft = 18;
+ const modelTop = 94;
+ const modelGap = 6;
+ const modelWidth =
+ (width - modelLeft * 2 - modelGap * (modelOptions.length - 1)) /
+ Math.max(1, modelOptions.length);
+ modelOptions.forEach((model, index) => {
+ const x = modelLeft + index * (modelWidth + modelGap);
+ const active = index === modelIndex;
+ roundedRect(context, x, modelTop, modelWidth, 32, 10);
+ context.fillStyle = active
+ ? rgba(colors.accent, 0.2)
+ : rgba(colors.text, 0.035);
+ context.fill();
+ context.strokeStyle = active
+ ? rgba(colors.accent, 0.82)
+ : rgba(colors.border, 0.48);
+ context.stroke();
+ context.fillStyle = rgba(colors.text, active ? 0.96 : 0.64);
+ context.font = active
+ ? "700 9px ui-sans-serif, system-ui, sans-serif"
+ : "560 8px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+ context.fillText(
+ model.label,
+ x + modelWidth / 2,
+ modelTop + 16,
+ modelWidth - 10,
+ );
+ });
+ const insetX = 56;
+ const top = 145;
+ const gridWidth = Math.max(80, width - insetX - 22);
+ const gridHeight = Math.max(46, height - top - 34);
+ const columnWidth = gridWidth / Math.max(1, reasoningOptions.length);
+ const rowHeight = gridHeight / Math.max(1, speedOptions.length);
+ speedOptions.forEach((speed, row) => {
+ reasoningOptions.forEach((reasoning, column) => {
+ const x = insetX + column * columnWidth + 3;
+ const y = top + row * rowHeight + 3;
+ const active = row === speedIndex && column === reasoningIndex;
+ roundedRect(
+ context,
+ x,
+ y,
+ Math.max(8, columnWidth - 6),
+ Math.max(8, rowHeight - 6),
+ 9,
+ );
+ context.fillStyle = active
+ ? mix(colors.accent, colors.surface, 0.18)
+ : rgba(colors.text, 0.045);
+ context.shadowColor = rgba(colors.accent, active ? 0.46 : 0);
+ context.shadowBlur = active ? 16 : 0;
+ context.fill();
+ context.shadowBlur = 0;
+ context.strokeStyle = active
+ ? rgba(colors.accent, 0.86)
+ : rgba(colors.border, 0.5);
+ context.stroke();
+ });
+ context.fillStyle = rgba(colors.mutedText, 0.88);
+ context.font = "600 9px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "right";
+ context.textBaseline = "middle";
+ context.fillText(
+ speed.label,
+ insetX - 9,
+ top + row * rowHeight + rowHeight / 2,
+ insetX - 14,
+ );
+ });
+ reasoningOptions.forEach((reasoning, column) => {
+ context.fillStyle = rgba(colors.mutedText, 0.88);
+ context.font = "600 9px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "center";
+ context.textBaseline = "alphabetic";
+ context.fillText(
+ reasoning.label,
+ insetX + column * columnWidth + columnWidth / 2,
+ height - 16,
+ Math.max(28, columnWidth - 8),
+ );
+ });
+ context.fillStyle = rgba(colors.mutedText, 0.82);
+ context.font = "650 9px ui-sans-serif, system-ui, sans-serif";
+ context.textBaseline = "alphabetic";
+ context.textAlign = "left";
+ context.fillText(copy().speed, 18, top - 13, insetX - 18);
+ context.textAlign = "right";
+ context.fillText(copy().reasoning, width - 22, top - 13, width / 2);
+ nextFrame(frame);
+ };
+ nextFrame(frame);
+ return { resize, pointer, pointerMove, keyboard, destroy: () => {} };
+ };
+
+ const createRoute = () => {
+ let viewport = prepareCanvas();
+ const colors = palette();
+ const nextFrame = createFrameScheduler();
+ const modelOptions = fieldOptions(currentSnapshot, "model");
+ const reasoningOptions = fieldOptions(currentSnapshot, "reasoning");
+ const speedOptions = fieldOptions(currentSnapshot, "speed");
+ let modelIndex = currentOptionIndex(currentSnapshot, "model");
+ let selected = Math.min(1, currentPresets.length - 1);
+ const resize = () => {
+ viewport = prepareCanvas();
+ };
+ const nodePositions = () =>
+ currentPresets.map((_, index) => ({
+ x: ((index + 1) / (currentPresets.length + 1)) * viewport.width,
+ y:
+ 166 +
+ Math.max(36, viewport.height - 190) *
+ (index % 2 === 0 ? 0.62 : index % 3 === 0 ? 0.82 : 0.28),
+ }));
+ const routeOutcome = () => {
+ const preset = selectPreset(selected);
+ const reasoningIndex = Math.max(
+ 0,
+ reasoningOptions.findIndex(
+ (option) => option.label === preset?.configuration.reasoning?.label,
+ ),
+ );
+ const speedIndex = Math.max(
+ 0,
+ speedOptions.findIndex(
+ (option) => option.label === preset?.configuration.speed?.label,
+ ),
+ );
+ return outcomeFromIndices(
+ { model: modelIndex, reasoning: reasoningIndex, speed: speedIndex },
+ preset?.name || copy().selectedLoadout,
+ "mission-map",
+ );
+ };
+ const choose = (clientX, clientY) => {
+ if (!viewport) return;
+ const bounds = canvas.getBoundingClientRect();
+ const x = clientX - bounds.left;
+ const y = clientY - bounds.top;
+ if (y >= 91 && y <= 132) {
+ modelIndex = nearestOptionIndex(
+ modelOptions,
+ (x - 18) / Math.max(1, bounds.width - 36),
+ );
+ return;
+ }
+ const nodes = nodePositions();
+ selected = nodes.reduce(
+ (best, node, index) =>
+ Math.hypot(node.x - x, node.y - y) < best.distance
+ ? { index, distance: Math.hypot(node.x - x, node.y - y) }
+ : best,
+ { index: selected, distance: Number.POSITIVE_INFINITY },
+ ).index;
+ };
+ const pointerMove = (event) => choose(event.clientX, event.clientY);
+ const pointer = (event) => {
+ choose(event.clientX, event.clientY);
+ const bounds = canvas.getBoundingClientRect();
+ if (event.clientY - bounds.top > 132) showProposal(routeOutcome());
+ };
+ const keyboard = (key) => {
+ if (key === "ArrowLeft" || key === "ArrowRight") {
+ selected =
+ (selected +
+ (key === "ArrowLeft" ? -1 : 1) +
+ currentPresets.length) %
+ currentPresets.length;
+ return true;
+ }
+ if (key === "ArrowUp" || key === "ArrowDown") {
+ modelIndex =
+ (modelIndex + (key === "ArrowUp" ? -1 : 1) + modelOptions.length) %
+ modelOptions.length;
+ return true;
+ }
+ if (key === "Enter" || key === " ") {
+ showProposal(routeOutcome());
+ return true;
+ }
+ return false;
+ };
+ const frame = (time) => {
+ if (!viewport || !stage) return;
+ const { context, width, height } = viewport;
+ context.clearRect(0, 0, width, height);
+ drawPlayfield(context, width, height, 0.62);
+ const activeOutcome = routeOutcome();
+ drawLoadoutStrip(
+ context,
+ width,
+ indicesForConfiguration(activeOutcome?.configuration),
+ null,
+ );
+ const modelLeft = 18;
+ const modelTop = 94;
+ const modelGap = 6;
+ const modelWidth =
+ (width - modelLeft * 2 - modelGap * (modelOptions.length - 1)) /
+ Math.max(1, modelOptions.length);
+ modelOptions.forEach((model, index) => {
+ const x = modelLeft + index * (modelWidth + modelGap);
+ const active = modelIndex === index;
+ roundedRect(context, x, modelTop, modelWidth, 31, 10);
+ context.fillStyle = active
+ ? rgba(colors.accent, 0.19)
+ : rgba(colors.text, 0.035);
+ context.fill();
+ context.strokeStyle = active
+ ? rgba(colors.accent, 0.78)
+ : rgba(colors.border, 0.46);
+ context.stroke();
+ context.fillStyle = rgba(colors.text, active ? 0.96 : 0.65);
+ context.font = active
+ ? "700 8px ui-sans-serif, system-ui, sans-serif"
+ : "560 8px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+ context.fillText(
+ model.label,
+ x + modelWidth / 2,
+ modelTop + 15.5,
+ modelWidth - 10,
+ );
+ });
+ const nodes = nodePositions();
+ context.strokeStyle = rgba(colors.border, 0.7);
+ context.lineWidth = 1.5;
+ context.beginPath();
+ nodes.forEach((node, index) => {
+ if (index === 0) context.moveTo(node.x, node.y);
+ else context.lineTo(node.x, node.y);
+ });
+ context.stroke();
+ nodes.forEach((node, index) => {
+ const active = index === selected;
+ const pulse = active ? 1 + Math.sin(time / 220) * 0.08 : 1;
+ const assetSize = (active ? 34 : 23) * pulse;
+ if (
+ !drawGameAsset(
+ context,
+ "beacon",
+ node.x - assetSize / 2,
+ node.y - assetSize / 2,
+ assetSize,
+ assetSize,
+ colors.accent,
+ active ? 1 : 0.74,
+ )
+ ) {
+ context.beginPath();
+ context.arc(
+ node.x,
+ node.y,
+ (active ? 11 : 7) * pulse,
+ 0,
+ Math.PI * 2,
+ );
+ context.fillStyle = active
+ ? mix(colors.accent, "#ffffff", 0.16)
+ : mix(colors.accent, colors.surface, 0.42);
+ context.shadowColor = rgba(colors.accent, active ? 0.55 : 0.14);
+ context.shadowBlur = active ? 14 : 5;
+ context.fill();
+ context.shadowBlur = 0;
+ }
+ context.fillStyle = rgba(colors.text, active ? 0.92 : 0.62);
+ context.font = active
+ ? "670 9px ui-sans-serif, system-ui, sans-serif"
+ : "560 8px ui-sans-serif, system-ui, sans-serif";
+ context.textAlign = "center";
+ context.fillText(
+ currentPresets[index]?.name || "",
+ node.x,
+ node.y + 24,
+ Math.max(52, width / Math.max(4, currentPresets.length) - 8),
+ );
+ });
+ nextFrame(frame);
+ };
+ nextFrame(frame);
+ return { resize, pointer, pointerMove, keyboard, destroy: () => {} };
+ };
+
+ const start = async (gameId) => {
+ if (!GAME_IDS.has(gameId) || !root || !composer?.isConnected) return;
+ closeStage(false, false);
+ currentGameId = gameId;
+ mountStage(gameId);
+ const requestedRevision = ++launchRevision;
+ await settingsAdapter?.close?.();
+ const snapshot = await settingsAdapter?.inspect?.();
+ if (
+ requestedRevision !== launchRevision ||
+ !root?.isConnected ||
+ !composer?.isConnected
+ ) {
+ return;
+ }
+ currentSnapshot = snapshot || null;
+ currentPresets = buildPresets(currentSnapshot);
+ if (!currentSnapshot?.available || currentPresets.length === 0) {
+ showUnavailable();
+ return;
+ }
+ if (!stage) return;
+ stage.classList.remove("csm-stage--loading");
+ stage.querySelector(".csm-stage-state")?.remove();
+ stageLabel = document.createElement("span");
+ stageLabel.className = "csm-stage-label";
+ stageLabel.textContent = gameHint(gameId);
+ canvas = document.createElement("canvas");
+ canvas.setAttribute("aria-hidden", "true");
+ stage.prepend(canvas, stageLabel);
+ position();
+ stage.focus({ preventScroll: true });
+ if (
+ preferences.reduceMotion ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ staticResult();
+ return;
+ }
+ currentGame =
+ gameId === "marbles"
+ ? createMarbles()
+ : gameId === "claw"
+ ? createClaw()
+ : gameId === "toss"
+ ? createToss()
+ : gameId === "balance"
+ ? createBalance()
+ : createRoute();
+ stage.addEventListener("pointermove", (event) =>
+ currentGame?.pointerMove?.(event),
+ );
+ stage.addEventListener("pointerdown", (event) => {
+ event.preventDefault();
+ currentGame?.pointer?.(event);
+ });
+ stage.addEventListener("keydown", (event) => {
+ if (currentGame?.keyboard?.(event.key)) event.preventDefault();
+ });
+ };
+
+ function onKeyDown(event) {
+ if (
+ document.documentElement.hasAttribute(
+ "data-codex-styler-adapter-keyboard",
+ )
+ ) {
+ return;
+ }
+ if (event.key === "Enter" || event.key === " ") {
+ onNativeTriggerKeyDown(event);
+ if (event.defaultPrevented) return;
+ }
+ if (event.key === "Escape" && stage) {
+ event.preventDefault();
+ closeStage();
+ }
+ }
+
+ function onVisibilityChange() {
+ if (document.visibilityState !== "visible") closeStage(false);
+ }
+
+ const interceptedTrigger = (event) => {
+ if (
+ preferences.mode === "disabled" ||
+ !settingsAdapter?.isTriggerTarget?.(event.target)
+ ) {
+ return null;
+ }
+ const nativeTrigger = settingsAdapter.resolveTrigger?.();
+ if (
+ nativeTrigger?.dataset?.codexStylerAdapterBypass === "true" ||
+ !(nativeTrigger instanceof HTMLElement)
+ ) {
+ return null;
+ }
+ return nativeTrigger;
+ };
+
+ function onNativeTriggerPointerDown(event) {
+ const nativeTrigger = interceptedTrigger(event);
+ if (!nativeTrigger) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ trigger = nativeTrigger;
+ if (!stage) void start(preferences.mode);
+ }
+
+ function onNativeTriggerClick(event) {
+ const nativeTrigger = interceptedTrigger(event);
+ if (!nativeTrigger) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ trigger = nativeTrigger;
+ if (!stage) void start(preferences.mode);
+ }
+
+ function onNativeTriggerKeyDown(event) {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ const nativeTrigger = interceptedTrigger(event);
+ if (!nativeTrigger) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ trigger = nativeTrigger;
+ if (!stage) void start(preferences.mode);
+ }
+
+ const configure = (next = {}) => {
+ const nextPalette = next.palette || {};
+ runtimeLocale = next.locale === "zh-CN" ? "zh-CN" : "en";
+ preferences = {
+ mode: GAME_IDS.has(next.mode)
+ ? next.mode
+ : next.enabled === true
+ ? "marbles"
+ : "disabled",
+ reduceMotion: next.reduceMotion === true,
+ palette: {
+ accent: nextPalette.accent || preferences.palette.accent,
+ surface: nextPalette.surface || preferences.palette.surface,
+ text: nextPalette.text || preferences.palette.text,
+ mutedText: nextPalette.mutedText || preferences.palette.mutedText,
+ border: nextPalette.border || preferences.palette.border,
+ radius: Number.isFinite(nextPalette.radius)
+ ? nextPalette.radius
+ : preferences.palette.radius,
+ surfaceOpacity: Number.isFinite(nextPalette.surfaceOpacity)
+ ? nextPalette.surfaceOpacity
+ : preferences.palette.surfaceOpacity,
+ focusBlur: Number.isFinite(nextPalette.focusBlur)
+ ? nextPalette.focusBlur
+ : preferences.palette.focusBlur,
+ warning: nextPalette.warning || preferences.palette.warning,
+ success: nextPalette.success || preferences.palette.success,
+ },
+ motionIntensity: Number.isFinite(next.motionIntensity)
+ ? clamp(next.motionIntensity, 0, 1)
+ : preferences.motionIntensity,
+ targetFps: Number.isFinite(next.targetFps)
+ ? clamp(next.targetFps, 24, 60)
+ : preferences.targetFps,
+ };
+ if (preferences.mode === "disabled") {
+ destroy();
+ return;
+ }
+ ensureRoot();
+ updateRootPalette();
+ position();
+ };
+
+ const refresh = () => {
+ if (preferences.mode === "disabled") return;
+ ensureRoot();
+ position();
+ };
+
+ const destroy = () => {
+ stopFrame();
+ currentGame?.destroy?.();
+ currentGame = null;
+ resizeObserver?.disconnect();
+ resizeObserver = null;
+ window.removeEventListener("resize", position);
+ document.removeEventListener("scroll", position, true);
+ document.removeEventListener("keydown", onKeyDown, true);
+ document.removeEventListener(
+ "pointerdown",
+ onNativeTriggerPointerDown,
+ true,
+ );
+ document.removeEventListener("click", onNativeTriggerClick, true);
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+ root?.remove();
+ document.getElementById(STYLE_ID)?.remove();
+ root = null;
+ trigger = null;
+ stage = null;
+ canvas = null;
+ stageLabel = null;
+ composer = null;
+ };
+
+ return { configure, refresh, destroy, start };
+ };
+
+ createFactory.version = FACTORY_VERSION;
+ window.__CODEX_STYLER_CREATE_COMPOSER_MOMENTS__ = createFactory;
+})();
diff --git a/apps/desktop/src-tauri/src/composer-settings-adapter.js b/apps/desktop/src-tauri/src/composer-settings-adapter.js
new file mode 100644
index 0000000..97f9dbe
--- /dev/null
+++ b/apps/desktop/src-tauri/src/composer-settings-adapter.js
@@ -0,0 +1,556 @@
+(() => {
+ const FACTORY_VERSION = 11;
+ if (
+ window.__CODEX_STYLER_CREATE_COMPOSER_SETTINGS_ADAPTER__?.version ===
+ FACTORY_VERSION
+ ) {
+ return;
+ }
+
+ const ROOT_ID = "codex-styler-composer-moments";
+ const FIELD_PATTERNS = {
+ /* `\b` only understands ASCII word boundaries. Keep the English boundary,
+ but match CJK labels with an explicit whitespace/end boundary. */
+ model: /^(?:model\b|模型(?:\s|$))/i,
+ reasoning: /^(?:effort\b|reasoning(?: effort)?\b|推理(?:强度)?(?:\s|$))/i,
+ speed: /^(?:speed\b|速度(?:\s|$))/i,
+ };
+
+ const normalize = (value) =>
+ String(value || "")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ const CONCEALED_ATTRIBUTE = "data-codex-styler-adapter-concealed";
+ const PROBE_ATTRIBUTE = "data-codex-styler-settings-probe";
+ const PROBE_STYLE_ID = "codex-styler-settings-probe-style";
+ const KEYBOARD_ATTRIBUTE = "data-codex-styler-adapter-keyboard";
+
+ const isVisible = (element) => {
+ if (!(element instanceof HTMLElement) || !element.isConnected) return false;
+ if (
+ (element.getAttribute("role") === "menu" ||
+ element.getAttribute("role") === "listbox") &&
+ element.getAttribute("data-state") === "closed"
+ ) {
+ return false;
+ }
+ if (
+ element.hidden ||
+ element.closest("[hidden]") ||
+ element.getAttribute("aria-hidden") === "true"
+ ) {
+ return false;
+ }
+ const style = getComputedStyle(element);
+ return style.display !== "none" && style.visibility !== "hidden";
+ };
+
+ const waitFor = (resolve, timeout = 650) =>
+ new Promise((finish) => {
+ const startedAt = performance.now();
+ const check = () => {
+ const value = resolve();
+ if (value || performance.now() - startedAt >= timeout) {
+ finish(value || null);
+ return;
+ }
+ /* Menu state may settle after the keyboard event. A short task delay
+ avoids both re-entrant Radix toggles and synchronous rAF test shims. */
+ window.setTimeout(check, 16);
+ };
+ check();
+ });
+
+ const dispatchEscape = () => {
+ document.documentElement.setAttribute(KEYBOARD_ATTRIBUTE, "true");
+ try {
+ document.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Escape",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ } finally {
+ document.documentElement.removeAttribute(KEYBOARD_ATTRIBUTE);
+ }
+ };
+
+ const createFactory = ({ resolveComposer }) => {
+ let revision = 0;
+ let probeDepth = 0;
+ let probeObserver = null;
+
+ const clickNative = (element) => {
+ if (!(element instanceof HTMLElement)) return;
+ element.dataset.codexStylerAdapterBypass = "true";
+ try {
+ element.click();
+ } finally {
+ delete element.dataset.codexStylerAdapterBypass;
+ }
+ };
+
+ const openWithKeyboard = (element) => {
+ if (!(element instanceof HTMLElement)) return;
+ element.dataset.codexStylerAdapterBypass = "true";
+ try {
+ element.focus({ preventScroll: true });
+ element.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Enter",
+ code: "Enter",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ element.dispatchEvent(
+ new KeyboardEvent("keyup", {
+ key: "Enter",
+ code: "Enter",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ } finally {
+ delete element.dataset.codexStylerAdapterBypass;
+ }
+ };
+
+ const resolveTrigger = () => {
+ const composer = resolveComposer?.();
+ const candidates = [
+ ...document.querySelectorAll("[data-codex-intelligence-trigger]"),
+ ].filter((element) => isVisible(element));
+ if (!composer || candidates.length < 2) return candidates[0] || null;
+ const composerBounds = composer.getBoundingClientRect();
+ return candidates.reduce((nearest, candidate) => {
+ const bounds = candidate.getBoundingClientRect();
+ const distance = Math.abs(bounds.bottom - composerBounds.bottom);
+ return !nearest || distance < nearest.distance
+ ? { element: candidate, distance }
+ : nearest;
+ }, null)?.element;
+ };
+
+ const visibleMenus = () =>
+ [...document.querySelectorAll("[role='menu']")].filter(
+ (element) => !element.closest(`#${ROOT_ID}`) && isVisible(element),
+ );
+
+ const concealMenu = (menu) => {
+ if (!(menu instanceof HTMLElement)) return menu;
+ menu.setAttribute(CONCEALED_ATTRIBUTE, "true");
+ menu.style.setProperty("opacity", "0", "important");
+ menu.style.setProperty("pointer-events", "none", "important");
+ menu.style.setProperty("transition", "none", "important");
+ menu.style.setProperty("animation", "none", "important");
+ return menu;
+ };
+
+ const concealDiscoveredMenus = () => {
+ document
+ .querySelectorAll("[role='menu'], [role='listbox']")
+ .forEach((menu) => {
+ if (!menu.closest(`#${ROOT_ID}`)) concealMenu(menu);
+ });
+ };
+
+ const beginSilentProbe = () => {
+ probeDepth += 1;
+ if (probeDepth > 1) return;
+ if (!document.getElementById(PROBE_STYLE_ID)) {
+ const style = document.createElement("style");
+ style.id = PROBE_STYLE_ID;
+ style.textContent = `
+ html[${PROBE_ATTRIBUTE}="true"] [role="menu"],
+ html[${PROBE_ATTRIBUTE}="true"] [role="listbox"],
+ html[${PROBE_ATTRIBUTE}="true"] [data-radix-popper-content-wrapper] {
+ opacity: 0 !important;
+ pointer-events: none !important;
+ transition: none !important;
+ animation: none !important;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+ document.documentElement.setAttribute(PROBE_ATTRIBUTE, "true");
+ concealDiscoveredMenus();
+ probeObserver = new MutationObserver(concealDiscoveredMenus);
+ probeObserver.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["hidden", "data-state", "aria-hidden"],
+ });
+ };
+
+ const endSilentProbe = () => {
+ probeDepth = Math.max(0, probeDepth - 1);
+ if (probeDepth > 0) return;
+ probeObserver?.disconnect();
+ probeObserver = null;
+ document.documentElement.removeAttribute(PROBE_ATTRIBUTE);
+ };
+
+ const revealMenus = () => {
+ document.querySelectorAll(`[${CONCEALED_ATTRIBUTE}]`).forEach((menu) => {
+ menu.removeAttribute(CONCEALED_ATTRIBUTE);
+ if (menu instanceof HTMLElement) {
+ menu.style.removeProperty("opacity");
+ menu.style.removeProperty("pointer-events");
+ menu.style.removeProperty("transition");
+ menu.style.removeProperty("animation");
+ }
+ });
+ };
+
+ const openRootMenu = async ({ conceal = true } = {}) => {
+ const trigger = resolveTrigger();
+ if (!(trigger instanceof HTMLElement)) return null;
+ let menu = visibleMenus().at(-1) || null;
+ if (
+ trigger.getAttribute("data-state") !== "open" &&
+ trigger.getAttribute("aria-expanded") !== "true"
+ ) {
+ /* Radix ignores HTMLElement.click() for the current Codex trigger,
+ while its keyboard contract remains stable and accessible. */
+ openWithKeyboard(trigger);
+ menu = await waitFor(() => visibleMenus().at(-1), 250);
+ if (!menu) {
+ clickNative(trigger);
+ menu = await waitFor(() => visibleMenus().at(-1), 400);
+ }
+ }
+ menu ||= await waitFor(() => visibleMenus().at(-1));
+ return conceal ? concealMenu(menu) : menu;
+ };
+
+ const closeMenus = async ({ reveal = probeDepth === 0 } = {}) => {
+ /* A submenu and its root are separate Radix layers. Close every active
+ layer and wait for each state transition before reopening another row. */
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ const openCount = visibleMenus().length;
+ if (openCount === 0) break;
+ dispatchEscape();
+ await waitFor(() => visibleMenus().length < openCount, 250);
+ }
+ if (reveal && visibleMenus().length === 0) revealMenus();
+ };
+
+ const silently = async (operation) => {
+ beginSilentProbe();
+ try {
+ return await operation();
+ } finally {
+ await closeMenus({ reveal: false });
+ endSilentProbe();
+ if (visibleMenus().length === 0) revealMenus();
+ }
+ };
+
+ const buttonText = (element) => normalize(element?.textContent);
+
+ const optionLabel = (element) => {
+ if (!(element instanceof HTMLElement)) return "";
+ const accessibleLabel = normalize(element.getAttribute("aria-label"));
+ if (accessibleLabel) return accessibleLabel;
+ const copy = element.cloneNode(true);
+ if (copy instanceof HTMLElement) {
+ copy
+ .querySelectorAll(
+ ".text-token-description-foreground, [data-description], [data-slot='description']",
+ )
+ .forEach((description) => description.remove());
+ const label = normalize(copy.textContent);
+ if (label) return label;
+ }
+ return buttonText(element);
+ };
+
+ const closestInteractive = (element) =>
+ element?.closest(
+ "button, [role='menuitem'], [role='option'], [aria-haspopup='menu']",
+ ) || null;
+
+ const findRow = (field) => {
+ if (field === "model") {
+ const trigger = resolveTrigger();
+ const marker = [
+ ...document.querySelectorAll("[data-model-picker-model-row]"),
+ ].find(
+ (element) =>
+ !element.closest(`#${ROOT_ID}`) &&
+ isVisible(element) &&
+ closestInteractive(element) !== trigger,
+ );
+ const interactive = closestInteractive(marker);
+ if (interactive) return interactive;
+ }
+ const pattern = FIELD_PATTERNS[field];
+ const candidates = [
+ ...document.querySelectorAll(
+ "button, [role='menuitem'], [aria-haspopup='menu']",
+ ),
+ ].filter(
+ (element) =>
+ !element.closest(`#${ROOT_ID}`) &&
+ isVisible(element) &&
+ (pattern.test(normalize(element.getAttribute("aria-label"))) ||
+ pattern.test(buttonText(element))),
+ );
+ return candidates.find((element) => element !== resolveTrigger()) || null;
+ };
+
+ const currentModel = (trigger) =>
+ normalize(
+ trigger?.querySelector("[class*='ModelPickerTriggerModelText']")
+ ?.textContent ||
+ trigger?.querySelector("[data-model-picker-model-row]")
+ ?.textContent ||
+ trigger?.dataset.modelLabel ||
+ "",
+ );
+
+ const rowValue = (field, row, trigger) => {
+ if (field === "model") {
+ const aria = normalize(row?.getAttribute("aria-label"));
+ const ariaValue = normalize(aria.replace(FIELD_PATTERNS.model, ""));
+ if (ariaValue) return ariaValue;
+ const marker = row?.querySelector("[data-model-picker-model-row]");
+ const siblingValue = normalize(
+ marker?.parentElement?.nextElementSibling?.textContent,
+ );
+ return siblingValue || currentModel(trigger);
+ }
+ const aria = normalize(row?.getAttribute("aria-label"));
+ const pattern = FIELD_PATTERNS[field];
+ const ariaValue = normalize(aria.replace(pattern, ""));
+ if (ariaValue) return ariaValue;
+ const parts = [...(row?.querySelectorAll("span") || [])]
+ .map((element) => normalize(element.textContent))
+ .filter(Boolean);
+ return parts.find((part) => !pattern.test(part)) || "";
+ };
+
+ const collectOptions = (container, currentLabel) => {
+ const candidates = [
+ ...container.querySelectorAll("[role='menuitem'], [role='option']"),
+ ].filter(
+ (element) => isVisible(element) && !element.closest(`#${ROOT_ID}`),
+ );
+ const options = [];
+ const seen = new Set();
+ candidates.forEach((element, index) => {
+ const label = optionLabel(element);
+ const key = label.toLocaleLowerCase();
+ if (!label || seen.has(key)) return;
+ seen.add(key);
+ options.push({
+ id: key || String(index),
+ label,
+ selected:
+ element.getAttribute("data-reasoning-selected") === "true" ||
+ element.getAttribute("data-model-selected") === "true" ||
+ normalize(currentLabel).toLocaleLowerCase() === key,
+ });
+ });
+ return options;
+ };
+
+ const inspectField = async (field) => {
+ const trigger = resolveTrigger();
+ const rootMenu = await openRootMenu();
+ if (!rootMenu || !trigger) return null;
+ const row = findRow(field);
+ if (!row) {
+ if (field !== "reasoning") return null;
+ const selected = rootMenu.querySelector(
+ "[data-reasoning-selected='true']",
+ );
+ if (!selected) return null;
+ const options = collectOptions(rootMenu, buttonText(selected));
+ return {
+ current: options.find((option) => option.selected) || null,
+ options,
+ };
+ }
+ const currentLabel = rowValue(field, row, trigger);
+ row.click();
+ const optionsMenu = await waitFor(() => {
+ const menus = visibleMenus();
+ return menus.length > 1 ? menus.at(-1) : null;
+ });
+ concealMenu(optionsMenu);
+ const options = optionsMenu
+ ? collectOptions(optionsMenu, currentLabel)
+ : [];
+ return {
+ current:
+ options.find((option) => option.selected) ||
+ (currentLabel
+ ? {
+ id: currentLabel.toLocaleLowerCase(),
+ label: currentLabel,
+ selected: true,
+ }
+ : null),
+ options,
+ };
+ };
+
+ const inspectSnapshot = async (operationRevision) => {
+ const trigger = resolveTrigger();
+ if (!trigger) {
+ return {
+ available: false,
+ reason: "trigger-missing",
+ model: null,
+ reasoning: null,
+ speed: null,
+ };
+ }
+ const modelLabel = currentModel(trigger);
+ const model = await inspectField("model");
+ await closeMenus();
+ const reasoning = await inspectField("reasoning");
+ await closeMenus();
+ const speed = await inspectField("speed");
+ await closeMenus();
+ if (operationRevision !== revision) {
+ return { available: false, reason: "stale" };
+ }
+ return {
+ available: Boolean(reasoning?.current && reasoning.options.length > 1),
+ reason:
+ reasoning?.current && reasoning.options.length > 1
+ ? null
+ : "reasoning-options-missing",
+ model:
+ model ||
+ (modelLabel
+ ? {
+ current: {
+ id: modelLabel.toLocaleLowerCase(),
+ label: modelLabel,
+ selected: true,
+ },
+ options: [],
+ }
+ : null),
+ reasoning,
+ speed,
+ };
+ };
+
+ const inspect = async () => {
+ const operationRevision = ++revision;
+ return silently(() => inspectSnapshot(operationRevision));
+ };
+
+ const selectField = async (field, target) => {
+ if (!target?.label) return true;
+ const rootMenu = await openRootMenu();
+ if (!rootMenu) return false;
+ let optionsMenu = rootMenu;
+ const row = findRow(field);
+ if (row) {
+ row.click();
+ optionsMenu =
+ (await waitFor(() => {
+ const menus = visibleMenus();
+ return menus.length > 1 ? menus.at(-1) : null;
+ })) || rootMenu;
+ }
+ const normalizedTarget = normalize(target.label).toLocaleLowerCase();
+ const option = [
+ ...optionsMenu.querySelectorAll("[role='menuitem'], [role='option']"),
+ ].find(
+ (element) =>
+ isVisible(element) &&
+ optionLabel(element).toLocaleLowerCase() === normalizedTarget,
+ );
+ if (!(option instanceof HTMLElement)) return false;
+ option.click();
+ await new Promise((resolve) => window.setTimeout(resolve, 40));
+ await closeMenus();
+ return true;
+ };
+
+ const apply = async (configuration) => {
+ const operationRevision = ++revision;
+ return silently(async () => {
+ const before = await inspectSnapshot(operationRevision);
+ if (!before.available || operationRevision !== revision) {
+ return { ok: false, reason: before.reason || "unavailable" };
+ }
+ const changes = ["model", "reasoning", "speed"].filter((field) => {
+ const target = configuration?.[field];
+ const current = before[field]?.current;
+ return target?.label && current?.label !== target.label;
+ });
+ for (const field of changes) {
+ const selected = await selectField(field, configuration[field]);
+ if (!selected)
+ return { ok: false, reason: `${field}-selection-failed` };
+ if (operationRevision !== revision) {
+ return { ok: false, reason: "stale" };
+ }
+ }
+ const after = await inspectSnapshot(operationRevision);
+ if (operationRevision !== revision) {
+ return { ok: false, reason: "stale" };
+ }
+ const verified = changes.every(
+ (field) =>
+ normalize(after[field]?.current?.label).toLocaleLowerCase() ===
+ normalize(configuration[field]?.label).toLocaleLowerCase(),
+ );
+ return {
+ ok: verified,
+ reason: verified ? null : "verification-failed",
+ before,
+ after,
+ };
+ });
+ };
+
+ const destroy = () => {
+ revision += 1;
+ probeDepth = 0;
+ probeObserver?.disconnect();
+ probeObserver = null;
+ document.documentElement.removeAttribute(PROBE_ATTRIBUTE);
+ document.getElementById(PROBE_STYLE_ID)?.remove();
+ void closeMenus();
+ };
+
+ const isTriggerTarget = (target) => {
+ const trigger = resolveTrigger();
+ return Boolean(
+ trigger &&
+ target instanceof Node &&
+ (target === trigger || trigger.contains(target)),
+ );
+ };
+
+ const showOfficialMenu = async () => {
+ await closeMenus();
+ return openRootMenu({ conceal: false });
+ };
+
+ return {
+ inspect,
+ apply,
+ destroy,
+ close: closeMenus,
+ resolveTrigger,
+ isTriggerTarget,
+ openOfficialMenu: showOfficialMenu,
+ };
+ };
+
+ createFactory.version = FACTORY_VERSION;
+ window.__CODEX_STYLER_CREATE_COMPOSER_SETTINGS_ADAPTER__ = createFactory;
+})();
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index b688d1d..5a4cace 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -9,9 +9,10 @@ use std::{
fs,
path::{Component, Path, PathBuf},
sync::Mutex,
- time::Instant,
+ time::{Duration, Instant},
};
+use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use model::{AppRuntime, RuntimeStatus};
use semver::Version;
use serde::{Deserialize, Serialize};
@@ -27,6 +28,28 @@ const SUPPORTED_CODEX_VERSIONS: &[&str] = &["26.707.72221", "26.707.91948"];
const RELEASES_API: &str =
"https://api.github.com/repos/xuhuanstudio/codex-styler/releases?per_page=20";
+fn composer_game_assets_script() -> String {
+ let assets = serde_json::json!({
+ "claw": format!(
+ "data:image/webp;base64,{}",
+ BASE64_STANDARD.encode(include_bytes!("../assets/interactions/claw.webp"))
+ ),
+ "capsule": format!(
+ "data:image/webp;base64,{}",
+ BASE64_STANDARD.encode(include_bytes!("../assets/interactions/capsule.webp"))
+ ),
+ "marble": format!(
+ "data:image/webp;base64,{}",
+ BASE64_STANDARD.encode(include_bytes!("../assets/interactions/marble.webp"))
+ ),
+ "beacon": format!(
+ "data:image/webp;base64,{}",
+ BASE64_STANDARD.encode(include_bytes!("../assets/interactions/beacon.webp"))
+ ),
+ });
+ format!("window.__CODEX_STYLER_COMPOSER_ASSETS__ = {assets};")
+}
+
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
@@ -733,12 +756,42 @@ async fn launch_codex(
Ok(runtime.status())
}
+async fn injected_runtime_is_active(websocket_url: &str) -> bool {
+ cdp::evaluate(
+ websocket_url,
+ "window.__CODEX_STYLER_RUNTIME__?.isActive?.() === true;",
+ )
+ .await
+ .ok()
+ .and_then(|response| {
+ response
+ .pointer("/result/result/value")
+ .and_then(Value::as_bool)
+ }) == Some(true)
+}
+
+async fn evaluate_with_renderer_recovery(
+ port: u16,
+ websocket_url: &mut String,
+ expression: &str,
+) -> Result {
+ match cdp::evaluate(websocket_url, expression).await {
+ Ok(response) => Ok(response),
+ Err(error) if error.is_connection_loss() => {
+ *websocket_url = cdp::wait_for_ready_target(port, 24).await?;
+ cdp::evaluate(websocket_url, expression).await
+ }
+ Err(error) => Err(error),
+ }
+}
+
#[tauri::command]
async fn apply_theme(
theme: Value,
variant: String,
compatibility_mode: String,
revision: u64,
+ experience: Value,
state: State<'_, Mutex>,
) -> Result {
let started_at = Instant::now();
@@ -748,7 +801,7 @@ async fn apply_theme(
if !is_valid_compatibility_mode(&compatibility_mode) {
return Err("The requested runtime strategy is invalid".into());
}
- let websocket_url = {
+ let (mut websocket_url, port) = {
let mut runtime = state
.lock()
.map_err(|_| "Runtime state is unavailable".to_string())?;
@@ -757,47 +810,115 @@ async fn apply_theme(
}
runtime.revision = revision;
runtime.state = model::RuntimeState::Applying;
- runtime
+ let websocket_url = runtime
.websocket_url
.clone()
- .ok_or_else(|| "Codex is not connected".to_string())?
+ .ok_or_else(|| "Codex is not connected".to_string())?;
+ let port = runtime
+ .port
+ .ok_or_else(|| "Codex debugging port is unavailable".to_string())?;
+ (websocket_url, port)
};
let theme_json = serde_json::to_string(&theme).map_err(|error| error.to_string())?;
let variant_json = serde_json::to_string(&variant).map_err(|error| error.to_string())?;
let mode_json =
serde_json::to_string(&compatibility_mode).map_err(|error| error.to_string())?;
+ let experience_json = serde_json::to_string(&experience).map_err(|error| error.to_string())?;
let expression = format!(
- "{}\nwindow.__CODEX_STYLER_RUNTIME__.apply({}, {}, {}, {});",
+ "{}\n{}\n{}\n{}\nwindow.__CODEX_STYLER_RUNTIME__.apply({}, {}, {}, {}, {});",
+ composer_game_assets_script(),
+ include_str!("composer-settings-adapter.js"),
+ include_str!("composer-moments.js"),
include_str!("runtime.js"),
theme_json,
variant_json,
mode_json,
revision,
+ experience_json,
);
- let response = match cdp::evaluate(&websocket_url, &expression).await {
- Ok(response) => response,
- Err(error) => {
- let detail = error.to_string();
+ let mut response =
+ match evaluate_with_renderer_recovery(port, &mut websocket_url, &expression).await {
+ Ok(response) => response,
+ Err(error) => {
+ let detail = error.to_string();
+ if let Ok(mut runtime) = state.lock()
+ && revision == runtime.revision
+ {
+ if error.is_connection_loss() {
+ runtime.invalidate_connection(model::RuntimeState::Error, detail.clone());
+ } else {
+ runtime.state = model::RuntimeState::Error;
+ runtime.message = Some(detail.clone());
+ }
+ runtime.record(
+ "apply-configuration",
+ "error",
+ started_at.elapsed().as_millis() as u64,
+ );
+ }
+ return Err(detail);
+ }
+ };
+ if let Ok(mut runtime) = state.lock()
+ && revision == runtime.revision
+ {
+ runtime.websocket_url = Some(websocket_url.clone());
+ }
+
+ // Electron can finish replacing its bootstrap renderer just after the
+ // first successful evaluation. Verify that the active runtime survived
+ // that handoff and retry once in the same controlled session when needed.
+ tokio::time::sleep(Duration::from_millis(320)).await;
+ if !injected_runtime_is_active(&websocket_url).await {
+ if let Ok(ready_websocket) = cdp::wait_for_ready_target(port, 24).await {
+ websocket_url = ready_websocket;
+ }
+ response = match cdp::evaluate(&websocket_url, &expression).await {
+ Ok(response) => response,
+ Err(error) => {
+ let detail = error.to_string();
+ if let Ok(mut runtime) = state.lock()
+ && revision == runtime.revision
+ {
+ if error.is_connection_loss() {
+ runtime.invalidate_connection(model::RuntimeState::Error, detail.clone());
+ } else {
+ runtime.state = model::RuntimeState::Error;
+ runtime.message = Some(detail.clone());
+ }
+ runtime.record(
+ "apply-configuration-retry",
+ "error",
+ started_at.elapsed().as_millis() as u64,
+ );
+ }
+ return Err(detail);
+ }
+ };
+ if let Ok(mut runtime) = state.lock()
+ && revision == runtime.revision
+ {
+ runtime.websocket_url = Some(websocket_url.clone());
+ }
+ tokio::time::sleep(Duration::from_millis(320)).await;
+ if !injected_runtime_is_active(&websocket_url).await {
+ let detail = "The Codex renderer did not retain the injected runtime after startup";
if let Ok(mut runtime) = state.lock()
&& revision == runtime.revision
{
- if error.is_connection_loss() {
- runtime.invalidate_connection(model::RuntimeState::Error, detail.clone());
- } else {
- runtime.state = model::RuntimeState::Error;
- runtime.message = Some(detail.clone());
- }
+ runtime.state = model::RuntimeState::Error;
+ runtime.message = Some(detail.into());
runtime.record(
- "apply-configuration",
- "error",
+ "apply-configuration-retry",
+ "runtime-not-active",
started_at.elapsed().as_millis() as u64,
);
}
- return Err(detail);
+ return Err(detail.into());
}
- };
+ }
let outcome = response.pointer("/result/result/value");
let resolved_mode = outcome
@@ -905,6 +1026,89 @@ async fn update_companion(
Ok(runtime.status())
}
+#[tauri::command]
+async fn update_runtime_experience(
+ experience: Value,
+ revision: u64,
+ state: State<'_, Mutex>,
+) -> Result {
+ let started_at = Instant::now();
+ let (websocket_url, previous_state) = {
+ let mut runtime = state
+ .lock()
+ .map_err(|_| "Runtime state is unavailable".to_string())?;
+ if revision < runtime.revision {
+ return Ok(runtime.status());
+ }
+ if !matches!(
+ runtime.state,
+ model::RuntimeState::Applied | model::RuntimeState::Fallback
+ ) {
+ return Err("runtime-not-active".into());
+ }
+ runtime.revision = revision;
+ let previous_state = runtime.state;
+ let websocket_url = runtime
+ .websocket_url
+ .clone()
+ .ok_or_else(|| "Codex is not connected".to_string())?;
+ (websocket_url, previous_state)
+ };
+ let experience_json = serde_json::to_string(&experience).map_err(|error| error.to_string())?;
+ let expression = format!(
+ "window.__CODEX_STYLER_RUNTIME__?.updateExperience?.({}, {}) ?? {{ ok: false, reason: 'runtime-update-unsupported' }};",
+ experience_json, revision,
+ );
+ let response = match cdp::evaluate(&websocket_url, &expression).await {
+ Ok(response) => response,
+ Err(error) => {
+ let detail = error.to_string();
+ if let Ok(mut runtime) = state.lock()
+ && revision == runtime.revision
+ {
+ if error.is_connection_loss() {
+ runtime.invalidate_connection(model::RuntimeState::Error, detail.clone());
+ } else {
+ runtime.state = model::RuntimeState::Error;
+ runtime.message = Some(detail.clone());
+ }
+ runtime.record(
+ "update-runtime-experience",
+ "error",
+ started_at.elapsed().as_millis() as u64,
+ );
+ }
+ return Err(detail);
+ }
+ };
+ let outcome = response.pointer("/result/result/value");
+ if outcome
+ .and_then(|value| value.get("ok"))
+ .and_then(Value::as_bool)
+ != Some(true)
+ {
+ return Err(outcome
+ .and_then(|value| value.get("reason"))
+ .and_then(Value::as_str)
+ .unwrap_or("runtime-update-unsupported")
+ .to_string());
+ }
+ let mut runtime = state
+ .lock()
+ .map_err(|_| "Runtime state is unavailable".to_string())?;
+ if revision != runtime.revision {
+ return Ok(runtime.status());
+ }
+ runtime.state = previous_state;
+ runtime.message = Some("Interaction preferences updated without reinjecting the theme".into());
+ runtime.record(
+ "update-runtime-experience",
+ "applied",
+ started_at.elapsed().as_millis() as u64,
+ );
+ Ok(runtime.status())
+}
+
#[tauri::command]
async fn pause_theme(state: State<'_, Mutex>) -> Result {
let websocket_url = state
@@ -988,6 +1192,7 @@ pub fn run() {
launch_codex,
apply_theme,
update_companion,
+ update_runtime_experience,
pause_theme,
restore_official,
save_theme_archive,
@@ -1014,6 +1219,18 @@ pub fn run() {
mod tests {
use super::*;
+ #[test]
+ fn composer_game_assets_are_embedded_as_local_webp_data_urls() {
+ let script = composer_game_assets_script();
+ assert!(script.starts_with("window.__CODEX_STYLER_COMPOSER_ASSETS__ = "));
+ for name in ["claw", "capsule", "marble", "beacon"] {
+ assert!(script.contains(&format!("\"{name}\"")));
+ }
+ assert_eq!(script.matches("data:image/webp;base64,").count(), 4);
+ assert!(!script.contains("http://"));
+ assert!(!script.contains("https://"));
+ }
+
#[test]
fn known_codex_version_uses_the_semantic_adapter() {
assert_eq!(
diff --git a/apps/desktop/src-tauri/src/runtime.js b/apps/desktop/src-tauri/src/runtime.js
index 908df70..7b2dc67 100644
--- a/apps/desktop/src-tauri/src/runtime.js
+++ b/apps/desktop/src-tauri/src/runtime.js
@@ -1,11 +1,15 @@
(() => {
- if (window.__CODEX_STYLER_RUNTIME__?.version === 36) return;
+ const RUNTIME_VERSION = 41;
+ if (window.__CODEX_STYLER_RUNTIME__?.version === RUNTIME_VERSION) return;
+ window.__CODEX_STYLER_TRANSIENT_INTERACTIONS__?.destroy?.();
+ delete window.__CODEX_STYLER_TRANSIENT_INTERACTIONS__;
window.__CODEX_STYLER_RUNTIME__?.restore?.();
const BACKDROP_ID = "codex-styler-scene-root";
const ENTITY_ID = "codex-styler-entity-root";
const STYLE_ID = "codex-styler-runtime-style";
const CONTRAST_REPAIR_STYLE_ID = "codex-styler-contrast-repair-style";
+ const COMPOSER_MOMENTS_ROOT_ID = "codex-styler-composer-moments";
const APP_ROOT_ATTRIBUTE = "data-codex-styler-app-root";
const OVERLAY_ROOT_ATTRIBUTE = "data-codex-styler-overlay-root";
const UNLAYERED_ROOT_ATTRIBUTE = "data-codex-styler-unlayered-root";
@@ -45,6 +49,8 @@
let activeState = null;
let entityCleanup = null;
let entityPositioner = null;
+ let composerMoments = null;
+ let composerSettingsAdapter = null;
let latestRevision = 0;
const finiteBetween = (value, minimum, maximum) =>
@@ -502,6 +508,10 @@
};
const remove = () => {
+ composerMoments?.destroy?.();
+ composerMoments = null;
+ composerSettingsAdapter?.destroy?.();
+ composerSettingsAdapter = null;
if (scenePointerHandler)
window.removeEventListener("pointermove", scenePointerHandler);
if (sceneResetHandler) {
@@ -559,7 +569,9 @@
};
const injectedRoot = (element) =>
- element?.id === BACKDROP_ID || element?.id === ENTITY_ID;
+ element?.id === BACKDROP_ID ||
+ element?.id === ENTITY_ID ||
+ element?.id === COMPOSER_MOMENTS_ROOT_ID;
const appRootFallback = () => {
const conventionalRoot = document.querySelector("body > #root");
@@ -1620,9 +1632,8 @@
select,
[role="button"],
[role="tab"],
- [role="option"],
- [role="switch"]
- ) {
+ [role="option"]
+ ):not([role="switch"]):not([role="checkbox"]):not([role="radio"]) {
transition: color var(--codex-styler-motion-duration) ease, background-color var(--codex-styler-motion-duration) ease, border-color var(--codex-styler-motion-duration) ease, box-shadow var(--codex-styler-motion-duration) ease, transform var(--codex-styler-motion-duration) ease !important;
}
@media (hover: hover) and (pointer: fine) {
@@ -1651,9 +1662,8 @@
select,
[role="button"],
[role="tab"],
- [role="option"],
- [role="switch"]
- ):focus-visible {
+ [role="option"]
+ ):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):focus-visible {
outline: 2px solid color-mix(in srgb, ${appearance.accent} 74%, var(--codex-styler-focus)) !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 4px color-mix(in srgb, ${appearance.accent} 14%, transparent) !important;
@@ -1686,10 +1696,29 @@
) {
accent-color: ${appearance.accent} !important;
}
- html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="switch"][aria-checked="true"] {
+ /* Switches keep Codex's native geometry and thumb mechanics. Theme
+ styling is deliberately limited to state color and the accessible
+ focus outline so imported themes cannot turn a compact toggle into
+ a generic active button. */
+ html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="switch"] {
+ accent-color: ${appearance.accent} !important;
+ }
+ html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="switch"]:is(
+ [aria-checked="true"],
+ [data-state="checked"],
+ [data-state="on"]
+ ) {
border-color: color-mix(in srgb, ${appearance.accent} 72%, ${appearance.border}) !important;
background-color: color-mix(in srgb, ${appearance.accent} 74%, var(--codex-styler-surface-raised)) !important;
}
+ html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="switch"]:focus-visible {
+ outline: 2px solid color-mix(in srgb, ${appearance.accent} 74%, var(--codex-styler-focus)) !important;
+ outline-offset: 2px !important;
+ }
+ html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="switch"]:is(:disabled, [aria-disabled="true"]) {
+ filter: saturate(.72);
+ opacity: .58 !important;
+ }
html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(
button,
input,
@@ -1698,10 +1727,9 @@
[role="button"],
[role="tab"],
[role="option"],
- [role="switch"],
[role="checkbox"],
[role="radio"]
- ):is(:disabled, [aria-disabled="true"]) {
+ ):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):is(:disabled, [aria-disabled="true"]) {
color: var(--codex-styler-text-tertiary) !important;
border-color: color-mix(in srgb, ${appearance.border} 54%, transparent) !important;
background-color: color-mix(in srgb, var(--codex-styler-control) 48%, transparent) !important;
@@ -1712,11 +1740,8 @@
html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(
button,
[role="button"],
- [role="option"],
- [role="switch"],
- [role="checkbox"],
- [role="radio"]
- ):is(
+ [role="option"]
+ ):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):is(
[aria-pressed="true"],
[aria-checked="true"],
[data-state="active"],
@@ -1790,20 +1815,20 @@
* stroke geometry. Destructive, invalid and branded actions keep
* their native meaning instead of being recolored by the theme.
*/
- html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] body > [${APP_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > svg:first-child,
- html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] body > [${APP_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > :is(span, div):first-child > svg:only-child,
- html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > svg:first-child,
- html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > :is(span, div):first-child > svg:only-child {
+ html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] body > [${APP_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > svg:first-child,
+ html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] body > [${APP_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > :is(span, div):first-child > svg:only-child,
+ html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > svg:first-child,
+ html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > :is(span, div):first-child > svg:only-child {
overflow: visible;
border-radius: max(3px, calc(${appearance.radius}px * 0.28));
color: var(--codex-styler-icon) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, ${appearance.accent} 10%, transparent);
filter: drop-shadow(0 2px 5px color-mix(in srgb, ${appearance.accent} 20%, transparent));
}
- html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] body > [${APP_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > svg:first-child,
- html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] body > [${APP_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > :is(span, div):first-child > svg:only-child,
- html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > svg:first-child,
- html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > :is(span, div):first-child > svg:only-child {
+ html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] body > [${APP_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > svg:first-child,
+ html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] body > [${APP_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > :is(span, div):first-child > svg:only-child,
+ html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > svg:first-child,
+ html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"]):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):not([aria-invalid="true"]):not([data-variant="destructive"]):not([data-tone="danger"]):not([data-state="error"]):not([data-brand]):not([class*="destructive"]):not([class*="danger"]):not([class*="text-red"]) > :is(span, div):first-child > svg:only-child {
overflow: visible;
border-radius: 999px;
color: var(--codex-styler-icon-emphasis) !important;
@@ -2961,7 +2986,59 @@
const nextFrame = () =>
new Promise((resolve) => requestAnimationFrame(() => resolve()));
- const render = (theme, variant, safeMode, requestedMode, resolvedMode) => {
+ const waitForApplicationSurface = async (timeout = 3600) => {
+ const startedAt = performance.now();
+ let stableSamples = 0;
+ while (performance.now() - startedAt < timeout) {
+ const root = appRootFallback();
+ const surface = document.querySelector(
+ 'main.main-surface, aside.app-shell-left-panel, .composer-surface-chrome, [data-testid="composer"], [role="main"]',
+ );
+ if (root && surface) {
+ stableSamples += 1;
+ if (stableSamples >= 2) return true;
+ } else {
+ stableSamples = 0;
+ }
+ await new Promise((resolve) => window.setTimeout(resolve, 60));
+ }
+ return false;
+ };
+
+ const composerExperienceConfiguration = (theme, variant, experience = {}) => {
+ const visual = theme.variants[variant];
+ const appearance = visual.appearance;
+ return {
+ mode:
+ experience.composerInteractionMode ||
+ (experience.composerMomentsEnabled === true ? "marbles" : "disabled"),
+ locale: experience.locale === "zh-CN" ? "zh-CN" : "en",
+ reduceMotion: experience.reduceMotion === true,
+ palette: {
+ accent: appearance.accent,
+ surface: appearance.surface,
+ text: appearance.text,
+ mutedText: appearance.mutedText,
+ border: appearance.border,
+ radius: appearance.radius,
+ surfaceOpacity: appearance.surfaceOpacity,
+ focusBlur: appearance.focusBlur,
+ warning: appearance.palette?.warning || appearance.accent,
+ success: appearance.palette?.success || appearance.accent,
+ },
+ motionIntensity: visual.motion.intensity,
+ targetFps: visual.motion.targetFps,
+ };
+ };
+
+ const render = (
+ theme,
+ variant,
+ safeMode,
+ requestedMode,
+ resolvedMode,
+ experience = {},
+ ) => {
assertSafeTheme(theme, variant);
remove();
document.documentElement.setAttribute("data-codex-styler", theme.id);
@@ -2969,7 +3046,8 @@
"data-codex-styler-mode",
safeMode ? "compatibility" : "semantic",
);
- const appearance = theme.variants[variant].appearance;
+ const visual = theme.variants[variant];
+ const appearance = visual.appearance;
const contrastSystem = resolveContrastSystem(theme, variant);
document.documentElement.setAttribute("data-codex-styler-variant", variant);
document.documentElement.setAttribute(
@@ -3115,6 +3193,23 @@
entityRoot.setAttribute("aria-hidden", "true");
document.body.appendChild(entityRoot);
installEntity(entityRoot, theme, variant);
+ const momentsFactory = window.__CODEX_STYLER_CREATE_COMPOSER_MOMENTS__;
+ const settingsAdapterFactory =
+ window.__CODEX_STYLER_CREATE_COMPOSER_SETTINGS_ADAPTER__;
+ if (typeof momentsFactory === "function") {
+ if (typeof settingsAdapterFactory === "function") {
+ composerSettingsAdapter = settingsAdapterFactory({
+ resolveComposer: () => entityTarget("composer"),
+ });
+ }
+ composerMoments = momentsFactory({
+ resolveComposer: () => entityTarget("composer"),
+ settingsAdapter: composerSettingsAdapter,
+ });
+ composerMoments.configure(
+ composerExperienceConfiguration(theme, variant, experience),
+ );
+ }
layoutResizeHandler = () => updateResponsiveLayout();
window.addEventListener("resize", layoutResizeHandler, { passive: true });
@@ -3124,12 +3219,14 @@
safeMode: Boolean(safeMode),
requestedMode,
resolvedMode,
+ experience,
};
mutationObserver = new MutationObserver(() => {
updateStackingRoots();
updatePageKind();
updateResponsiveLayout();
entityPositioner?.();
+ composerMoments?.refresh?.();
if (
activeState &&
(!document.getElementById(BACKDROP_ID) ||
@@ -3143,6 +3240,7 @@
state.safeMode,
state.requestedMode,
state.resolvedMode,
+ state.experience,
);
return;
}
@@ -3176,6 +3274,7 @@
true,
state.requestedMode,
"compatibility",
+ state.experience,
);
document.documentElement.setAttribute(
"data-codex-styler-fallback",
@@ -3192,6 +3291,7 @@
true,
state.requestedMode,
"compatibility",
+ state.experience,
);
document.documentElement.setAttribute(
"data-codex-styler-fallback",
@@ -3233,11 +3333,50 @@
return { ok: true, stale: false, revision };
};
+ const updateExperience = (experience = {}, revision = latestRevision + 1) => {
+ if (!Number.isInteger(revision) || revision < 0) {
+ throw new Error(
+ "Codex Styler rejected an invalid configuration revision",
+ );
+ }
+ if (revision < latestRevision) {
+ return { ok: true, stale: true, revision: latestRevision };
+ }
+ if (!activeState || !composerMoments) {
+ return {
+ ok: false,
+ stale: false,
+ revision: latestRevision,
+ reason: "runtime-not-active",
+ };
+ }
+ latestRevision = revision;
+ const nextExperience = {
+ ...activeState.experience,
+ ...experience,
+ };
+ composerMoments.configure(
+ composerExperienceConfiguration(
+ activeState.theme,
+ activeState.variant,
+ nextExperience,
+ ),
+ );
+ activeState.experience = nextExperience;
+ return {
+ ok: true,
+ stale: false,
+ revision,
+ mode: nextExperience.composerInteractionMode || "disabled",
+ };
+ };
+
const apply = async (
theme,
variant,
compatibilityMode = "auto",
revision = latestRevision + 1,
+ experience = {},
) => {
assertSafeTheme(theme, variant);
if (!Number.isInteger(revision) || revision < 0) {
@@ -3261,13 +3400,33 @@
throw new Error("Codex Styler rejected an invalid runtime strategy");
}
+ await waitForApplicationSurface();
+ if (revision !== latestRevision) {
+ return {
+ ok: true,
+ stale: true,
+ revision: latestRevision,
+ themeId: theme.id,
+ requestedMode: compatibilityMode,
+ resolvedMode: activeState?.resolvedMode ?? "compatibility",
+ reason: null,
+ };
+ }
+
const themeRequestsSemantic =
theme.compatibility?.codex?.mode === "semantic";
if (
compatibilityMode === "compatibility" ||
(compatibilityMode === "auto" && !themeRequestsSemantic)
) {
- render(theme, variant, true, compatibilityMode, "compatibility");
+ render(
+ theme,
+ variant,
+ true,
+ compatibilityMode,
+ "compatibility",
+ experience,
+ );
return {
ok: true,
themeId: theme.id,
@@ -3281,7 +3440,7 @@
const resolvedMode =
compatibilityMode === "developer" ? "developer" : "semantic";
- render(theme, variant, false, compatibilityMode, resolvedMode);
+ render(theme, variant, false, compatibilityMode, resolvedMode, experience);
if (compatibilityMode === "developer") {
return {
ok: true,
@@ -3325,7 +3484,14 @@
contrastRepairApplied = verification.ok;
}
if (!verification.ok) {
- render(theme, variant, true, compatibilityMode, "compatibility");
+ render(
+ theme,
+ variant,
+ true,
+ compatibilityMode,
+ "compatibility",
+ experience,
+ );
document.documentElement.setAttribute(
"data-codex-styler-fallback",
verification.reason,
@@ -3354,9 +3520,11 @@
}
window.__CODEX_STYLER_RUNTIME__ = {
- version: 36,
+ version: RUNTIME_VERSION,
+ isActive: () => Boolean(activeState),
apply,
updateEntity,
+ updateExperience,
pause: remove,
restore: remove,
};
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index 35db2c6..c5bd0f5 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex Styler",
- "version": "0.2.0-beta.7",
+ "version": "0.2.0-beta.8",
"identifier": "studio.xuhuan.codexstyler",
"build": {
"beforeDevCommand": "pnpm dev",
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3920f21..c5e1a76 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -27,6 +27,7 @@ import {
restoreOfficial,
restartApp,
updateCompanionConfiguration,
+ updateRuntimeExperience,
validateCodexInstallPath,
type RuntimeStatus,
} from "./lib/runtime";
@@ -84,6 +85,7 @@ vi.mock("./lib/runtime", async (importOriginal) => {
}),
applyConfiguration: vi.fn(),
updateCompanionConfiguration: vi.fn(),
+ updateRuntimeExperience: vi.fn(),
chooseCodexInstallPath: vi.fn(),
launchCodex: vi.fn(),
pauseTheme: vi.fn(),
@@ -124,6 +126,8 @@ describe("Codex Styler shell", () => {
vi.mocked(applyConfiguration).mockResolvedValue(appliedRuntime);
vi.mocked(updateCompanionConfiguration).mockReset();
vi.mocked(updateCompanionConfiguration).mockResolvedValue(appliedRuntime);
+ vi.mocked(updateRuntimeExperience).mockReset();
+ vi.mocked(updateRuntimeExperience).mockResolvedValue(appliedRuntime);
vi.mocked(launchCodex).mockReset();
vi.mocked(launchCodex).mockResolvedValue({
...appliedRuntime,
@@ -180,6 +184,9 @@ describe("Codex Styler shell", () => {
expect(screen.getAllByText("Codex Styler").length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Themes" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Interactions" }),
+ ).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Create" }),
).not.toBeInTheDocument();
@@ -203,6 +210,90 @@ describe("Codex Styler shell", () => {
expect(configurationDock?.previousElementSibling).toBe(viewport);
});
+ it("keeps composer interactions as a dedicated, persistent selection", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Interactions" }));
+
+ expect(
+ screen.getByRole("heading", { name: "Interactions", level: 1 }),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("option", { name: /Triple Drop/ }));
+
+ expect(screen.getByRole("option", { name: /Triple Drop/ })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(
+ JSON.parse(localStorage.getItem("codex-styler.settings.v1") ?? "{}")
+ .composerInteractionMode,
+ ).toBe("marbles");
+ });
+
+ it("does not run an incremental interaction update before the theme runtime is active", async () => {
+ vi.mocked(getRuntimeStatus).mockResolvedValue({
+ ...appliedRuntime,
+ state: "connected",
+ revision: 7,
+ });
+ localStorage.setItem(
+ "codex-styler.settings.v1",
+ JSON.stringify({
+ locale: "en",
+ appearance: "system",
+ runtimeStrategy: "enhanced",
+ appliedThemeId: builtinThemes[0].id,
+ themeVariant: "dark",
+ companionMode: "theme-default",
+ composerInteractionMode: "toss",
+ reduceMotion: false,
+ automaticUpdateChecks: false,
+ onboardingComplete: true,
+ }),
+ );
+
+ render( );
+
+ await waitFor(() => expect(getRuntimeStatus).toHaveBeenCalled());
+ expect(updateRuntimeExperience).not.toHaveBeenCalled();
+ expect(applyConfiguration).not.toHaveBeenCalled();
+ });
+
+ it("restores the selected interaction after reconnecting to an active runtime", async () => {
+ vi.mocked(getRuntimeStatus).mockResolvedValue({
+ ...appliedRuntime,
+ state: "applied",
+ revision: 7,
+ });
+ localStorage.setItem(
+ "codex-styler.settings.v1",
+ JSON.stringify({
+ locale: "en",
+ appearance: "system",
+ runtimeStrategy: "enhanced",
+ appliedThemeId: builtinThemes[0].id,
+ themeVariant: "dark",
+ companionMode: "theme-default",
+ composerInteractionMode: "toss",
+ reduceMotion: false,
+ automaticUpdateChecks: false,
+ onboardingComplete: true,
+ }),
+ );
+
+ render( );
+
+ await waitFor(() => expect(updateRuntimeExperience).toHaveBeenCalledOnce());
+ expect(vi.mocked(updateRuntimeExperience).mock.calls[0]).toEqual([
+ {
+ composerInteractionMode: "toss",
+ locale: "en",
+ reduceMotion: false,
+ },
+ 8,
+ ]);
+ expect(applyConfiguration).not.toHaveBeenCalled();
+ });
+
it("uses the persistent setup bar instead of redundant selection toasts", () => {
render( );
fireEvent.click(screen.getByRole("button", { name: "Themes" }));
@@ -630,7 +721,7 @@ describe("Codex Styler shell", () => {
it("checks for updates from settings and reports the current version", async () => {
render( );
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
- expect(screen.getByText("Codex Styler 0.2.0-beta.7")).toBeInTheDocument();
+ expect(screen.getByText("Codex Styler 0.2.0-beta.8")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Check for updates" }));
await waitFor(() => expect(checkForUpdates).toHaveBeenCalledWith("en"));
expect(await screen.findByText("You’re up to date")).toBeInTheDocument();
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 11d7ae2..3b5b3f8 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -1,14 +1,10 @@
import {
Check,
- ChevronRight,
- Copy,
- Download,
Film,
FolderOpen,
- Image,
+ Gamepad2,
Layers3,
Leaf,
- LockKeyhole,
MoreHorizontal,
Monitor,
Moon,
@@ -19,15 +15,11 @@ import {
PanelLeftClose,
PanelLeftOpen,
Plus,
- RefreshCw,
- Save,
Settings,
ShieldCheck,
Sparkles,
Sun,
- Trash2,
Upload,
- X,
} from "lucide-react";
import {
builtinCompanions,
@@ -62,6 +54,16 @@ import { Onboarding } from "./components/Onboarding";
import { PreviewWorkspace } from "./components/PreviewWorkspace";
import { SelectField } from "./components/ui/SelectField";
import type { CompanionCreatorProject } from "./features/companion-creator/model";
+import {
+ DeleteCompanionDialog,
+ DeleteCompanionProjectDialog,
+ DeleteThemeDialog,
+ DiagnosticsDialog,
+ RestartCodexDialog,
+ UnsavedThemeDialog,
+ UpdateDialog,
+ type UpdateInstallStatus,
+} from "./features/app-shell/AppDialogs";
import {
deleteCompanionProject,
listCompanionProjects,
@@ -98,6 +100,7 @@ import {
restartApp,
restoreOfficial,
updateCompanionConfiguration,
+ updateRuntimeExperience,
validateCodexInstallPath,
type AvailableUpdate,
type CodexDetection,
@@ -143,13 +146,7 @@ import {
type CompanionAssetMap,
} from "./lib/companion-files";
import { themeAssetUrl } from "./lib/assets";
-import {
- collectDiagnostics,
- diagnosticsSummary,
- exportDiagnostics,
- openWindowsCompatibilityIssue,
- type DiagnosticsReport,
-} from "./lib/diagnostics";
+import { collectDiagnostics, type DiagnosticsReport } from "./lib/diagnostics";
import {
appSessionReducer,
createInitialAppSession,
@@ -163,21 +160,30 @@ import {
import { SettingsView } from "./features/settings/SettingsView";
import { HomeView } from "./features/home/HomeView";
import { ThemesView, type ThemeCollection } from "./features/themes/ThemesView";
+import {
+ NewThemeDialog,
+ type NewThemeStep,
+} from "./features/themes/NewThemeDialog";
import {
CompanionsView,
type CompanionCollection,
} from "./features/companions/CompanionsView";
import { ThemeEditorView } from "./features/theme-editor/ThemeEditorView";
+import { InteractionsView } from "./features/interactions/InteractionsView";
import {
themeDraftsEqual,
useThemeDraftHistory,
} from "./features/theme-editor/use-theme-draft-history";
type View =
- "home" | "themes" | "companions" | "settings" | "editor" | "companion-editor";
-type NewThemeStep = "choose" | "existing";
+ | "home"
+ | "themes"
+ | "companions"
+ | "interactions"
+ | "settings"
+ | "editor"
+ | "companion-editor";
type UpdateStatus = "idle" | "checking" | "current" | "error";
-type UpdateInstallStatus = "idle" | "downloading" | "installing" | "restarting";
type ApplySuccessMessage =
| "configurationApplied"
| "themeApplied"
@@ -291,7 +297,7 @@ export function App() {
} | null>(null);
const [installPathBusy, setInstallPathBusy] = useState(false);
const [toast, setToast] = useState(null);
- const [currentVersion, setCurrentVersion] = useState("0.2.0-beta.7");
+ const [currentVersion, setCurrentVersion] = useState("0.2.0-beta.8");
const [updateStatus, setUpdateStatus] = useState("idle");
const [availableUpdate, setAvailableUpdate] =
useState(null);
@@ -338,6 +344,7 @@ export function App() {
useState(null);
const startupUpdateCheckRef = useRef(false);
const liveCompanionSyncRef = useRef(null);
+ const runtimeExperienceSyncRef = useRef(null);
const applyRevisionRef = useRef(0);
const locale = resolveLocale(settings.locale);
@@ -602,6 +609,59 @@ export function App() {
};
}, [runtime.connected, runtime.state]);
+ useEffect(() => {
+ if (!runtime.connected) {
+ runtimeExperienceSyncRef.current = null;
+ return;
+ }
+ if (busy || (runtime.state !== "applied" && runtime.state !== "fallback")) {
+ return;
+ }
+ const signature = [
+ runtime.port ?? "connected",
+ settings.composerInteractionMode,
+ locale,
+ settings.reduceMotion ? "reduced" : "animated",
+ ].join(":");
+ if (runtimeExperienceSyncRef.current === signature) return;
+ runtimeExperienceSyncRef.current = signature;
+ const revision = Math.max(applyRevisionRef.current, runtime.revision) + 1;
+ applyRevisionRef.current = revision;
+ let active = true;
+ void updateRuntimeExperience(
+ {
+ composerInteractionMode: settings.composerInteractionMode,
+ locale,
+ reduceMotion: settings.reduceMotion,
+ },
+ revision,
+ )
+ .then((next) => {
+ if (active && revision === applyRevisionRef.current) setRuntime(next);
+ })
+ .catch((error) => {
+ if (!active) return;
+ if (runtimeExperienceSyncRef.current === signature) {
+ runtimeExperienceSyncRef.current = null;
+ }
+ /* A runtime from an older Styler build remains safe and usable. The
+ next full apply upgrades it; do not force a theme restart here. */
+ console.warn("Could not synchronize interaction preferences", error);
+ });
+ return () => {
+ active = false;
+ };
+ }, [
+ busy,
+ locale,
+ runtime.connected,
+ runtime.port,
+ runtime.revision,
+ runtime.state,
+ settings.composerInteractionMode,
+ settings.reduceMotion,
+ ]);
+
useEffect(() => {
void listCompanionProjects()
.then(setCompanionProjects)
@@ -798,6 +858,8 @@ export function App() {
variant: variantSnapshot,
runtimeStrategy: settingsSnapshot.runtimeStrategy,
reduceMotion: settingsSnapshot.reduceMotion,
+ composerInteractionMode: settingsSnapshot.composerInteractionMode,
+ locale: resolveLocale(settingsSnapshot.locale),
revision,
},
resolveAsset,
@@ -852,6 +914,8 @@ export function App() {
variant: settingsSnapshot.themeVariant,
runtimeStrategy: settingsSnapshot.runtimeStrategy,
reduceMotion: settingsSnapshot.reduceMotion,
+ composerInteractionMode: settingsSnapshot.composerInteractionMode,
+ locale: resolveLocale(settingsSnapshot.locale),
revision,
},
resolveAsset,
@@ -1781,6 +1845,11 @@ export function App() {
{ id: "home", label: "home", icon: },
{ id: "themes", label: "themes", icon: },
{ id: "companions", label: "companions", icon: },
+ {
+ id: "interactions",
+ label: "interactions",
+ icon: ,
+ },
{ id: "settings", label: "settings", icon: },
];
const activeNavigation =
@@ -2022,6 +2091,16 @@ export function App() {
/>
)}
+ {view === "interactions" && (
+
+ handleSettingsChange({ composerInteractionMode })
+ }
+ />
+ )}
+
{view === "companion-editor" && (
-
-
-
-
- {t("deleteThemeTitle")}
- {t("deleteThemeBody")}
-
- {pendingDelete.locales[locale]?.name ??
- pendingDelete.metadata.name}
-
-
- setPendingDelete(null)}
- >
- {t("cancel")}
-
-
-
- {t("deleteTheme")}
-
-
-
-
+ setPendingDelete(null)}
+ onDelete={handleDeleteTheme}
+ />
)}
{pendingNavigation && (
-
-
-
-
-
- {t("unsavedThemeTitle")}
- {t("unsavedThemeBody")}
-
- {draftTheme.locales[locale]?.name ?? draftTheme.metadata.name}
-
-
-
- {t("keepEditing")}
-
-
- {t("discardChanges")}
-
- void saveAndNavigate()}
- disabled={busy}
- >
-
- {busy ? t("saving") : t("saveAndLeave")}
-
-
-
-
+ void saveAndNavigate()}
+ />
)}
{pendingCompanionDelete && (
-
-
-
-
-
- {t("deleteCompanionTitle")}
- {t("deleteCompanionBody")}
-
- {pendingCompanionDelete.locales[locale]?.name ??
- pendingCompanionDelete.name}
-
-
- setPendingCompanionDelete(null)}
- >
- {t("cancel")}
-
- void handleDeleteCompanion()}
- >
-
- {t("deleteCompanion")}
-
-
-
-
+ setPendingCompanionDelete(null)}
+ onDelete={() => void handleDeleteCompanion()}
+ />
)}
{pendingCompanionProjectDelete && (
-
-
-
-
-
- {t("deleteDraftTitle")}
- {t("deleteDraftBody")}
- {pendingCompanionProjectDelete.name}
-
- setPendingCompanionProjectDelete(null)}
- >
- {t("cancel")}
-
- void handleDeleteCompanionProject()}
- >
-
- {t("deleteDraft")}
-
-
-
-
+ setPendingCompanionProjectDelete(null)}
+ onDelete={() => void handleDeleteCompanionProject()}
+ />
)}
{pendingApplication && (
-
-
-
-
-
- {t("quitCodexTitle")}
- {t("quitCodexBody")}
- {restartError && (
-
- {t("restartFailedDetail")}: {restartError}
-
- )}
-
- {
- setPendingApplication(null);
- setRestartError(null);
- }}
- disabled={busy}
- >
- {t("cancel")}
-
-
-
- {busy
- ? t("restartingCodex")
- : restartError
- ? t("retryRestart")
- : t("quitAndContinue")}
-
-
-
-
+ {
+ setPendingApplication(null);
+ setRestartError(null);
+ }}
+ onConfirm={handleQuitAndApply}
+ />
)}
{diagnosticsReport && (
-
-
-
- {t("diagnosticsPrivacy")}
-
- {diagnosticsReport.checks.map((check) => (
-
- {check.status}
- {check.id}
- {check.detail}
-
- ))}
-
-
- {t("previewDiagnosticText")}
-
- {diagnosticsSummary(diagnosticsReport)}
-
-
-
-
-
- {t("openCompatibilityIssue")}
-
- void exportDiagnostics(diagnosticsReport)}
- >
-
- {t("exportDiagnostics")}
-
-
-
-
+ setDiagnosticsReport(null)}
+ />
)}
{availableUpdate && (
-
-
-
-
-
-
- {t("updateAvailable")}
-
- Codex Styler {availableUpdate.version}
-
- {availableUpdate.prerelease && {t("prerelease")} }
-
- {t("updateAvailableBody")}
- {availableUpdate.releaseNotes ? (
-
-
{t("releaseNotes")}
-
- {availableUpdate.releaseNotes.summary}
-
- {availableUpdate.releaseNotes.highlights.length > 0 && (
-
- {t("releaseHighlights")}
-
- {availableUpdate.releaseNotes.highlights.map((item) => (
- {item}
- ))}
-
-
- )}
- {availableUpdate.releaseNotes.fixes.length > 0 && (
-
- {t("releaseFixes")}
-
- {availableUpdate.releaseNotes.fixes.map((item) => (
- {item}
- ))}
-
-
- )}
-
- ) : availableUpdate.notes ? (
-
-
{t("releaseNotes")}
-
{availableUpdate.notes}
-
- ) : null}
- {updateInstallStatus !== "idle" && (
-
-
-
- {updateInstallStatus === "downloading"
- ? t("downloadingUpdate")
- : updateInstallStatus === "installing"
- ? t("installingUpdate")
- : t("restartingAfterUpdate")}
-
- {updateProgress !== null &&
- updateInstallStatus === "downloading" && (
- {updateProgress}%
- )}
-
-
-
-
-
- )}
-
- {
- updateSettings({
- skippedUpdateVersion: availableUpdate.version,
- });
- setAvailableUpdate(null);
- }}
- >
- {t("skipThisVersion")}
-
- setAvailableUpdate(null)}
- >
- {t("remindMeLater")}
-
- void handleDownloadAndInstallUpdate()}
- >
-
- {t("downloadAndInstall")}
-
-
-
-
+ {
+ updateSettings({
+ skippedUpdateVersion: availableUpdate.version,
+ });
+ setAvailableUpdate(null);
+ }}
+ onLater={() => setAvailableUpdate(null)}
+ onInstall={() => void handleDownloadAndInstallUpdate()}
+ />
)}
{toast && (
@@ -2613,141 +2406,3 @@ export function App() {
);
}
-
-interface SharedViewProps {
- locale: Locale;
- t: (key: MessageKey) => string;
- resolveAsset: (theme: ThemeDefinition, path: string) => string;
-}
-
-function NewThemeDialog({
- step,
- themes,
- locale,
- t,
- onClose,
- onChooseStep,
- onBlank,
- onImage,
- onExisting,
- resolveAsset,
-}: SharedViewProps & {
- step: NewThemeStep;
- themes: ThemeDefinition[];
- onClose: () => void;
- onChooseStep: (step: NewThemeStep) => void;
- onBlank: () => void;
- onImage: () => void;
- onExisting: (theme: ThemeDefinition) => void;
-}) {
- return (
-
-
-
-
- {step === "choose" ? (
-
-
-
-
-
-
- {t("recommended")}
- {t("createFromImage")}
- {t("createFromImageLong")}
-
-
-
-
-
-
-
-
- {t("cleanStart")}
- {t("startBlank")}
- {t("startBlankDetail")}
-
-
-
-
onChooseStep("existing")}
- >
-
-
-
-
- {t("optionalStartingPoint")}
- {t("useExistingTheme")}
- {t("useExistingThemeDetail")}
-
-
-
-
- ) : (
-
- {themes.map((theme) => {
- const copy = theme.locales[locale] ?? theme.locales.en;
- const preview = theme.metadata.preview
- ? resolveAsset(theme, theme.metadata.preview)
- : undefined;
- return (
- onExisting(theme)}>
-
-
- {copy.name}
- {copy.description}
-
-
-
- );
- })}
-
- )}
-
-
- {t("newThemePrivacy")}
-
-
-
- );
-}
diff --git a/apps/desktop/src/components/PreviewWorkspace.test.tsx b/apps/desktop/src/components/PreviewWorkspace.test.tsx
index d739fad..bafeef6 100644
--- a/apps/desktop/src/components/PreviewWorkspace.test.tsx
+++ b/apps/desktop/src/components/PreviewWorkspace.test.tsx
@@ -121,6 +121,9 @@ describe("PreviewWorkspace task views", () => {
expect(preview).toHaveAttribute("data-material", "frosted");
expect(preview).toHaveAttribute("data-typography", "editorial");
expect(preview).toHaveAttribute("data-motion-character", "fluid");
+ expect(preview).toHaveStyle({
+ borderRadius: `${gildedGrandeur.variants.dark.appearance.radius}px`,
+ });
rerender(
{
expect(preview).toHaveAttribute("data-material", "frosted");
expect(preview).toHaveAttribute("data-typography", "cinematic");
expect(preview).toHaveAttribute("data-motion-character", "expressive");
+ expect(preview).toHaveStyle({
+ borderRadius: `${merryBigTop.variants.dark.appearance.radius}px`,
+ });
});
it("collapses theme motion to still when reduced motion is enabled", () => {
diff --git a/apps/desktop/src/components/PreviewWorkspace.tsx b/apps/desktop/src/components/PreviewWorkspace.tsx
index 8cdda5e..a776490 100644
--- a/apps/desktop/src/components/PreviewWorkspace.tsx
+++ b/apps/desktop/src/components/PreviewWorkspace.tsx
@@ -591,6 +591,7 @@ export function PreviewWorkspace({
"--preview-radius": officialPreview
? "10px"
: visual.appearance.radius + "px",
+ borderRadius: officialPreview ? "10px" : visual.appearance.radius + "px",
"--preview-focus-blur": officialPreview
? "0px"
: visual.appearance.focusBlur + "px",
diff --git a/apps/desktop/src/components/preview/TaskActivityPreview.test.tsx b/apps/desktop/src/components/preview/TaskActivityPreview.test.tsx
index 5e9675b..c1c53ba 100644
--- a/apps/desktop/src/components/preview/TaskActivityPreview.test.tsx
+++ b/apps/desktop/src/components/preview/TaskActivityPreview.test.tsx
@@ -8,7 +8,7 @@ describe("TaskActivityPreview", () => {
expect(screen.getByText("Implementation plan")).toBeInTheDocument();
expect(screen.getByText("Run checks")).toBeInTheDocument();
- expect(screen.getByText("182 tests passed")).toBeInTheDocument();
+ expect(screen.getByText("All checks passed")).toBeInTheDocument();
expect(screen.getByText("3 files changed")).toBeInTheDocument();
expect(
container.querySelector('[data-message-author-role="user"]'),
diff --git a/apps/desktop/src/components/preview/TaskActivityPreview.tsx b/apps/desktop/src/components/preview/TaskActivityPreview.tsx
index c0a12e6..e731fa4 100644
--- a/apps/desktop/src/components/preview/TaskActivityPreview.tsx
+++ b/apps/desktop/src/components/preview/TaskActivityPreview.tsx
@@ -56,7 +56,7 @@ export function TaskActivityPreview({ isChinese }: TaskActivityPreviewProps) {
pnpm --filter @codex-styler/desktop test
- {isChinese ? "182 项测试通过" : "182 tests passed"}
+ {isChinese ? "全部检查通过" : "All checks passed"}
diff --git a/apps/desktop/src/components/preview/TerminalActivityPreview.tsx b/apps/desktop/src/components/preview/TerminalActivityPreview.tsx
index 4a0beae..ddc0f58 100644
--- a/apps/desktop/src/components/preview/TerminalActivityPreview.tsx
+++ b/apps/desktop/src/components/preview/TerminalActivityPreview.tsx
@@ -31,7 +31,7 @@ export function TerminalActivityPreview({
$ pnpm test
- ✓ desktop 182 tests
+ ✓ desktop all checks passed
✓ theme-core 21 tests
diff --git a/apps/desktop/src/features/app-shell/AppDialogs.test.tsx b/apps/desktop/src/features/app-shell/AppDialogs.test.tsx
new file mode 100644
index 0000000..e7580c4
--- /dev/null
+++ b/apps/desktop/src/features/app-shell/AppDialogs.test.tsx
@@ -0,0 +1,68 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { translate, type MessageKey } from "../../lib/i18n";
+import { RestartCodexDialog, UpdateDialog } from "./AppDialogs";
+
+const t = (key: MessageKey) => translate("en", key);
+
+describe("application dialogs", () => {
+ afterEach(cleanup);
+
+ it("keeps restart recovery explicit when a previous attempt failed", () => {
+ const onConfirm = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "The managed process did not exit",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Retry restart" }));
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("shows localized release notes before allowing an update install", () => {
+ const onInstall = vi.fn();
+ const onSkip = vi.fn();
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText("A smaller and safer desktop update."),
+ ).toBeVisible();
+ expect(screen.getByText("Reliable restart recovery")).toBeVisible();
+
+ fireEvent.click(screen.getByRole("button", { name: "Skip this version" }));
+ fireEvent.click(
+ screen.getByRole("button", { name: "Download and install" }),
+ );
+ expect(onSkip).toHaveBeenCalledOnce();
+ expect(onInstall).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/desktop/src/features/app-shell/AppDialogs.tsx b/apps/desktop/src/features/app-shell/AppDialogs.tsx
new file mode 100644
index 0000000..3338929
--- /dev/null
+++ b/apps/desktop/src/features/app-shell/AppDialogs.tsx
@@ -0,0 +1,446 @@
+import type {
+ CompanionDefinition,
+ ThemeDefinition,
+} from "@codex-styler/theme-core";
+import {
+ ChevronRight,
+ Download,
+ RefreshCw,
+ Save,
+ ShieldCheck,
+ Trash2,
+ X,
+} from "lucide-react";
+import type { CompanionCreatorProject } from "../companion-creator/model";
+import {
+ diagnosticsSummary,
+ exportDiagnostics,
+ openWindowsCompatibilityIssue,
+ type DiagnosticsReport,
+} from "../../lib/diagnostics";
+import type { Locale, MessageKey } from "../../lib/i18n";
+import type { AvailableUpdate } from "../../lib/runtime";
+
+type Translate = (key: MessageKey) => string;
+
+export function DeleteThemeDialog({
+ theme,
+ locale,
+ t,
+ onCancel,
+ onDelete,
+}: {
+ theme: ThemeDefinition;
+ locale: Locale;
+ t: Translate;
+ onCancel: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
+
+
+
+ {t("deleteThemeTitle")}
+ {t("deleteThemeBody")}
+ {theme.locales[locale]?.name ?? theme.metadata.name}
+
+
+ {t("cancel")}
+
+
+
+ {t("deleteTheme")}
+
+
+
+
+ );
+}
+
+export function UnsavedThemeDialog({
+ theme,
+ locale,
+ t,
+ busy,
+ onCancel,
+ onDiscard,
+ onSave,
+}: {
+ theme: ThemeDefinition;
+ locale: Locale;
+ t: Translate;
+ busy: boolean;
+ onCancel: () => void;
+ onDiscard: () => void;
+ onSave: () => void;
+}) {
+ return (
+
+
+
+
+
+ {t("unsavedThemeTitle")}
+ {t("unsavedThemeBody")}
+ {theme.locales[locale]?.name ?? theme.metadata.name}
+
+
+ {t("keepEditing")}
+
+
+ {t("discardChanges")}
+
+
+
+ {busy ? t("saving") : t("saveAndLeave")}
+
+
+
+
+ );
+}
+
+export function DeleteCompanionDialog({
+ companion,
+ locale,
+ t,
+ onCancel,
+ onDelete,
+}: {
+ companion: CompanionDefinition;
+ locale: Locale;
+ t: Translate;
+ onCancel: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
+
+
+
+ {t("deleteCompanionTitle")}
+ {t("deleteCompanionBody")}
+ {companion.locales[locale]?.name ?? companion.name}
+
+
+ {t("cancel")}
+
+
+
+ {t("deleteCompanion")}
+
+
+
+
+ );
+}
+
+export function DeleteCompanionProjectDialog({
+ project,
+ t,
+ onCancel,
+ onDelete,
+}: {
+ project: CompanionCreatorProject;
+ t: Translate;
+ onCancel: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
+
+
+
+ {t("deleteDraftTitle")}
+ {t("deleteDraftBody")}
+ {project.name}
+
+
+ {t("cancel")}
+
+
+
+ {t("deleteDraft")}
+
+
+
+
+ );
+}
+
+export function RestartCodexDialog({
+ t,
+ busy,
+ error,
+ onCancel,
+ onConfirm,
+}: {
+ t: Translate;
+ busy: boolean;
+ error: string | null;
+ onCancel: () => void;
+ onConfirm: () => void;
+}) {
+ return (
+
+
+
+
+
+ {t("quitCodexTitle")}
+ {t("quitCodexBody")}
+ {error && (
+
+ {t("restartFailedDetail")}: {error}
+
+ )}
+
+
+ {t("cancel")}
+
+
+
+ {busy
+ ? t("restartingCodex")
+ : error
+ ? t("retryRestart")
+ : t("quitAndContinue")}
+
+
+
+
+ );
+}
+
+export function DiagnosticsDialog({
+ report,
+ t,
+ onClose,
+}: {
+ report: DiagnosticsReport;
+ t: Translate;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+ {t("diagnosticsPrivacy")}
+
+ {report.checks.map((check) => (
+
+ {check.status}
+ {check.id}
+ {check.detail}
+
+ ))}
+
+
+ {t("previewDiagnosticText")}
+ {diagnosticsSummary(report)}
+
+
+
+
+ {t("openCompatibilityIssue")}
+
+ void exportDiagnostics(report)}
+ >
+
+ {t("exportDiagnostics")}
+
+
+
+
+ );
+}
+
+export type UpdateInstallStatus =
+ "idle" | "downloading" | "installing" | "restarting";
+
+export function UpdateDialog({
+ update,
+ installStatus,
+ progress,
+ t,
+ onSkip,
+ onLater,
+ onInstall,
+}: {
+ update: AvailableUpdate;
+ installStatus: UpdateInstallStatus;
+ progress: number | null;
+ t: Translate;
+ onSkip: () => void;
+ onLater: () => void;
+ onInstall: () => void;
+}) {
+ const isIdle = installStatus === "idle";
+ return (
+
+
+
+
+
+
+ {t("updateAvailable")}
+
Codex Styler {update.version}
+ {update.prerelease && {t("prerelease")} }
+
+ {t("updateAvailableBody")}
+ {update.releaseNotes ? (
+
+
{t("releaseNotes")}
+
+ {update.releaseNotes.summary}
+
+ {update.releaseNotes.highlights.length > 0 && (
+
+ {t("releaseHighlights")}
+
+ {update.releaseNotes.highlights.map((item) => (
+ {item}
+ ))}
+
+
+ )}
+ {update.releaseNotes.fixes.length > 0 && (
+
+ {t("releaseFixes")}
+
+ {update.releaseNotes.fixes.map((item) => (
+ {item}
+ ))}
+
+
+ )}
+
+ ) : update.notes ? (
+
+
{t("releaseNotes")}
+
{update.notes}
+
+ ) : null}
+ {!isIdle && (
+
+
+
+ {installStatus === "downloading"
+ ? t("downloadingUpdate")
+ : installStatus === "installing"
+ ? t("installingUpdate")
+ : t("restartingAfterUpdate")}
+
+ {progress !== null && installStatus === "downloading" && (
+ {progress}%
+ )}
+
+
+
+
+
+ )}
+
+
+ {t("skipThisVersion")}
+
+
+ {t("remindMeLater")}
+
+
+
+ {t("downloadAndInstall")}
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/features/companion-creator/CompanionCreator.tsx b/apps/desktop/src/features/companion-creator/CompanionCreator.tsx
index a642b57..26a7788 100644
--- a/apps/desktop/src/features/companion-creator/CompanionCreator.tsx
+++ b/apps/desktop/src/features/companion-creator/CompanionCreator.tsx
@@ -53,7 +53,6 @@ import {
nearestDirectionFrame,
normalizeAngle,
pointerAngle,
- suggestSharedAlignment,
type DirectionCalibration,
} from "./calibration";
import { BehaviorAudition } from "./BehaviorAudition";
@@ -62,11 +61,8 @@ import { DirectionDial } from "./DirectionDial";
import {
alphaBounds,
cleanFrame,
- extractImageFrames,
- extractVideoFrames,
measureVisualDeltas,
sampleFrameColor,
- sliceAtlas,
validateCreatorSourceFiles,
type ExtractedFrame,
} from "./media";
@@ -79,7 +75,6 @@ import {
normalizeCompanionProject,
resetCompanionProjectDerivedState,
suggestedCompanionName,
- type AtlasSliceSettings,
type CompanionCreatorProject,
type CompanionImportKind,
type CreatorStep,
@@ -100,7 +95,6 @@ import {
} from "./motions";
import {
cacheCompanionProjectFrames,
- loadCompanionProjectFrames,
loadCompanionProjectSource,
saveCompanionProject,
} from "./project-files";
@@ -122,6 +116,21 @@ import {
resetEdgeReview,
summarizeEdgeReview,
} from "./quality-review";
+import {
+ applySuggestedSharedAlignment,
+ atlasFieldLabel,
+ clamp,
+ cleanupIsNoop,
+ cleanupSettingsSignature,
+ defaultAtlas,
+ extractProjectFrames,
+ extractionActionLabel,
+ formatFileSize,
+ projectStateSignature,
+ restoreCachedFrames,
+ sharedCanvasDimensions,
+ sourceDescriptor,
+} from "./creator-helpers";
import {
CleanupBrushStage,
DirectionTimeline,
@@ -522,212 +531,6 @@ type CreatorConfirmAction =
}
| { kind: "change-source"; sourceKind: CompanionImportKind };
-function cleanupSettingsSignature(
- cleanup: CompanionCreatorProject["cleanup"],
-): string {
- return JSON.stringify(cleanup);
-}
-
-function cleanupIsNoop(cleanup: CompanionCreatorProject["cleanup"]): boolean {
- return (
- cleanup.mode === "preserve-alpha" &&
- cleanup.cornerMasks.length === 0 &&
- cleanup.strokes.length === 0
- );
-}
-
-function projectStateSignature(project: CompanionCreatorProject): string {
- const snapshot = structuredClone(project);
- snapshot.updatedAt = "";
- return JSON.stringify(snapshot);
-}
-
-function clamp(value: number, minimum: number, maximum: number): number {
- return Math.min(maximum, Math.max(minimum, value));
-}
-
-function defaultAtlas() {
- return {
- columns: 4,
- rows: 4,
- cellWidth: 512,
- cellHeight: 512,
- marginX: 0,
- marginY: 0,
- gapX: 0,
- gapY: 0,
- order: "row-major" as const,
- page: 0,
- };
-}
-
-function sourceDescriptor(kind: CompanionImportKind, files: File[]) {
- return {
- kind,
- files: files.map((file) => ({
- name: file.name,
- type: file.type,
- size: file.size,
- lastModified: file.lastModified,
- })),
- videoRange: kind === "video" ? { startMs: 0, endMs: 30_000 } : undefined,
- extractionFps: kind === "video" ? 12 : undefined,
- atlas: kind === "atlas" ? defaultAtlas() : undefined,
- };
-}
-
-function formatFileSize(bytes: number): string {
- if (bytes <= 0) return "0 B";
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
- return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
-}
-
-function extractionActionLabel(
- kind: CompanionImportKind | undefined,
- locale: "en" | "zh-CN",
-): string {
- const labels = {
- image: locale === "zh-CN" ? "准备伙伴帧" : "Prepare companion frame",
- sequence: locale === "zh-CN" ? "导入图片序列" : "Import image sequence",
- video: locale === "zh-CN" ? "抽取视频帧" : "Extract video frames",
- atlas: locale === "zh-CN" ? "切割序列图集" : "Slice sprite atlas",
- } as const;
- return kind
- ? labels[kind]
- : locale === "zh-CN"
- ? "生成帧"
- : "Generate frames";
-}
-
-function atlasFieldLabel(
- field:
- | "columns"
- | "rows"
- | "cellWidth"
- | "cellHeight"
- | "marginX"
- | "marginY"
- | "gapX"
- | "gapY",
- locale: "en" | "zh-CN",
-): string {
- const labels = {
- columns: locale === "zh-CN" ? "列数" : "Columns",
- rows: locale === "zh-CN" ? "行数" : "Rows",
- cellWidth: locale === "zh-CN" ? "单格宽度" : "Cell width",
- cellHeight: locale === "zh-CN" ? "单格高度" : "Cell height",
- marginX: locale === "zh-CN" ? "水平外边距" : "Horizontal margin",
- marginY: locale === "zh-CN" ? "垂直外边距" : "Vertical margin",
- gapX: locale === "zh-CN" ? "水平间距" : "Column gap",
- gapY: locale === "zh-CN" ? "垂直间距" : "Row gap",
- } as const;
- return labels[field];
-}
-
-async function extractProjectFrames(
- project: CompanionCreatorProject,
- files: File[],
- onProgress?: (progress: number) => void,
-): Promise {
- if (!project.source || files.length === 0) return [];
- if (project.source.kind === "video") {
- const range = project.source.videoRange ?? { startMs: 0, endMs: 30_000 };
- return extractVideoFrames(
- files[0]!,
- { ...range, fps: project.source.extractionFps ?? 12 },
- onProgress,
- );
- }
- if (project.source.kind === "atlas") {
- return sliceAtlas(files[0]!, project.source.atlas ?? defaultAtlas());
- }
- return extractImageFrames(files);
-}
-
-async function restoreCachedFrames(
- project: CompanionCreatorProject,
- options?: {
- signal?: AbortSignal;
- onProgress?: (completed: number, total: number) => void;
- },
-): Promise {
- if (
- project.frames.length === 0 ||
- project.frames.some((frame) => !frame.storedPath)
- ) {
- return null;
- }
- const blobs = await loadCompanionProjectFrames(
- project.id,
- project.frames.map((frame) => frame.storedPath!),
- );
- if (blobs.some((blob) => blob === null)) return null;
-
- const restored = new Array(project.frames.length);
- let cursor = 0;
- let completed = 0;
- try {
- await Promise.all(
- Array.from({ length: Math.min(2, project.frames.length) }, async () => {
- while (cursor < project.frames.length) {
- if (options?.signal) throwIfRestoreCancelled(options.signal);
- const index = cursor;
- cursor += 1;
- const logical = project.frames[index]!;
- const blob = blobs[index]!;
- const bitmap = await createImageBitmap(blob);
- restored[index] = {
- id: logical.id,
- sourceIndex: logical.sourceIndex,
- sourceTimeMs: logical.sourceTimeMs,
- blob,
- url: URL.createObjectURL(blob),
- width: bitmap.width,
- height: bitmap.height,
- };
- bitmap.close();
- completed += 1;
- options?.onProgress?.(completed, project.frames.length);
- }
- }),
- );
- } catch (reason) {
- for (const frame of restored) {
- if (frame) URL.revokeObjectURL(frame.url);
- }
- throw reason;
- }
- return restored;
-}
-
-function sharedCanvasDimensions(frames: ExtractedFrame[]) {
- if (frames.length === 0) return undefined;
- return {
- width: Math.min(...frames.map((frame) => frame.width)),
- height: Math.min(...frames.map((frame) => frame.height)),
- };
-}
-
-function applySuggestedSharedAlignment(
- project: CompanionCreatorProject,
- extractedFrames: ExtractedFrame[],
-): boolean {
- const suggestion = suggestSharedAlignment(
- project.frames,
- 8,
- sharedCanvasDimensions(extractedFrames),
- );
- if (!suggestion) return false;
- project.frames.forEach((frame, index) => {
- const offset = suggestion.offsets[index];
- if (offset) frame.baselineOffset = offset;
- });
- project.sharedCrop = suggestion.crop;
- project.groundLine = suggestion.groundLine;
- return true;
-}
-
export function CompanionCreator({
locale,
initialProject,
diff --git a/apps/desktop/src/features/companion-creator/compiler.ts b/apps/desktop/src/features/companion-creator/compiler.ts
index 05dfff0..27c8200 100644
--- a/apps/desktop/src/features/companion-creator/compiler.ts
+++ b/apps/desktop/src/features/companion-creator/compiler.ts
@@ -301,7 +301,7 @@ export async function compileCompanion(
const definition: CompanionPackageDefinition = {
format: COMPANION_FORMAT,
id,
- version: "0.2.0-beta.7",
+ version: "0.2.0-beta.8",
metadata: {
name: project.name,
description: project.description.trim(),
@@ -479,7 +479,7 @@ export async function compileCompanion(
const definition: CompanionPackageDefinition = {
format: COMPANION_FORMAT,
id,
- version: "0.2.0-beta.7",
+ version: "0.2.0-beta.8",
metadata: {
name: project.name,
description: project.description.trim(),
diff --git a/apps/desktop/src/features/companion-creator/creator-helpers.test.ts b/apps/desktop/src/features/companion-creator/creator-helpers.test.ts
new file mode 100644
index 0000000..16e42f0
--- /dev/null
+++ b/apps/desktop/src/features/companion-creator/creator-helpers.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+import { createCompanionProject } from "./model";
+import {
+ clamp,
+ cleanupIsNoop,
+ defaultAtlas,
+ extractionActionLabel,
+ formatFileSize,
+ projectStateSignature,
+ sharedCanvasDimensions,
+ sourceDescriptor,
+} from "./creator-helpers";
+
+describe("companion creator helpers", () => {
+ it("builds predictable source defaults without mutating imported files", () => {
+ const file = new File(["video"], "turn.mp4", { type: "video/mp4" });
+
+ expect(sourceDescriptor("video", [file])).toMatchObject({
+ kind: "video",
+ files: [{ name: "turn.mp4", type: "video/mp4", size: 5 }],
+ videoRange: { startMs: 0, endMs: 30_000 },
+ extractionFps: 12,
+ });
+ expect(sourceDescriptor("atlas", [file]).atlas).toEqual(defaultAtlas());
+ });
+
+ it("keeps draft comparisons stable across autosave timestamps", () => {
+ const before = createCompanionProject("stable-signature");
+ const after = structuredClone(before);
+ after.updatedAt = new Date(Date.now() + 10_000).toISOString();
+
+ expect(projectStateSignature(after)).toBe(projectStateSignature(before));
+
+ after.name = "Edited companion";
+ expect(projectStateSignature(after)).not.toBe(
+ projectStateSignature(before),
+ );
+ });
+
+ it("recognizes no-op cleanup and reports compact user-facing values", () => {
+ const project = createCompanionProject("cleanup-state");
+ expect(cleanupIsNoop(project.cleanup)).toBe(true);
+
+ project.cleanup.cornerMasks.push({
+ corner: "top-right",
+ width: 12,
+ height: 12,
+ });
+ expect(cleanupIsNoop(project.cleanup)).toBe(false);
+ expect(formatFileSize(0)).toBe("0 B");
+ expect(formatFileSize(1536)).toBe("1.5 KiB");
+ expect(formatFileSize(2 * 1024 * 1024)).toBe("2.0 MiB");
+ expect(extractionActionLabel("video", "zh-CN")).toBe("抽取视频帧");
+ expect(clamp(12, 0, 10)).toBe(10);
+ });
+
+ it("derives a shared canvas from the smallest source dimensions", () => {
+ expect(sharedCanvasDimensions([])).toBeUndefined();
+ expect(
+ sharedCanvasDimensions([
+ {
+ id: "frame-a",
+ sourceIndex: 0,
+ blob: new Blob(),
+ url: "blob:a",
+ width: 640,
+ height: 480,
+ },
+ {
+ id: "frame-b",
+ sourceIndex: 1,
+ blob: new Blob(),
+ url: "blob:b",
+ width: 512,
+ height: 720,
+ },
+ ]),
+ ).toEqual({ width: 512, height: 480 });
+ });
+});
diff --git a/apps/desktop/src/features/companion-creator/creator-helpers.ts b/apps/desktop/src/features/companion-creator/creator-helpers.ts
new file mode 100644
index 0000000..d0e7769
--- /dev/null
+++ b/apps/desktop/src/features/companion-creator/creator-helpers.ts
@@ -0,0 +1,220 @@
+import { suggestSharedAlignment } from "./calibration";
+import {
+ extractImageFrames,
+ extractVideoFrames,
+ sliceAtlas,
+ type ExtractedFrame,
+} from "./media";
+import type { CompanionCreatorProject, CompanionImportKind } from "./model";
+import { loadCompanionProjectFrames } from "./project-files";
+import { throwIfRestoreCancelled } from "./restore-flow";
+
+export function cleanupSettingsSignature(
+ cleanup: CompanionCreatorProject["cleanup"],
+): string {
+ return JSON.stringify(cleanup);
+}
+
+export function cleanupIsNoop(
+ cleanup: CompanionCreatorProject["cleanup"],
+): boolean {
+ return (
+ cleanup.mode === "preserve-alpha" &&
+ cleanup.cornerMasks.length === 0 &&
+ cleanup.strokes.length === 0
+ );
+}
+
+export function projectStateSignature(
+ project: CompanionCreatorProject,
+): string {
+ const snapshot = structuredClone(project);
+ snapshot.updatedAt = "";
+ return JSON.stringify(snapshot);
+}
+
+export function clamp(value: number, minimum: number, maximum: number): number {
+ return Math.min(maximum, Math.max(minimum, value));
+}
+
+export function defaultAtlas() {
+ return {
+ columns: 4,
+ rows: 4,
+ cellWidth: 512,
+ cellHeight: 512,
+ marginX: 0,
+ marginY: 0,
+ gapX: 0,
+ gapY: 0,
+ order: "row-major" as const,
+ page: 0,
+ };
+}
+
+export function sourceDescriptor(kind: CompanionImportKind, files: File[]) {
+ return {
+ kind,
+ files: files.map((file) => ({
+ name: file.name,
+ type: file.type,
+ size: file.size,
+ lastModified: file.lastModified,
+ })),
+ videoRange: kind === "video" ? { startMs: 0, endMs: 30_000 } : undefined,
+ extractionFps: kind === "video" ? 12 : undefined,
+ atlas: kind === "atlas" ? defaultAtlas() : undefined,
+ };
+}
+
+export function formatFileSize(bytes: number): string {
+ if (bytes <= 0) return "0 B";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
+}
+
+export function extractionActionLabel(
+ kind: CompanionImportKind | undefined,
+ locale: "en" | "zh-CN",
+): string {
+ const labels = {
+ image: locale === "zh-CN" ? "准备伙伴帧" : "Prepare companion frame",
+ sequence: locale === "zh-CN" ? "导入图片序列" : "Import image sequence",
+ video: locale === "zh-CN" ? "抽取视频帧" : "Extract video frames",
+ atlas: locale === "zh-CN" ? "切割序列图集" : "Slice sprite atlas",
+ } as const;
+ return kind
+ ? labels[kind]
+ : locale === "zh-CN"
+ ? "生成帧"
+ : "Generate frames";
+}
+
+export function atlasFieldLabel(
+ field:
+ | "columns"
+ | "rows"
+ | "cellWidth"
+ | "cellHeight"
+ | "marginX"
+ | "marginY"
+ | "gapX"
+ | "gapY",
+ locale: "en" | "zh-CN",
+): string {
+ const labels = {
+ columns: locale === "zh-CN" ? "列数" : "Columns",
+ rows: locale === "zh-CN" ? "行数" : "Rows",
+ cellWidth: locale === "zh-CN" ? "单格宽度" : "Cell width",
+ cellHeight: locale === "zh-CN" ? "单格高度" : "Cell height",
+ marginX: locale === "zh-CN" ? "水平外边距" : "Horizontal margin",
+ marginY: locale === "zh-CN" ? "垂直外边距" : "Vertical margin",
+ gapX: locale === "zh-CN" ? "水平间距" : "Column gap",
+ gapY: locale === "zh-CN" ? "垂直间距" : "Row gap",
+ } as const;
+ return labels[field];
+}
+
+export async function extractProjectFrames(
+ project: CompanionCreatorProject,
+ files: File[],
+ onProgress?: (progress: number) => void,
+): Promise {
+ if (!project.source || files.length === 0) return [];
+ if (project.source.kind === "video") {
+ const range = project.source.videoRange ?? { startMs: 0, endMs: 30_000 };
+ return extractVideoFrames(
+ files[0]!,
+ { ...range, fps: project.source.extractionFps ?? 12 },
+ onProgress,
+ );
+ }
+ if (project.source.kind === "atlas") {
+ return sliceAtlas(files[0]!, project.source.atlas ?? defaultAtlas());
+ }
+ return extractImageFrames(files);
+}
+
+export async function restoreCachedFrames(
+ project: CompanionCreatorProject,
+ options?: {
+ signal?: AbortSignal;
+ onProgress?: (completed: number, total: number) => void;
+ },
+): Promise {
+ if (
+ project.frames.length === 0 ||
+ project.frames.some((frame) => !frame.storedPath)
+ ) {
+ return null;
+ }
+ const blobs = await loadCompanionProjectFrames(
+ project.id,
+ project.frames.map((frame) => frame.storedPath!),
+ );
+ if (blobs.some((blob) => blob === null)) return null;
+
+ const restored = new Array(project.frames.length);
+ let cursor = 0;
+ let completed = 0;
+ try {
+ await Promise.all(
+ Array.from({ length: Math.min(2, project.frames.length) }, async () => {
+ while (cursor < project.frames.length) {
+ if (options?.signal) throwIfRestoreCancelled(options.signal);
+ const index = cursor;
+ cursor += 1;
+ const logical = project.frames[index]!;
+ const blob = blobs[index]!;
+ const bitmap = await createImageBitmap(blob);
+ restored[index] = {
+ id: logical.id,
+ sourceIndex: logical.sourceIndex,
+ sourceTimeMs: logical.sourceTimeMs,
+ blob,
+ url: URL.createObjectURL(blob),
+ width: bitmap.width,
+ height: bitmap.height,
+ };
+ bitmap.close();
+ completed += 1;
+ options?.onProgress?.(completed, project.frames.length);
+ }
+ }),
+ );
+ } catch (reason) {
+ for (const frame of restored) {
+ if (frame) URL.revokeObjectURL(frame.url);
+ }
+ throw reason;
+ }
+ return restored;
+}
+
+export function sharedCanvasDimensions(frames: ExtractedFrame[]) {
+ if (frames.length === 0) return undefined;
+ return {
+ width: Math.min(...frames.map((frame) => frame.width)),
+ height: Math.min(...frames.map((frame) => frame.height)),
+ };
+}
+
+export function applySuggestedSharedAlignment(
+ project: CompanionCreatorProject,
+ extractedFrames: ExtractedFrame[],
+): boolean {
+ const suggestion = suggestSharedAlignment(
+ project.frames,
+ 8,
+ sharedCanvasDimensions(extractedFrames),
+ );
+ if (!suggestion) return false;
+ project.frames.forEach((frame, index) => {
+ const offset = suggestion.offsets[index];
+ if (offset) frame.baselineOffset = offset;
+ });
+ project.sharedCrop = suggestion.crop;
+ project.groundLine = suggestion.groundLine;
+ return true;
+}
diff --git a/apps/desktop/src/features/companions/CompanionsView.tsx b/apps/desktop/src/features/companions/CompanionsView.tsx
index 84d8232..e22620c 100644
--- a/apps/desktop/src/features/companions/CompanionsView.tsx
+++ b/apps/desktop/src/features/companions/CompanionsView.tsx
@@ -38,6 +38,7 @@ import type {
SelectableCompanionPlacementMode,
} from "../../lib/companion-placement-modes";
import { matchesResourceSearch } from "../../lib/resource-search";
+import { useCompactDetailFlow } from "../../lib/use-compact-detail-flow";
export type CompanionCollection = "builtIn" | "mine";
@@ -138,36 +139,27 @@ export function CompanionsView({
isLive,
busy,
}: CompanionsViewProps) {
- const [compactDetailOpen, setCompactDetailOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showAllProjects, setShowAllProjects] = useState(false);
const [browsedCompanionId, setBrowsedCompanionId] = useState(
selected?.id ?? null,
);
- const layoutRef = useRef(null);
const companionListRef = useRef(null);
- useEffect(() => {
- if (!compactDetailOpen) return;
- const element = layoutRef.current?.closest(
- ".app-main__viewport",
- );
- if (!element) return;
- if (typeof element.scrollTo === "function") {
- element.scrollTo({
- top: 0,
- behavior: reduceMotion ? "auto" : "smooth",
- });
- } else {
- element.scrollTop = 0;
- }
- }, [compactDetailOpen, reduceMotion]);
+ const {
+ closeDetail,
+ containerRef: layoutRef,
+ detailOpen: compactDetailOpen,
+ onKeyDown: onLayoutKeyDown,
+ openDetail,
+ } = useCompactDetailFlow(reduceMotion);
const companions =
collection === "builtIn" ? builtinCompanions : localCompanions;
const filteredCompanions = companions.filter((item) => {
- const copy = item.locales[locale] ?? item.locales.en ?? {
- name: item.name,
- description: item.description,
- };
+ const copy = item.locales[locale] ??
+ item.locales.en ?? {
+ name: item.name,
+ description: item.description,
+ };
return matchesResourceSearch(searchQuery, [
copy.name,
copy.description,
@@ -265,7 +257,7 @@ export function CompanionsView({
activeTab={collection}
onTabChange={(nextCollection) => {
setSearchQuery("");
- setCompactDetailOpen(false);
+ closeDetail({ restoreFocus: false });
onCollectionChange(nextCollection);
}}
search={
@@ -360,16 +352,14 @@ export function CompanionsView({
- setCompactDetailOpen(false)}
- >
+ closeDetail()}>
{t("backToCompanions")}
@@ -541,10 +531,10 @@ export function CompanionsView({
"companion-option" +
(!selected ? " companion-option--active" : "")
}
- onClick={() => {
+ onClick={(event) => {
setBrowsedCompanionId(null);
onSelect(null);
- setCompactDetailOpen(true);
+ openDetail(event.currentTarget);
}}
aria-pressed={!selected}
>
@@ -652,10 +642,10 @@ export function CompanionsView({
"companion-option" +
(active ? " companion-option--active" : "")
}
- onClick={() => {
+ onClick={(event) => {
setBrowsedCompanionId(item.id);
onSelect(item);
- setCompactDetailOpen(true);
+ openDetail(event.currentTarget);
}}
aria-pressed={active}
>
diff --git a/apps/desktop/src/features/interactions/InteractionsView.test.tsx b/apps/desktop/src/features/interactions/InteractionsView.test.tsx
new file mode 100644
index 0000000..ada67ec
--- /dev/null
+++ b/apps/desktop/src/features/interactions/InteractionsView.test.tsx
@@ -0,0 +1,48 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { translate } from "../../lib/i18n";
+import { InteractionsView } from "./InteractionsView";
+
+describe("InteractionsView", () => {
+ afterEach(cleanup);
+
+ it("supports roving keyboard selection without adding duplicate actions", () => {
+ const onChange = vi.fn();
+ render(
+ translate("en", key)}
+ onChange={onChange}
+ />,
+ );
+
+ const official = screen.getByRole("option", {
+ name: /Use the official control/,
+ });
+ official.focus();
+ fireEvent.keyDown(official, { key: "ArrowDown" });
+
+ expect(onChange).toHaveBeenCalledWith("marbles");
+ expect(screen.getByRole("option", { name: /Triple Drop/ })).toHaveFocus();
+ });
+
+ it("explains that every game result controls the complete Codex loadout", () => {
+ render(
+ translate("en", key)}
+ onChange={vi.fn()}
+ />,
+ );
+
+ const binding = screen.getByRole("region", {
+ name: "Every play returns one complete loadout",
+ });
+ expect(binding).toHaveTextContent("Model engine");
+ expect(binding).toHaveTextContent("Reasoning effort");
+ expect(binding).toHaveTextContent("Response speed");
+ expect(binding).toHaveTextContent(
+ "The action itself selects all three real settings",
+ );
+ });
+});
diff --git a/apps/desktop/src/features/interactions/InteractionsView.tsx b/apps/desktop/src/features/interactions/InteractionsView.tsx
new file mode 100644
index 0000000..bf22aee
--- /dev/null
+++ b/apps/desktop/src/features/interactions/InteractionsView.tsx
@@ -0,0 +1,219 @@
+import {
+ BrainCircuit,
+ Blocks,
+ CircleOff,
+ Cpu,
+ Gauge,
+ Gamepad2,
+ Orbit,
+ Route,
+ ScanSearch,
+} from "lucide-react";
+import type { ReactNode } from "react";
+import type { KeyboardEvent } from "react";
+import type { MessageKey } from "../../lib/i18n";
+import type { ComposerInteractionMode } from "../../lib/composer-interactions";
+
+export interface InteractionsViewProps {
+ mode: ComposerInteractionMode;
+ t: (key: MessageKey) => string;
+ onChange: (mode: ComposerInteractionMode) => void;
+}
+
+const modes: Array<{
+ id: ComposerInteractionMode;
+ name: MessageKey;
+ description: MessageKey;
+ category: MessageKey;
+ icon: ReactNode;
+}> = [
+ {
+ id: "disabled",
+ name: "interactionDisabled",
+ description: "interactionDisabledDetail",
+ category: "interactionCategoryOfficial",
+ icon: ,
+ },
+ {
+ id: "marbles",
+ name: "interactionMarbles",
+ description: "interactionMarblesDetail",
+ category: "interactionCategoryPhysics",
+ icon: ,
+ },
+ {
+ id: "claw",
+ name: "interactionClaw",
+ description: "interactionClawDetail",
+ category: "interactionCategorySkill",
+ icon: ,
+ },
+ {
+ id: "toss",
+ name: "interactionToss",
+ description: "interactionTossDetail",
+ category: "interactionCategoryChance",
+ icon: ,
+ },
+ {
+ id: "balance",
+ name: "interactionBalance",
+ description: "interactionBalanceDetail",
+ category: "interactionCategoryTune",
+ icon: ,
+ },
+ {
+ id: "route",
+ name: "interactionRoute",
+ description: "interactionRouteDetail",
+ category: "interactionCategoryChoose",
+ icon: ,
+ },
+];
+
+function moveModeSelection(
+ event: KeyboardEvent,
+ currentIndex: number,
+ onChange: (mode: ComposerInteractionMode) => void,
+) {
+ let nextIndex: number | null = null;
+ if (event.key === "ArrowDown" || event.key === "ArrowRight") {
+ nextIndex = (currentIndex + 1) % modes.length;
+ } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
+ nextIndex = (currentIndex - 1 + modes.length) % modes.length;
+ } else if (event.key === "Home") {
+ nextIndex = 0;
+ } else if (event.key === "End") {
+ nextIndex = modes.length - 1;
+ }
+ if (nextIndex === null) return;
+
+ event.preventDefault();
+ onChange(modes[nextIndex].id);
+ const options =
+ event.currentTarget.parentElement?.querySelectorAll(
+ '[role="option"]',
+ );
+ options?.[nextIndex]?.focus();
+}
+
+export function InteractionsView({ mode, t, onChange }: InteractionsViewProps) {
+ const selected = modes.find((item) => item.id === mode) ?? modes[0];
+
+ return (
+
+
+
+
{t("interactionKicker")}
+
{t("interactions")}
+
{t("interactionDescription")}
+
+
+
+
+
+ {modes.map((item, index) => {
+ const active = item.id === selected.id;
+ return (
+ onChange(item.id)}
+ onKeyDown={(event) => moveModeSelection(event, index, onChange)}
+ >
+ {item.icon}
+
+ {t(item.category)}
+ {t(item.name)}
+ {t(item.description)}
+
+
+ {active ? t("interactionSelected") : ""}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
{t("interactionComposerPlaceholder")}
+
+
+
+ {selected.id === "disabled"
+ ? t("interactionOfficialControl")
+ : t(selected.name)}
+
+
+
+
+ {t("interactionNativePlacement")}
+ {t("interactionNativePlacementDetail")}
+
+
+
+ {selected.id !== "disabled" ? (
+
+
+ {t("interactionBindingTitle")}
+ {t("interactionBindingDetail")}
+
+
+
+
+ {t("interactionFieldModel")}
+
+
+
+ {t("interactionFieldReasoning")}
+
+
+
+ {t("interactionFieldSpeed")}
+
+
+
+ ) : null}
+
+
+
+
{t("interactionSafetyTitle")}
+ {t("interactionSafetyDetail")}
+
+
+
{t("interactionFallbackTitle")}
+ {t("interactionFallbackDetail")}
+
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/features/themes/NewThemeDialog.test.tsx b/apps/desktop/src/features/themes/NewThemeDialog.test.tsx
new file mode 100644
index 0000000..ca9c0f2
--- /dev/null
+++ b/apps/desktop/src/features/themes/NewThemeDialog.test.tsx
@@ -0,0 +1,59 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { nativeRefined } from "@codex-styler/theme-core";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { translate, type MessageKey } from "../../lib/i18n";
+import { NewThemeDialog } from "./NewThemeDialog";
+
+const t = (key: MessageKey) => translate("en", key);
+
+describe("NewThemeDialog", () => {
+ afterEach(cleanup);
+
+ it("keeps the creation choices and existing-theme navigation explicit", () => {
+ const onChooseStep = vi.fn();
+ const onImage = vi.fn();
+
+ const { rerender } = render(
+ path}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /create from image/i }));
+ expect(onImage).toHaveBeenCalledOnce();
+
+ fireEvent.click(screen.getByRole("button", { name: /existing theme/i }));
+ expect(onChooseStep).toHaveBeenCalledWith("existing");
+
+ rerender(
+ path}
+ />,
+ );
+
+ expect(
+ screen.getByRole("heading", { name: /choose a starting theme/i }),
+ ).toBeVisible();
+ expect(
+ screen.getByRole("button", { name: /native refined/i }),
+ ).toBeVisible();
+ });
+});
diff --git a/apps/desktop/src/features/themes/NewThemeDialog.tsx b/apps/desktop/src/features/themes/NewThemeDialog.tsx
new file mode 100644
index 0000000..b8c8ddd
--- /dev/null
+++ b/apps/desktop/src/features/themes/NewThemeDialog.tsx
@@ -0,0 +1,140 @@
+import type { ThemeDefinition } from "@codex-styler/theme-core";
+import { ChevronRight, Copy, Image, LockKeyhole, Plus, X } from "lucide-react";
+import type { Locale, MessageKey } from "../../lib/i18n";
+
+export type NewThemeStep = "choose" | "existing";
+
+export function NewThemeDialog({
+ step,
+ themes,
+ locale,
+ t,
+ onClose,
+ onChooseStep,
+ onBlank,
+ onImage,
+ onExisting,
+ resolveAsset,
+}: {
+ step: NewThemeStep;
+ themes: ThemeDefinition[];
+ locale: Locale;
+ t: (key: MessageKey) => string;
+ onClose: () => void;
+ onChooseStep: (step: NewThemeStep) => void;
+ onBlank: () => void;
+ onImage: () => void;
+ onExisting: (theme: ThemeDefinition) => void;
+ resolveAsset: (theme: ThemeDefinition, path: string) => string;
+}) {
+ return (
+
+
+
+
+ {step === "choose" ? (
+
+
+
+
+
+
+ {t("recommended")}
+ {t("createFromImage")}
+ {t("createFromImageLong")}
+
+
+
+
+
+
+
+
+ {t("cleanStart")}
+ {t("startBlank")}
+ {t("startBlankDetail")}
+
+
+
+
onChooseStep("existing")}
+ >
+
+
+
+
+ {t("optionalStartingPoint")}
+ {t("useExistingTheme")}
+ {t("useExistingThemeDetail")}
+
+
+
+
+ ) : (
+
+ {themes.map((theme) => {
+ const localizedTheme = theme.locales[locale] ?? theme.locales.en;
+ const preview = theme.metadata.preview
+ ? resolveAsset(theme, theme.metadata.preview)
+ : undefined;
+ return (
+ onExisting(theme)}>
+
+
+ {localizedTheme.name}
+ {localizedTheme.description}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {t("newThemePrivacy")}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/features/themes/ThemeRow.tsx b/apps/desktop/src/features/themes/ThemeRow.tsx
index b71a378..beac238 100644
--- a/apps/desktop/src/features/themes/ThemeRow.tsx
+++ b/apps/desktop/src/features/themes/ThemeRow.tsx
@@ -19,7 +19,7 @@ export interface ThemeRowProps {
active: boolean;
live: boolean;
resolveAsset: (theme: ThemeDefinition, path: string) => string;
- onSelect: () => void;
+ onSelect: (trigger: HTMLButtonElement) => void;
local: boolean;
onEdit: () => void;
onDelete: () => void;
@@ -48,7 +48,7 @@ export function ThemeRow({
onSelect(event.currentTarget)}
aria-label={`${t("preview")}: ${localized.name}`}
aria-pressed={active}
/>
diff --git a/apps/desktop/src/features/themes/ThemesView.test.tsx b/apps/desktop/src/features/themes/ThemesView.test.tsx
index 8207708..1ab90c6 100644
--- a/apps/desktop/src/features/themes/ThemesView.test.tsx
+++ b/apps/desktop/src/features/themes/ThemesView.test.tsx
@@ -102,4 +102,37 @@ describe("ThemesView", () => {
within(list).getByRole("button", { name: "Preview: Native Refined" }),
).toBeVisible();
});
+
+ it("moves focus into compact details and restores the selected row", () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: true })),
+ );
+ vi.stubGlobal(
+ "requestAnimationFrame",
+ vi.fn((callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ }),
+ );
+ const { container } = renderThemesView();
+ const trigger = screen.getByRole("button", {
+ name: "Preview: Native Refined",
+ });
+
+ fireEvent.click(trigger);
+
+ const back = screen.getByRole("button", { name: "Back to themes" });
+ expect(back).toHaveFocus();
+ expect(container.querySelector(".theme-library-workspace")).toHaveClass(
+ "theme-library-workspace--detail",
+ );
+
+ fireEvent.keyDown(back, { key: "Escape" });
+
+ expect(trigger).toHaveFocus();
+ expect(container.querySelector(".theme-library-workspace")).not.toHaveClass(
+ "theme-library-workspace--detail",
+ );
+ });
});
diff --git a/apps/desktop/src/features/themes/ThemesView.tsx b/apps/desktop/src/features/themes/ThemesView.tsx
index 15c72fd..ec6947e 100644
--- a/apps/desktop/src/features/themes/ThemesView.tsx
+++ b/apps/desktop/src/features/themes/ThemesView.tsx
@@ -5,6 +5,7 @@ import { ResourceLibraryToolbar } from "../../components/ui/ResourceLibraryToolb
import type { ThemeVariantName } from "../../lib/app-session";
import type { Locale, MessageKey } from "../../lib/i18n";
import { matchesResourceSearch } from "../../lib/resource-search";
+import { useCompactDetailFlow } from "../../lib/use-compact-detail-flow";
import { ThemeDetailCard } from "./ThemeDetailCard";
import { ThemeRow } from "./ThemeRow";
@@ -48,26 +49,16 @@ export function ThemesView({
resolveAsset,
liveThemeId,
}: ThemesViewProps) {
- const [compactDetailOpen, setCompactDetailOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [browsedThemeId, setBrowsedThemeId] = useState(selectedTheme.id);
- const workspaceRef = useRef(null);
const themeListRef = useRef(null);
- useEffect(() => {
- if (!compactDetailOpen) return;
- const element = workspaceRef.current?.closest(
- ".app-main__viewport",
- );
- if (!element) return;
- if (typeof element.scrollTo === "function") {
- element.scrollTo({
- top: 0,
- behavior: reduceMotion ? "auto" : "smooth",
- });
- } else {
- element.scrollTop = 0;
- }
- }, [compactDetailOpen, reduceMotion]);
+ const {
+ closeDetail,
+ containerRef: workspaceRef,
+ detailOpen: compactDetailOpen,
+ onKeyDown: onWorkspaceKeyDown,
+ openDetail,
+ } = useCompactDetailFlow(reduceMotion);
const themes = collection === "builtIn" ? builtinThemes : localThemes;
const filteredThemes = themes.filter((theme) => {
const localizedTheme = theme.locales[locale] ?? theme.locales.en;
@@ -137,7 +128,7 @@ export function ThemesView({
activeTab={collection}
onTabChange={(nextCollection) => {
setSearchQuery("");
- setCompactDetailOpen(false);
+ closeDetail({ restoreFocus: false });
onCollectionChange(nextCollection);
}}
search={
@@ -174,6 +165,7 @@ export function ThemesView({
) : (
{
+ onSelect={(trigger) => {
setBrowsedThemeId(theme.id);
onSelect(theme);
- setCompactDetailOpen(true);
+ openDetail(trigger);
}}
local={collection === "mine"}
onEdit={() => onEdit(theme)}
@@ -246,7 +238,7 @@ export function ThemesView({
reduceMotion={reduceMotion}
t={t}
resolveAsset={resolveAsset}
- onBack={() => setCompactDetailOpen(false)}
+ onBack={() => closeDetail()}
onEdit={() => onEdit(browsedTheme)}
/>
diff --git a/apps/desktop/src/lib/composer-interactions.ts b/apps/desktop/src/lib/composer-interactions.ts
new file mode 100644
index 0000000..0e30164
--- /dev/null
+++ b/apps/desktop/src/lib/composer-interactions.ts
@@ -0,0 +1,16 @@
+export const composerInteractionModes = [
+ "disabled",
+ "marbles",
+ "claw",
+ "toss",
+ "balance",
+ "route",
+] as const;
+
+export type ComposerInteractionMode = (typeof composerInteractionModes)[number];
+
+export function isComposerInteractionMode(
+ value: unknown,
+): value is ComposerInteractionMode {
+ return composerInteractionModes.includes(value as ComposerInteractionMode);
+}
diff --git a/apps/desktop/src/lib/i18n.ts b/apps/desktop/src/lib/i18n.ts
index 9815402..e7a076e 100644
--- a/apps/desktop/src/lib/i18n.ts
+++ b/apps/desktop/src/lib/i18n.ts
@@ -8,6 +8,7 @@ const messages = {
themes: "Themes",
create: "Create",
companions: "Companions",
+ interactions: "Interactions",
library: "My Themes",
settings: "Settings",
homeKicker: "Workspace control",
@@ -15,6 +16,52 @@ const messages = {
companionLibraryKicker: "Independent scene entities",
companionLibraryDescription:
"Place companions by dragging. Recommended pairings are optional, and themes and companions always stay independent.",
+ interactionKicker: "Composer configuration experiences",
+ interactionDescription:
+ "Choose how Codex's native model and reasoning control behaves. The selected interaction replaces that control in place and never reads prompt content.",
+ interactionChooseMode: "Choose a composer interaction",
+ interactionSelected: "Selected",
+ interactionDisabled: "Use the official control",
+ interactionDisabledDetail:
+ "Keep Codex's native model and reasoning menu unchanged.",
+ interactionMarbles: "Triple Drop",
+ interactionMarblesDetail:
+ "Drop one configuration marble for model, reasoning, and speed; each landing becomes the exact result.",
+ interactionClaw: "Capsule Crane",
+ interactionClawDetail:
+ "Aim for a capsule that visibly contains a complete model, reasoning, and speed loadout.",
+ interactionToss: "Loadout Forge",
+ interactionTossDetail:
+ "Spin and lock three independent rings, then forge their model, reasoning, and speed result.",
+ interactionBalance: "Three-axis Console",
+ interactionBalanceDetail:
+ "Directly select the model engine, reasoning effort, and response speed in one control deck.",
+ interactionRoute: "Mission Map",
+ interactionRouteDetail:
+ "Choose a model engine, then a transparent reasoning-and-speed route for the task intent.",
+ interactionCategoryOfficial: "Official",
+ interactionCategoryPhysics: "Physics",
+ interactionCategorySkill: "Skill",
+ interactionCategoryChance: "Forge",
+ interactionCategoryTune: "Tune",
+ interactionCategoryChoose: "Choose",
+ interactionNativePlacement: "Replaces the native control in place",
+ interactionNativePlacementDetail:
+ "Open Codex's normal configuration control to use the selected experience. No extra floating launcher is added beside the composer.",
+ interactionComposerPlaceholder: "Describe a task to Codex…",
+ interactionOfficialControl: "Official configuration",
+ interactionSafetyTitle: "Exact, confirmable changes",
+ interactionSafetyDetail:
+ "Only visible model, reasoning, and speed choices are used. A configuration diff is shown before anything changes.",
+ interactionBindingTitle: "Every play returns one complete loadout",
+ interactionBindingDetail:
+ "The action itself selects all three real settings. Nothing is a decorative score or disconnected reward.",
+ interactionFieldModel: "Model engine",
+ interactionFieldReasoning: "Reasoning effort",
+ interactionFieldSpeed: "Response speed",
+ interactionFallbackTitle: "Explicit recovery path",
+ interactionFallbackDetail:
+ "If Codex's control cannot be read reliably, the play panel stays visible and offers an explicit switch to official settings—nothing flashes or changes silently.",
companionDraftsKicker: "Autosaved creator projects",
settingsKicker: "Local preferences",
themeIndexKicker: "Theme index",
@@ -469,6 +516,9 @@ const messages = {
reduceMotion: "Reduce motion",
reduceMotionDescription:
"Freezes pointer tracking, idle animation, and non-essential parallax.",
+ composerMoments: "Configuration plays",
+ composerMomentsDescription:
+ "Adds optional theme-adaptive plays beside the Codex composer. Each play reads only the visible model, reasoning, and speed choices, previews an exact configuration diff, and applies it only after confirmation. Prompt content is never read or changed.",
automaticUpdateChecks: "Automatically check for updates",
automaticUpdateChecksDescription:
"Checks GitHub Releases after launch. Nothing is downloaded without confirmation.",
@@ -610,7 +660,7 @@ const messages = {
companionUpdated: "Companion updated",
codexQuitFailed:
"Codex did not close. Save your work, close it manually, and try again.",
- version: "Beta 7",
+ version: "Beta 8",
},
"zh-CN": {
unofficial: "非官方开源项目",
@@ -618,6 +668,7 @@ const messages = {
themes: "主题",
create: "创作",
companions: "互动伙伴",
+ interactions: "交互玩法",
library: "我的主题",
settings: "设置",
homeKicker: "工作区控制",
@@ -625,6 +676,51 @@ const messages = {
companionLibraryKicker: "独立场景实体",
companionLibraryDescription:
"可通过拖拽调整伙伴位置;默认搭配仅供参考,主题与互动伙伴始终可以独立选择。",
+ interactionKicker: "输入区配置交互",
+ interactionDescription:
+ "选择 Codex 原生模型与推理设置控件的交互方式。所选玩法会在原控件位置直接替换,且绝不读取输入内容。",
+ interactionChooseMode: "选择输入区交互玩法",
+ interactionSelected: "已选择",
+ interactionDisabled: "使用官方控件",
+ interactionDisabledDetail: "保持 Codex 原生模型与推理设置菜单不变。",
+ interactionMarbles: "三段落珠",
+ interactionMarblesDetail:
+ "依次为模型、推理强度与速度落下一颗配置珠,每次落点都直接成为结果。",
+ interactionClaw: "配置胶囊机",
+ interactionClawDetail:
+ "瞄准并抓取写明模型、推理强度与速度三项参数的完整配置胶囊。",
+ interactionToss: "三环锻造",
+ interactionTossDetail:
+ "分别转动并锁定模型、推理强度与速度三环,再锻造成最终配置。",
+ interactionBalance: "三轴控制台",
+ interactionBalanceDetail:
+ "在同一个控制台中直接选择模型引擎、推理强度与响应速度。",
+ interactionRoute: "任务航图",
+ interactionRouteDetail:
+ "先选择模型引擎,再选择透明可见的推理强度与速度任务航线。",
+ interactionCategoryOfficial: "官方",
+ interactionCategoryPhysics: "物理",
+ interactionCategorySkill: "操作",
+ interactionCategoryChance: "组合",
+ interactionCategoryTune: "调校",
+ interactionCategoryChoose: "选择",
+ interactionNativePlacement: "在原生控件位置直接替换",
+ interactionNativePlacementDetail:
+ "点击 Codex 原有的配置控件即可进入所选玩法;输入框旁不会再出现额外的悬浮启动按钮。",
+ interactionComposerPlaceholder: "向 Codex 描述任务…",
+ interactionOfficialControl: "官方配置控件",
+ interactionSafetyTitle: "变更明确且需确认",
+ interactionSafetyDetail:
+ "只使用界面可见的模型、推理强度与速度选项;实际更改前会展示配置差异。",
+ interactionBindingTitle: "每次玩法都会产生一套完整配置",
+ interactionBindingDetail:
+ "操作本身直接决定三项真实设置,不再出现与结果无关的分数或装饰奖励。",
+ interactionFieldModel: "模型引擎",
+ interactionFieldReasoning: "推理强度",
+ interactionFieldSpeed: "响应速度",
+ interactionFallbackTitle: "明确的恢复路径",
+ interactionFallbackDetail:
+ "无法可靠读取 Codex 控件时,玩法面板会保持可见,并明确提供改用官方设置;不会闪现原面板或静默修改。",
companionDraftsKicker: "自动保存的创作工程",
settingsKicker: "本地偏好设置",
themeIndexKicker: "主题索引",
@@ -1056,6 +1152,9 @@ const messages = {
languageDescription: "界面语言与本地主题元数据",
reduceMotion: "减少动态",
reduceMotionDescription: "停止光标跟随、待机动作和非必要的视差动态。",
+ composerMoments: "配置玩法",
+ composerMomentsDescription:
+ "在 Codex 输入框旁加入主题自适应玩法。玩法只读取界面中可见的模型、推理强度与速度选项,先展示明确的配置差异,经确认后才应用;不会读取或修改输入内容。",
automaticUpdateChecks: "自动检查更新",
automaticUpdateChecksDescription:
"启动后检查 GitHub Releases;未经确认不会下载任何内容。",
@@ -1188,7 +1287,7 @@ const messages = {
themeDeleted: "主题已删除",
companionUpdated: "互动伙伴已更新",
codexQuitFailed: "Codex 未能关闭。请保存工作、手动退出后再试。",
- version: "Beta 7",
+ version: "Beta 8",
},
} as const;
diff --git a/apps/desktop/src/lib/runtime.ts b/apps/desktop/src/lib/runtime.ts
index a27dfcc..cb6d16b 100644
--- a/apps/desktop/src/lib/runtime.ts
+++ b/apps/desktop/src/lib/runtime.ts
@@ -8,6 +8,8 @@ import {
} from "@codex-styler/theme-core";
import { prepareThemeForRuntime } from "./assets";
import type { RuntimeStrategy } from "./storage";
+import type { Locale } from "./i18n";
+import type { ComposerInteractionMode } from "./composer-interactions";
export interface CodexDetection {
installed: boolean;
@@ -63,9 +65,17 @@ export interface AppliedConfiguration {
variant: "light" | "dark";
runtimeStrategy: RuntimeStrategy;
reduceMotion: boolean;
+ composerInteractionMode: ComposerInteractionMode;
+ locale: Locale;
revision: number;
}
+export interface RuntimeExperiencePreferences {
+ composerInteractionMode: ComposerInteractionMode;
+ locale: Locale;
+ reduceMotion: boolean;
+}
+
export type UpdateDownloadEvent =
| { event: "Started"; data: { contentLength: number | null } }
| { event: "Progress"; data: { chunkLength: number } }
@@ -205,6 +215,11 @@ export async function applyTheme(
runtimeStrategy: RuntimeStrategy,
resolveAsset?: (theme: ThemeDefinition, path: string) => string,
revision = 0,
+ experience: RuntimeExperiencePreferences = {
+ composerInteractionMode: "disabled",
+ locale: "en",
+ reduceMotion: false,
+ },
): Promise {
const payload = await prepareThemeForRuntime(theme, resolveAsset);
if (!isTauri()) {
@@ -225,6 +240,7 @@ export async function applyTheme(
compatibilityMode:
runtimeStrategy === "enhanced" ? "auto" : "compatibility",
revision,
+ experience,
});
}
@@ -246,9 +262,32 @@ export async function applyConfiguration(
configuration.runtimeStrategy,
resolveAsset,
configuration.revision,
+ {
+ composerInteractionMode: configuration.composerInteractionMode,
+ locale: configuration.locale,
+ reduceMotion: configuration.reduceMotion,
+ },
);
}
+export async function updateRuntimeExperience(
+ experience: RuntimeExperiencePreferences,
+ revision: number,
+): Promise {
+ if (!isTauri()) {
+ browserStatus = {
+ ...browserStatus,
+ revision,
+ message: "Runtime interaction preferences updated in preview mode",
+ };
+ return { ...browserStatus };
+ }
+ return invoke("update_runtime_experience", {
+ experience,
+ revision,
+ });
+}
+
export async function updateCompanion(
theme: ThemeDefinition,
resolveAsset: ((theme: ThemeDefinition, path: string) => string) | undefined,
@@ -345,7 +384,7 @@ export async function checkForUpdates(
locale: "en" | "zh-CN",
): Promise {
if (!isTauri()) {
- return { currentVersion: "0.2.0-beta.7", update: null };
+ return { currentVersion: "0.2.0-beta.8", update: null };
}
return invoke("check_for_updates", { locale });
}
diff --git a/apps/desktop/src/lib/storage.test.ts b/apps/desktop/src/lib/storage.test.ts
index e976268..13dba4f 100644
--- a/apps/desktop/src/lib/storage.test.ts
+++ b/apps/desktop/src/lib/storage.test.ts
@@ -49,6 +49,29 @@ describe("v0.1 settings migration", () => {
themeVariant: "light",
});
});
+
+ it("migrates the legacy composer toggle to an explicit interaction mode", () => {
+ localStorage.setItem(
+ "codex-styler.settings.v1",
+ JSON.stringify({ composerMomentsEnabled: true, reduceMotion: true }),
+ );
+ expect(loadSettings()).toMatchObject({
+ composerInteractionMode: "marbles",
+ reduceMotion: true,
+ });
+
+ localStorage.setItem(
+ "codex-styler.settings.v1",
+ JSON.stringify({ composerMomentsEnabled: false }),
+ );
+ expect(loadSettings().composerInteractionMode).toBe("disabled");
+
+ localStorage.setItem(
+ "codex-styler.settings.v1",
+ JSON.stringify({ composerInteractionMode: "route" }),
+ );
+ expect(loadSettings().composerInteractionMode).toBe("route");
+ });
});
describe("workspace UI preferences", () => {
diff --git a/apps/desktop/src/lib/storage.ts b/apps/desktop/src/lib/storage.ts
index 5b696c9..bbe4594 100644
--- a/apps/desktop/src/lib/storage.ts
+++ b/apps/desktop/src/lib/storage.ts
@@ -5,6 +5,10 @@ import type {
ThemeVariantName,
} from "@codex-styler/theme-core";
import type { LocalePreference } from "./i18n";
+import {
+ isComposerInteractionMode,
+ type ComposerInteractionMode,
+} from "./composer-interactions";
export type ManagerAppearance = "system" | "light" | "dark";
export type RuntimeStrategy = "enhanced" | "conservative";
@@ -39,6 +43,7 @@ export interface UserSettings {
companionSizes: Record;
companionAttachments: Record;
reduceMotion: boolean;
+ composerInteractionMode: ComposerInteractionMode;
automaticUpdateChecks: boolean;
skippedUpdateVersion: string | null;
lastUpdateCheckAt: string | null;
@@ -68,6 +73,7 @@ export function loadSettings(): UserSettings {
runtimeStrategy?:
RuntimeStrategy | "auto" | "compatibility" | "developer";
manualUpdateChecks?: boolean;
+ composerMomentsEnabled?: boolean;
};
const runtimeStrategy: RuntimeStrategy =
parsed.runtimeStrategy === "compatibility" ||
@@ -92,6 +98,7 @@ export function loadSettings(): UserSettings {
: !parsed.manualUpdateChecks);
const currentSettings = { ...parsed };
delete currentSettings.manualUpdateChecks;
+ delete currentSettings.composerMomentsEnabled;
return {
...defaultSettings(),
...currentSettings,
@@ -133,6 +140,13 @@ export function loadSettings(): UserSettings {
typeof parsed.reduceMotion === "boolean"
? parsed.reduceMotion
: false,
+ composerInteractionMode: isComposerInteractionMode(
+ parsed.composerInteractionMode,
+ )
+ ? parsed.composerInteractionMode
+ : parsed.composerMomentsEnabled === true
+ ? "marbles"
+ : "disabled",
automaticUpdateChecks,
skippedUpdateVersion:
typeof parsed.skippedUpdateVersion === "string" ||
@@ -170,6 +184,7 @@ function defaultSettings(): UserSettings {
companionSizes: {},
companionAttachments: {},
reduceMotion: false,
+ composerInteractionMode: "disabled",
automaticUpdateChecks: true,
skippedUpdateVersion: null,
lastUpdateCheckAt: null,
diff --git a/apps/desktop/src/lib/use-compact-detail-flow.ts b/apps/desktop/src/lib/use-compact-detail-flow.ts
new file mode 100644
index 0000000..17fd60e
--- /dev/null
+++ b/apps/desktop/src/lib/use-compact-detail-flow.ts
@@ -0,0 +1,83 @@
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type KeyboardEvent,
+} from "react";
+
+const compactLibraryQuery = "(max-width: 1060px)";
+
+function compactLibraryIsActive() {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia(compactLibraryQuery).matches
+ );
+}
+
+interface CloseDetailOptions {
+ restoreFocus?: boolean;
+}
+
+/**
+ * Keeps the responsive list → detail transition usable without coupling
+ * resource views to viewport listeners. Wide layouts continue to show both
+ * panes, while compact layouts move focus into the detail view and restore it
+ * to the originating row on Back or Escape.
+ */
+export function useCompactDetailFlow(
+ reduceMotion: boolean,
+) {
+ const [detailOpen, setDetailOpen] = useState(false);
+ const containerRef = useRef(null);
+ const triggerRef = useRef(null);
+
+ const openDetail = useCallback((trigger?: HTMLElement | null) => {
+ if (trigger) triggerRef.current = trigger;
+ setDetailOpen(true);
+ }, []);
+
+ const closeDetail = useCallback((options?: CloseDetailOptions) => {
+ const shouldRestoreFocus = options?.restoreFocus ?? true;
+ setDetailOpen(false);
+ if (!shouldRestoreFocus || !compactLibraryIsActive()) return;
+ window.requestAnimationFrame(() => triggerRef.current?.focus());
+ }, []);
+
+ useEffect(() => {
+ if (!detailOpen || !compactLibraryIsActive()) return;
+ const container = containerRef.current;
+ const viewport = container?.closest(".app-main__viewport");
+ if (viewport) {
+ if (typeof viewport.scrollTo === "function") {
+ viewport.scrollTo({
+ top: 0,
+ behavior: reduceMotion ? "auto" : "smooth",
+ });
+ } else {
+ viewport.scrollTop = 0;
+ }
+ }
+ container
+ ?.querySelector(".compact-detail-back")
+ ?.focus({ preventScroll: true });
+ }, [detailOpen, reduceMotion]);
+
+ const onKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key !== "Escape" || !detailOpen) return;
+ event.preventDefault();
+ closeDetail();
+ },
+ [closeDetail, detailOpen],
+ );
+
+ return {
+ closeDetail,
+ containerRef,
+ detailOpen,
+ onKeyDown,
+ openDetail,
+ };
+}
diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx
index 6095143..3175916 100644
--- a/apps/desktop/src/main.tsx
+++ b/apps/desktop/src/main.tsx
@@ -10,6 +10,7 @@ import "./styles/settings.css";
import "./styles/editor.css";
import "./styles/creator-beta3.css";
import "./styles/interaction-surfaces.css";
+import "./styles/interactions.css";
import "./styles/typography.css";
import "./styles/experience-beta6.css";
import "./styles/scroll-surfaces.css";
diff --git a/apps/desktop/src/runtime-injection.test.ts b/apps/desktop/src/runtime-injection.test.ts
index 52f8a32..ea35da3 100644
--- a/apps/desktop/src/runtime-injection.test.ts
+++ b/apps/desktop/src/runtime-injection.test.ts
@@ -1,11 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nativeRefined, type SceneEntity } from "@codex-styler/theme-core";
import runtimeSource from "../src-tauri/src/runtime.js?raw";
+import composerMomentsSource from "../src-tauri/src/composer-moments.js?raw";
+import composerSettingsAdapterSource from "../src-tauri/src/composer-settings-adapter.js?raw";
import { contrastRatio, minimumContrast } from "./lib/contrast";
import { resolveThemeContrast } from "./lib/theme-contrast";
import { assignThemeColorHarmony } from "./lib/theme-color-harmony";
import { resolveThemePreviewPalette } from "./lib/theme-preview-palette";
-import { codexFixture, portalFixture } from "./test/fixtures/codex-dom";
+import {
+ codexFixture,
+ installCodexIntelligenceFixture,
+ portalFixture,
+} from "./test/fixtures/codex-dom";
interface InjectedRuntime {
version: number;
@@ -14,6 +20,12 @@ interface InjectedRuntime {
variant: "light" | "dark",
mode: "auto" | "compatibility" | "developer",
revision?: number,
+ experience?: {
+ composerInteractionMode:
+ "disabled" | "marbles" | "claw" | "toss" | "balance" | "route";
+ locale: "en" | "zh-CN";
+ reduceMotion: boolean;
+ },
) => Promise<{
resolvedMode: string;
reason: string | null;
@@ -24,6 +36,15 @@ interface InjectedRuntime {
entity: unknown,
revision?: number,
) => { ok: boolean; stale: boolean };
+ updateExperience: (
+ experience: {
+ composerInteractionMode:
+ "disabled" | "marbles" | "claw" | "toss" | "balance" | "route";
+ locale: "en" | "zh-CN";
+ reduceMotion: boolean;
+ },
+ revision?: number,
+ ) => { ok: boolean; stale: boolean; mode: string };
restore: () => void;
}
@@ -110,7 +131,9 @@ describe("injected compatibility runtime", () => {
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
- Function(runtimeSource)();
+ Function(
+ `${composerSettingsAdapterSource}\n${composerMomentsSource}\n${runtimeSource}`,
+ )();
});
afterEach(() => {
@@ -142,10 +165,473 @@ describe("injected compatibility runtime", () => {
}
).__CODEX_STYLER_RUNTIME__ = { version: 20, restore };
- Function(runtimeSource)();
+ Function(
+ `${composerSettingsAdapterSource}\n${composerMomentsSource}\n${runtimeSource}`,
+ )();
expect(restore).toHaveBeenCalledOnce();
- expect(runtime().version).toBe(36);
+ expect(runtime().version).toBe(41);
+ });
+
+ it("updates composer interactions without reinjecting the active theme", async () => {
+ document.body.innerHTML = codexFixture("task");
+ installCodexIntelligenceFixture();
+ await runtime().apply(nativeRefined, "dark", "compatibility", 1, {
+ composerInteractionMode: "disabled",
+ locale: "en",
+ reduceMotion: false,
+ });
+ const backdrop = document.getElementById("codex-styler-scene-root");
+
+ const outcome = runtime().updateExperience(
+ {
+ composerInteractionMode: "toss",
+ locale: "zh-CN",
+ reduceMotion: true,
+ },
+ 2,
+ );
+
+ expect(outcome).toMatchObject({ ok: true, stale: false, mode: "toss" });
+ expect(document.getElementById("codex-styler-scene-root")).toBe(backdrop);
+ expect(
+ document.getElementById("codex-styler-composer-moments"),
+ ).not.toBeNull();
+ });
+
+ it("adds theme-adaptive composer moments without touching prompt content", async () => {
+ document.body.innerHTML = codexFixture("task");
+ installCodexIntelligenceFixture();
+ const composer = document.querySelector(
+ ".composer-surface-chrome",
+ ) as HTMLElement;
+ const originalPrompt = composer.textContent;
+ vi.spyOn(composer, "getBoundingClientRect").mockReturnValue({
+ x: 180,
+ y: 540,
+ left: 180,
+ top: 540,
+ right: 820,
+ bottom: 650,
+ width: 640,
+ height: 110,
+ toJSON: () => ({}),
+ });
+
+ await runtime().apply(nativeRefined, "dark", "compatibility", 1, {
+ composerInteractionMode: "toss",
+ locale: "en",
+ reduceMotion: true,
+ });
+
+ const root = document.getElementById("codex-styler-composer-moments");
+ const trigger = document.querySelector(
+ "[data-codex-intelligence-trigger]",
+ );
+ expect(root).not.toBeNull();
+ expect(root?.style.getPropertyValue("--csm-accent")).toBe(
+ nativeRefined.variants.dark.appearance.accent,
+ );
+ expect(root?.style.getPropertyValue("--csm-surface-opacity")).toBe(
+ `${Math.round(
+ nativeRefined.variants.dark.appearance.surfaceOpacity * 100,
+ )}%`,
+ );
+ expect(root?.style.getPropertyValue("--csm-blur")).toBe(
+ `${nativeRefined.variants.dark.appearance.focusBlur}px`,
+ );
+ expect(getComputedStyle(root as HTMLElement).zIndex).toBe("10");
+ expect(root?.querySelector(".csm-trigger")).toBeNull();
+
+ trigger?.click();
+ await vi.waitFor(() =>
+ expect(root?.querySelector("[role='application']")).toHaveAccessibleName(
+ "Loadout Forge",
+ ),
+ );
+ await vi.waitFor(() =>
+ expect(root?.querySelector(".csm-proposal")).not.toBeNull(),
+ );
+ expect(root?.textContent).toContain("Reasoning effort");
+ expect(root?.textContent).toContain("Speed");
+ expect(composer.textContent).toBe(originalPrompt);
+
+ runtime().restore();
+ expect(document.getElementById("codex-styler-composer-moments")).toBeNull();
+ });
+
+ it("takes over an asynchronously opened localized Codex settings menu", async () => {
+ document.body.innerHTML = codexFixture("task");
+ installCodexIntelligenceFixture();
+ document
+ .querySelector("[aria-label='Model 5.6 Sol']")
+ ?.setAttribute("aria-label", "模型 5.6 Sol");
+ const reasoningRow = document.querySelector("[aria-label='Effort High']");
+ reasoningRow?.setAttribute("aria-label", "推理强度 High");
+ if (reasoningRow) reasoningRow.textContent = "推理强度 High";
+ const speedRow = document.querySelector("[aria-label='Speed Fast']");
+ speedRow?.setAttribute("aria-label", "速度 Fast");
+ if (speedRow) speedRow.textContent = "速度 Fast";
+
+ await runtime().apply(nativeRefined, "dark", "compatibility", 1, {
+ composerInteractionMode: "toss",
+ locale: "zh-CN",
+ reduceMotion: true,
+ });
+ const trigger = document.querySelector(
+ "[data-codex-intelligence-trigger]",
+ );
+ const pointerDown = new PointerEvent("pointerdown", {
+ bubbles: true,
+ cancelable: true,
+ });
+ trigger?.dispatchEvent(pointerDown);
+
+ expect(pointerDown.defaultPrevented).toBe(true);
+ expect(document.querySelector(".csm-stage--loading")).not.toBeNull();
+ expect(
+ Array.from(document.querySelectorAll("[role='menu']")).some(
+ (menu) => !menu.hidden && menu.style.opacity !== "0",
+ ),
+ ).toBe(false);
+ await vi.waitFor(() =>
+ expect(document.querySelector(".csm-proposal")).not.toBeNull(),
+ );
+ expect(document.querySelector(".csm-stage")).toHaveAccessibleName(
+ "三环锻造",
+ );
+ expect(
+ Array.from(document.querySelectorAll("[role='menu']")).some(
+ (menu) => !menu.hidden && menu.style.opacity !== "0",
+ ),
+ ).toBe(false);
+ });
+
+ it("keeps composer moments absent when the user turns them off", async () => {
+ document.body.innerHTML = codexFixture("task");
+
+ await runtime().apply(nativeRefined, "dark", "compatibility", 1, {
+ composerInteractionMode: "disabled",
+ locale: "en",
+ reduceMotion: false,
+ });
+
+ expect(document.getElementById("codex-styler-composer-moments")).toBeNull();
+ });
+
+ it("keeps composer moments idempotent across theme updates and restores focus", async () => {
+ document.body.innerHTML = codexFixture("task");
+ installCodexIntelligenceFixture();
+ const composer = document.querySelector(
+ ".composer-surface-chrome",
+ ) as HTMLElement;
+ vi.spyOn(composer, "getBoundingClientRect").mockReturnValue({
+ x: 180,
+ y: 540,
+ left: 180,
+ top: 540,
+ right: 820,
+ bottom: 650,
+ width: 640,
+ height: 110,
+ toJSON: () => ({}),
+ });
+
+ const experience = {
+ composerInteractionMode: "claw" as const,
+ locale: "en" as const,
+ reduceMotion: true,
+ };
+ await runtime().apply(
+ nativeRefined,
+ "dark",
+ "compatibility",
+ 1,
+ experience,
+ );
+ await runtime().apply(
+ nativeRefined,
+ "light",
+ "compatibility",
+ 2,
+ experience,
+ );
+
+ expect(
+ document.querySelectorAll("#codex-styler-composer-moments"),
+ ).toHaveLength(1);
+ expect(
+ document.querySelectorAll("#codex-styler-composer-moments-style"),
+ ).toHaveLength(1);
+
+ const trigger = document.querySelector(
+ "[data-codex-intelligence-trigger]",
+ );
+ trigger?.focus();
+ trigger?.click();
+ await vi.waitFor(() =>
+ expect(document.querySelector(".csm-proposal")).not.toBeNull(),
+ );
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ expect(trigger).toHaveFocus();
+ expect(document.querySelector(".csm-stage")).toBeNull();
+ });
+
+ it("opens every animated composer moment and renders its first frame", async () => {
+ document.body.innerHTML = codexFixture("task");
+ installCodexIntelligenceFixture();
+ const composer = document.querySelector(
+ ".composer-surface-chrome",
+ ) as HTMLElement;
+ vi.spyOn(composer, "getBoundingClientRect").mockReturnValue({
+ x: 180,
+ y: 540,
+ left: 180,
+ top: 540,
+ right: 820,
+ bottom: 650,
+ width: 640,
+ height: 110,
+ toJSON: () => ({}),
+ });
+ const gradient = { addColorStop: vi.fn() };
+ const context = {
+ arc: vi.fn(),
+ arcTo: vi.fn(),
+ beginPath: vi.fn(),
+ clearRect: vi.fn(),
+ closePath: vi.fn(),
+ createLinearGradient: vi.fn(() => gradient),
+ createRadialGradient: vi.fn(() => gradient),
+ ellipse: vi.fn(),
+ fill: vi.fn(),
+ fillText: vi.fn(),
+ lineTo: vi.fn(),
+ moveTo: vi.fn(),
+ quadraticCurveTo: vi.fn(),
+ restore: vi.fn(),
+ rotate: vi.fn(),
+ save: vi.fn(),
+ setLineDash: vi.fn(),
+ setTransform: vi.fn(),
+ stroke: vi.fn(),
+ translate: vi.fn(),
+ };
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
+ context as unknown as CanvasRenderingContext2D,
+ );
+ vi.stubGlobal("matchMedia", () => ({ matches: false }));
+ const animationFrames: FrameRequestCallback[] = [];
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
+ animationFrames.push(callback);
+ return animationFrames.length;
+ });
+
+ const games = ["marbles", "claw", "toss", "balance", "route"] as const;
+ for (const [index, game] of games.entries()) {
+ await runtime().apply(nativeRefined, "dark", "compatibility", index + 1, {
+ composerInteractionMode: game,
+ locale: "en",
+ reduceMotion: false,
+ });
+ animationFrames.length = 0;
+ context.clearRect.mockClear();
+ document
+ .querySelector("[data-codex-intelligence-trigger]")
+ ?.click();
+ await vi.waitFor(() =>
+ expect(
+ document.querySelector(".csm-stage canvas"),
+ ).not.toBeNull(),
+ );
+ const stage = document.querySelector(".csm-stage");
+ expect(stage).toHaveFocus();
+ expect(stage?.querySelector("canvas")).not.toBeNull();
+ const keyboardAction = new KeyboardEvent("keydown", {
+ key:
+ game === "balance"
+ ? "ArrowLeft"
+ : game === "route"
+ ? "ArrowRight"
+ : "Enter",
+ bubbles: true,
+ cancelable: true,
+ });
+ stage?.dispatchEvent(keyboardAction);
+ expect(keyboardAction.defaultPrevented).toBe(true);
+ if (game === "balance") {
+ stage?.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "m",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ stage?.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "ArrowUp",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ stage?.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Enter",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ expect(document.querySelectorAll(".csm-config-diff s")).toHaveLength(3);
+ }
+ animationFrames.shift()?.(performance.now() + 16);
+ expect(context.clearRect).toHaveBeenCalled();
+ document.querySelector(".csm-close")?.click();
+ expect(document.querySelector(".csm-stage")).toBeNull();
+ }
+ });
+
+ it.each(["marbles", "claw", "toss", "balance", "route"] as const)(
+ "%s always resolves to a complete model, reasoning, and speed loadout",
+ async (game) => {
+ document.body.innerHTML = codexFixture("task");
+ installCodexIntelligenceFixture();
+
+ await runtime().apply(nativeRefined, "dark", "compatibility", 1, {
+ composerInteractionMode: game,
+ locale: "en",
+ reduceMotion: true,
+ });
+ document
+ .querySelector("[data-codex-intelligence-trigger]")
+ ?.click();
+
+ await vi.waitFor(() =>
+ expect(document.querySelector(".csm-proposal")).not.toBeNull(),
+ );
+ const proposal = document.querySelector(".csm-proposal") as HTMLElement;
+ const terms = Array.from(proposal.querySelectorAll("dt")).map(
+ (term) => term.textContent,
+ );
+ expect(terms).toEqual(["Model", "Reasoning effort", "Speed"]);
+ expect(proposal.querySelectorAll("s")).toHaveLength(3);
+ expect(proposal.textContent).not.toMatch(/score|points|reward/i);
+
+ document.querySelector(".csm-close")?.click();
+ },
+ );
+
+ it("previews an exact configuration diff and verifies the native setting update", async () => {
+ document.body.innerHTML = codexFixture("task");
+ installCodexIntelligenceFixture();
+ const composer = document.querySelector(
+ ".composer-surface-chrome",
+ ) as HTMLElement;
+ const originalPrompt = composer.textContent;
+ vi.spyOn(composer, "getBoundingClientRect").mockReturnValue({
+ x: 180,
+ y: 540,
+ left: 180,
+ top: 540,
+ right: 820,
+ bottom: 650,
+ width: 640,
+ height: 110,
+ toJSON: () => ({}),
+ });
+
+ await runtime().apply(nativeRefined, "dark", "compatibility", 1, {
+ composerInteractionMode: "marbles",
+ locale: "en",
+ reduceMotion: true,
+ });
+ document
+ .querySelector("[data-codex-intelligence-trigger]")
+ ?.click();
+ await vi.waitFor(() =>
+ expect(document.querySelector(".csm-proposal")).not.toBeNull(),
+ );
+ const proposal = document.querySelector(".csm-proposal") as HTMLElement;
+ expect(proposal.textContent).toContain("Model");
+ expect(proposal.querySelectorAll("s")).toHaveLength(3);
+
+ const apply = Array.from(proposal.querySelectorAll("button")).find(
+ (button) => button.textContent === "Apply configuration",
+ );
+ apply?.click();
+ await vi.waitFor(() =>
+ expect(proposal.textContent).toContain(
+ "Configuration applied and verified",
+ ),
+ );
+ const trigger = document.querySelector(
+ "[data-codex-intelligence-trigger]",
+ );
+ expect(
+ trigger?.querySelector("[data-model-picker-model-row]")?.textContent,
+ ).toBe("5.4");
+ expect(trigger?.dataset.selectedReasoningEffort).toBe("extra-high");
+ expect(
+ document.querySelector("[aria-label='Speed Standard']"),
+ ).not.toBeNull();
+ expect(composer.textContent).toBe(originalPrompt);
+ });
+
+ it("stops safely when the native Codex configuration cannot be identified", async () => {
+ document.body.innerHTML = codexFixture("task");
+ installCodexIntelligenceFixture();
+ const composer = document.querySelector(
+ ".composer-surface-chrome",
+ ) as HTMLElement;
+ vi.spyOn(composer, "getBoundingClientRect").mockReturnValue({
+ x: 180,
+ y: 540,
+ left: 180,
+ top: 540,
+ right: 820,
+ bottom: 650,
+ width: 640,
+ height: 110,
+ toJSON: () => ({}),
+ });
+
+ await runtime().apply(nativeRefined, "dark", "compatibility", 1, {
+ composerInteractionMode: "balance",
+ locale: "zh-CN",
+ reduceMotion: true,
+ });
+ document
+ .querySelectorAll("[data-fixture-field='reasoning'] [role='menuitem']")
+ .forEach((option, index) => {
+ if (index > 0) option.remove();
+ });
+ const trigger = document.querySelector(
+ "[data-codex-intelligence-trigger]",
+ );
+ trigger?.click();
+
+ await vi.waitFor(() =>
+ expect(document.querySelector(".csm-stage--unavailable")).not.toBeNull(),
+ );
+ expect(document.querySelector(".csm-proposal")).toBeNull();
+ expect(document.body.textContent).toContain(
+ "未识别到可安全调整的 Codex 配置",
+ );
+ expect(
+ Array.from(document.querySelectorAll("[role='menu']")).some(
+ (menu) => !menu.hidden && menu.style.opacity !== "0",
+ ),
+ ).toBe(false);
+ const official = Array.from(
+ document.querySelectorAll(".csm-stage button"),
+ ).find((button) => button.textContent === "改用官方设置");
+ official?.click();
+ await vi.waitFor(() =>
+ expect(
+ Array.from(
+ document.querySelectorAll("[role='menu']"),
+ ).some((menu) => !menu.hidden && menu.style.opacity !== "0"),
+ ).toBe(true),
+ );
});
it.each([
@@ -540,6 +1026,29 @@ describe("injected compatibility runtime", () => {
expect(stylesheet?.textContent).toContain(
'data-codex-styler-icons="themed"] body > [data-codex-styler-overlay-root] :is(button, [role="button"], [role="tab"], [role="menuitem"], [role="option"])',
);
+ expect(stylesheet?.textContent).toContain(
+ ':not([role="switch"]):not([role="checkbox"]):not([role="radio"])',
+ );
+ expect(stylesheet?.textContent).toContain(
+ '):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):focus-visible',
+ );
+ expect(stylesheet?.textContent).toContain(
+ '):not([role="switch"]):not([role="checkbox"]):not([role="radio"]):is(:disabled, [aria-disabled="true"])',
+ );
+ expect(stylesheet?.textContent).toContain(
+ '[role="switch"]:is(\n [aria-checked="true"]',
+ );
+ const switchTreatments = stylesheet?.textContent
+ ?.split("/* Switches keep Codex's native geometry")[1]
+ ?.split(
+ 'html[data-codex-styler][data-codex-styler-mode="semantic"] body > [data-codex-styler-app-root] :is(\n button,',
+ )[0];
+ expect(switchTreatments).toBeDefined();
+ expect(switchTreatments).toContain("accent-color:");
+ expect(switchTreatments).toContain("outline-offset: 2px !important");
+ expect(switchTreatments).not.toMatch(
+ /(?:^|\n)\s*(?:width|height|padding|margin|border-radius|transform|box-shadow)\s*:/,
+ );
expect(stylesheet?.textContent).toContain(
"color: var(--codex-styler-icon) !important",
);
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index ca1139d..cda84a1 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -630,7 +630,9 @@ button:disabled {
.home-current__preview {
position: relative;
min-width: 0;
+ overflow: hidden;
padding: 16px;
+ border-radius: calc(var(--radius-lg) - 1px);
background: #171a20;
}
@@ -857,7 +859,7 @@ button:disabled {
min-height: 430px;
overflow: hidden;
border: 1px solid var(--line-strong);
- border-radius: 2px 22px 22px 2px;
+ border-radius: 22px;
background: var(--shell-elevated);
box-shadow: var(--shadow);
}
@@ -1139,7 +1141,7 @@ button:disabled {
overflow: hidden;
padding: 0;
border: 0;
- border-radius: 3px 12px 12px 3px;
+ border-radius: 12px;
background: #202522;
}
@@ -1243,7 +1245,7 @@ button:disabled {
min-height: 390px;
overflow: hidden;
border: 1px solid var(--preview-border-strong);
- border-radius: 4px 14px 14px 4px;
+ border-radius: var(--preview-radius);
color: var(--preview-text);
background: var(--preview-bg);
isolation: isolate;
@@ -3187,7 +3189,7 @@ button:disabled {
.library-theme__visual {
height: 86px;
- border-radius: 5px 10px 10px 5px;
+ border-radius: 10px;
background-position: center;
background-size: cover;
}
@@ -3959,7 +3961,7 @@ button:disabled {
.new-theme-existing-list__preview {
height: 56px;
- border-radius: 5px 10px 10px 5px;
+ border-radius: 10px;
background-position: center;
background-size: cover;
}
diff --git a/apps/desktop/src/styles/interactions.css b/apps/desktop/src/styles/interactions.css
new file mode 100644
index 0000000..0122253
--- /dev/null
+++ b/apps/desktop/src/styles/interactions.css
@@ -0,0 +1,309 @@
+.interactions-page {
+ width: min(1200px, calc(100% - 44px));
+ padding-top: 24px;
+}
+
+.interactions-heading {
+ margin-bottom: 18px;
+}
+
+.interactions-heading p {
+ max-width: 760px;
+}
+
+.interactions-workspace {
+ display: grid;
+ min-height: min(620px, calc(100dvh - 245px));
+ grid-template-columns: minmax(300px, 0.8fr) minmax(0, 1.5fr);
+ gap: 16px;
+}
+
+.interaction-mode-list,
+.interaction-detail {
+ min-width: 0;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ background: var(--surface-base);
+ box-shadow: var(--shadow-panel);
+}
+
+.interaction-mode-list {
+ display: grid;
+ align-content: start;
+ gap: 5px;
+ padding: 8px;
+}
+
+.interaction-mode {
+ display: grid;
+ min-width: 0;
+ min-height: 82px;
+ grid-template-columns: 38px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 11px;
+ padding: 11px 12px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ text-align: left;
+ background: transparent;
+ cursor: pointer;
+ transition:
+ border-color var(--motion-standard) var(--ease-standard),
+ background var(--motion-standard) var(--ease-standard),
+ transform var(--motion-micro) var(--ease-standard);
+}
+
+.interaction-mode:hover {
+ background: var(--surface-hover);
+}
+
+.interaction-mode:active {
+ transform: scale(0.992);
+}
+
+.interaction-mode.is-selected {
+ border-color: color-mix(in srgb, var(--accent) 48%, var(--border-strong));
+ background: var(--surface-selected);
+}
+
+.interaction-mode__icon,
+.interaction-detail__icon {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border-radius: var(--radius-sm);
+ color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.interaction-mode__copy {
+ display: grid;
+ min-width: 0;
+ gap: 3px;
+}
+
+.interaction-mode__copy small,
+.interaction-detail__header small {
+ color: var(--text-tertiary);
+ font-size: 9px;
+ font-weight: 720;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.interaction-mode__copy strong {
+ font-size: var(--type-label);
+ font-weight: 680;
+}
+
+.interaction-mode__copy > span,
+.interaction-mode__state {
+ color: var(--text-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.4;
+}
+
+.interaction-mode__state {
+ color: var(--accent-ink);
+ font-weight: 680;
+}
+
+.interaction-detail {
+ display: grid;
+ align-content: start;
+ grid-template-rows: auto auto auto auto;
+ overflow: hidden;
+}
+
+.interaction-detail__header {
+ display: grid;
+ grid-template-columns: 44px minmax(0, 1fr);
+ gap: 14px;
+ padding: 24px 26px 20px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.interaction-detail__icon {
+ width: 44px;
+ height: 44px;
+}
+
+.interaction-detail__header h2 {
+ margin: 5px 0 6px;
+ font-size: clamp(24px, 3vw, 34px);
+ letter-spacing: -0.035em;
+}
+
+.interaction-detail__header p {
+ max-width: 620px;
+ margin: 0;
+ color: var(--text-secondary);
+ line-height: 1.55;
+}
+
+.interaction-placement {
+ display: grid;
+ gap: 16px;
+ margin: 22px 26px 20px;
+ padding: 24px;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ background:
+ radial-gradient(circle at 78% 18%, var(--accent-soft), transparent 36%),
+ var(--surface-subtle);
+}
+
+.interaction-placement__composer {
+ display: flex;
+ min-height: 88px;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 18px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-lg);
+ color: var(--text-tertiary);
+ background: var(--surface-base);
+ box-shadow: 0 16px 36px rgb(0 0 0 / 9%);
+}
+
+.interaction-placement__control {
+ display: inline-flex;
+ min-height: 36px;
+ align-items: center;
+ gap: 8px;
+ padding: 0 12px;
+ border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--border-strong));
+ border-radius: 999px;
+ color: var(--accent-ink);
+ background: var(--accent-soft);
+ white-space: nowrap;
+}
+
+.interaction-placement__caption {
+ display: grid;
+ gap: 4px;
+}
+
+.interaction-placement__caption span {
+ color: var(--text-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.5;
+}
+
+.interaction-binding {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(270px, 1.15fr);
+ align-items: center;
+ gap: 18px;
+ margin: 0 26px 20px;
+ padding: 14px 16px;
+ border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border-subtle));
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--accent-soft) 44%, var(--surface-subtle));
+}
+
+.interaction-binding > div {
+ display: grid;
+ gap: 4px;
+}
+
+.interaction-binding strong {
+ font-size: var(--type-label);
+}
+
+.interaction-binding > div > span {
+ color: var(--text-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.45;
+}
+
+.interaction-binding ol {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.interaction-binding li {
+ display: grid;
+ min-width: 0;
+ min-height: 48px;
+ place-content: center;
+ place-items: center;
+ gap: 5px;
+ padding: 7px;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ color: var(--accent-ink);
+ text-align: center;
+ background: var(--surface-base);
+}
+
+.interaction-binding li span {
+ overflow: hidden;
+ font-size: 10px;
+ font-weight: 650;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.interaction-assurances {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin: 0;
+ padding: 0 26px 24px;
+}
+
+.interaction-assurances > div {
+ padding: 14px;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ background: var(--surface-subtle);
+}
+
+.interaction-assurances dt {
+ font-weight: 680;
+}
+
+.interaction-assurances dd {
+ margin: 5px 0 0;
+ color: var(--text-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.5;
+}
+
+@media (max-width: 960px) {
+ .interactions-workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .interaction-mode-list {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 680px) {
+ .interactions-page {
+ width: calc(100% - 28px);
+ }
+
+ .interaction-mode-list,
+ .interaction-assurances {
+ grid-template-columns: 1fr;
+ }
+
+ .interaction-placement__composer {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .interaction-binding {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/desktop/src/styles/workspaces.css b/apps/desktop/src/styles/workspaces.css
index 9b58ea8..57aea3b 100644
--- a/apps/desktop/src/styles/workspaces.css
+++ b/apps/desktop/src/styles/workspaces.css
@@ -19,14 +19,14 @@
}
.home-current {
- min-height: clamp(380px, 46vh, 520px);
+ min-height: clamp(380px, 54vh, 560px);
grid-template-columns: minmax(0, 1.55fr) minmax(280px, 0.68fr);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-panel);
}
.home-current .workspace-preview {
- min-height: clamp(348px, calc(46vh - 32px), 488px);
+ min-height: clamp(348px, calc(54vh - 32px), 528px);
}
.home-current__content {
diff --git a/apps/desktop/src/test/fixtures/codex-dom.ts b/apps/desktop/src/test/fixtures/codex-dom.ts
index 2a2b1d3..914d65a 100644
--- a/apps/desktop/src/test/fixtures/codex-dom.ts
+++ b/apps/desktop/src/test/fixtures/codex-dom.ts
@@ -84,3 +84,135 @@ export function portalFixture(kind: "dialog" | "toast" | "menu"): HTMLElement {
}
return portal;
}
+
+export function installCodexIntelligenceFixture(): void {
+ const composer = document.querySelector(".composer-surface-chrome");
+ if (
+ !composer ||
+ document.querySelector("[data-codex-intelligence-trigger]")
+ ) {
+ return;
+ }
+ const trigger = document.createElement("button");
+ trigger.type = "button";
+ trigger.dataset.codexIntelligenceTrigger = "true";
+ trigger.dataset.selectedReasoningEffort = "high";
+ trigger.dataset.state = "closed";
+ trigger.setAttribute("aria-haspopup", "menu");
+ trigger.innerHTML = `
+ 5.6 Sol
+ High
+ `;
+
+ const rootMenu = document.createElement("div");
+ rootMenu.setAttribute("role", "menu");
+ rootMenu.hidden = true;
+ const modelRow = document.createElement("button");
+ modelRow.type = "button";
+ modelRow.setAttribute("aria-label", "Model 5.6 Sol");
+ modelRow.innerHTML = "5.6 Sol ";
+ const reasoningRow = document.createElement("button");
+ reasoningRow.type = "button";
+ reasoningRow.setAttribute("aria-label", "Effort High");
+ reasoningRow.textContent = "Effort High";
+ const speedRow = document.createElement("button");
+ speedRow.type = "button";
+ speedRow.setAttribute("aria-label", "Speed Fast");
+ speedRow.textContent = "Speed Fast";
+ rootMenu.append(modelRow, reasoningRow, speedRow);
+
+ const createOptions = (
+ field: "model" | "reasoning" | "speed",
+ labels: string[],
+ selected: string,
+ ) => {
+ const menu = document.createElement("div");
+ menu.setAttribute("role", "menu");
+ menu.dataset.fixtureField = field;
+ menu.hidden = true;
+ labels.forEach((label) => {
+ const option = document.createElement("button");
+ option.type = "button";
+ option.setAttribute("role", "menuitem");
+ option.textContent = label;
+ if (label === selected && field === "reasoning") {
+ option.dataset.reasoningSelected = "true";
+ }
+ option.addEventListener("click", () => {
+ if (field === "model") {
+ trigger.querySelector("[data-model-picker-model-row]")!.textContent =
+ label;
+ modelRow.querySelector("[data-model-picker-model-row]")!.textContent =
+ label;
+ modelRow.setAttribute("aria-label", `Model ${label}`);
+ } else if (field === "reasoning") {
+ const effortId = label.toLocaleLowerCase().replaceAll(" ", "-");
+ trigger.dataset.selectedReasoningEffort = effortId;
+ trigger.querySelector("[data-fixture-effort-label]")!.textContent =
+ label;
+ reasoningRow.setAttribute("aria-label", `Effort ${label}`);
+ reasoningRow.textContent = `Effort ${label}`;
+ menu
+ .querySelectorAll("[data-reasoning-selected]")
+ .forEach((item) => item.removeAttribute("data-reasoning-selected"));
+ option.dataset.reasoningSelected = "true";
+ } else {
+ speedRow.setAttribute("aria-label", `Speed ${label}`);
+ speedRow.textContent = `Speed ${label}`;
+ }
+ rootMenu.hidden = true;
+ menu.hidden = true;
+ trigger.dataset.state = "closed";
+ });
+ menu.appendChild(option);
+ });
+ return menu;
+ };
+
+ const modelMenu = createOptions("model", ["5.6 Sol", "5.4"], "5.6 Sol");
+ const reasoningMenu = createOptions(
+ "reasoning",
+ ["Low", "Medium", "High", "Extra High"],
+ "High",
+ );
+ const speedMenu = createOptions("speed", ["Standard", "Fast"], "Fast");
+ const closeAll = () => {
+ rootMenu.hidden = true;
+ modelMenu.hidden = true;
+ reasoningMenu.hidden = true;
+ speedMenu.hidden = true;
+ trigger.dataset.state = "closed";
+ };
+ trigger.addEventListener("click", () => {
+ const opening = trigger.dataset.state !== "open";
+ closeAll();
+ trigger.dataset.state = opening ? "open" : "closed";
+ rootMenu.hidden = !opening;
+ });
+ trigger.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ /* The real Radix trigger commits its open state after the keyboard event.
+ Keep the fixture asynchronous so adapters cannot rely on a synchronous
+ data-state mutation and then accidentally send a second toggle. */
+ window.setTimeout(() => {
+ const opening = trigger.dataset.state !== "open";
+ closeAll();
+ trigger.dataset.state = opening ? "open" : "closed";
+ rootMenu.hidden = !opening;
+ }, 0);
+ });
+ modelRow.addEventListener("click", () => {
+ modelMenu.hidden = false;
+ });
+ reasoningRow.addEventListener("click", () => {
+ reasoningMenu.hidden = false;
+ });
+ speedRow.addEventListener("click", () => {
+ speedMenu.hidden = false;
+ });
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && trigger.isConnected) closeAll();
+ });
+ composer.parentElement?.insertBefore(trigger, composer);
+ document.body.append(rootMenu, modelMenu, reasoningMenu, speedMenu);
+}
diff --git a/apps/site/package.json b/apps/site/package.json
index 986aefe..c7dd820 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -1,6 +1,6 @@
{
"name": "@codex-styler/site",
- "version": "0.2.0-beta.7",
+ "version": "0.2.0-beta.8",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/site/public/media/composer-interactions-capsule-crane.webp b/apps/site/public/media/composer-interactions-capsule-crane.webp
new file mode 100644
index 0000000..7ace9c9
Binary files /dev/null and b/apps/site/public/media/composer-interactions-capsule-crane.webp differ
diff --git a/apps/site/public/media/composer-interactions-manager.webp b/apps/site/public/media/composer-interactions-manager.webp
new file mode 100644
index 0000000..f140e75
Binary files /dev/null and b/apps/site/public/media/composer-interactions-manager.webp differ
diff --git a/apps/site/public/media/composer-interactions-result.webp b/apps/site/public/media/composer-interactions-result.webp
new file mode 100644
index 0000000..bf3550a
Binary files /dev/null and b/apps/site/public/media/composer-interactions-result.webp differ
diff --git a/apps/site/src/components/LandingPage.astro b/apps/site/src/components/LandingPage.astro
index e00689a..5287868 100644
--- a/apps/site/src/components/LandingPage.astro
+++ b/apps/site/src/components/LandingPage.astro
@@ -7,9 +7,9 @@ const zh = locale === "zh-CN";
const base = import.meta.env.BASE_URL.replace(/\/$/, "");
const docsBase = base + (zh ? "/zh-cn/docs/" : "/docs/");
const repo = "https://github.com/xuhuanstudio/codex-styler";
-const releaseTag = "v0.2.0-beta.7";
-const macAsset = "Codex-Styler_0.2.0-beta.7_aarch64-unsigned.dmg";
-const windowsAsset = "Codex-Styler_0.2.0-beta.7_x64-unsigned-setup.exe";
+const releaseTag = "v0.2.0-beta.8";
+const macAsset = "Codex-Styler_0.2.0-beta.8_aarch64-unsigned.dmg";
+const windowsAsset = "Codex-Styler_0.2.0-beta.8_x64-unsigned-setup.exe";
const macDownloadUrl = `${repo}/releases/download/${releaseTag}/${macAsset}`;
const windowsDownloadUrl = `${repo}/releases/download/${releaseTag}/${windowsAsset}`;
const copy = zh
@@ -144,6 +144,7 @@ const copy = zh
diff --git a/apps/site/src/data/docs.ts b/apps/site/src/data/docs.ts
index 0286d4d..e4508a9 100644
--- a/apps/site/src/data/docs.ts
+++ b/apps/site/src/data/docs.ts
@@ -5,6 +5,11 @@ export interface DocPage {
title: Record
;
description: Record;
eyebrow: Record;
+ media?: {
+ src: string;
+ alt: Record;
+ caption: Record;
+ };
body: Record>;
}
@@ -184,6 +189,82 @@ export const docs: DocPage[] = [
],
},
},
+ {
+ slug: "composer-interactions",
+ title: {
+ en: "Playful, verifiable Codex configuration",
+ "zh-CN": "可玩且可验证的 Codex 配置交互",
+ },
+ description: {
+ en: "Turn Codex's native configuration control into a short, theme-aware interaction that selects and verifies a real model, reasoning effort, and response speed.",
+ "zh-CN":
+ "把 Codex 原生配置控件变成短时、主题自适应的交互,并真实选择、写入和验证模型、推理强度与响应速度。",
+ },
+ eyebrow: {
+ en: "COMPOSER INTERACTIONS / BETA",
+ "zh-CN": "输入区交互玩法 / BETA",
+ },
+ media: {
+ src: "/media/composer-interactions-capsule-crane.webp",
+ alt: {
+ en: "Capsule Crane showing five complete Codex configuration loadouts",
+ "zh-CN": "胶囊机展示五套完整 Codex 配置",
+ },
+ caption: {
+ en: "Capsule Crane: every visible capsule contains a complete model, reasoning, and speed loadout.",
+ "zh-CN":
+ "配置胶囊机:每枚可见胶囊都包含一套完整的模型、推理强度和速度配置。",
+ },
+ },
+ body: {
+ en: [
+ {
+ heading: "One native entry, five purposeful interactions",
+ paragraphs: [
+ "Choose Triple Drop, Capsule Crane, Loadout Forge, Three-axis Console, or Mission Map from the manager. The selected experience replaces Codex's native configuration control in place; it does not add a second launcher beside the composer.",
+ "Each interaction gives the three real settings a visible role. Triple Drop chooses one field per landing, Capsule Crane exposes complete loadouts before capture, Loadout Forge locks three independent rings, Three-axis Console tunes a model and a reasoning-speed matrix, and Mission Map pairs a model engine with a transparent task route.",
+ ],
+ },
+ {
+ heading: "The result is a real configuration, not a score",
+ paragraphs: [
+ "Every completed play returns exactly one model, one reasoning effort, and one response speed drawn from the options currently visible in Codex. Unchanged values are labelled Keep current instead of pretending that every round must change every field.",
+ "Before anything changes, Styler shows a field-by-field diff. Apply configuration writes all three fields through the native control and reads them back. The result is accepted only when the observed Codex state matches the proposal.",
+ ],
+ },
+ {
+ heading: "Local, reversible, and theme-aware",
+ paragraphs: [
+ "The interaction never reads, changes, or submits prompt content. Its language follows the Codex Styler language setting, and its color, material, radius, blur, motion intensity, and frame-rate budget follow the active theme.",
+ "If the native configuration structure cannot be read reliably, no guessed click or silent setting change occurs. The interaction keeps an explicit path back to Codex's official control. Reduce motion replaces continuous animation with a deterministic complete-loadout result.",
+ ],
+ },
+ ],
+ "zh-CN": [
+ {
+ heading: "一个原生入口,五种有实际意义的玩法",
+ paragraphs: [
+ "可在管理器中选择三段落珠、配置胶囊机、三环锻造、三轴控制台或任务航图。所选玩法会在原位置替换 Codex 原生配置控件,不会在输入框旁再增加第二个启动器。",
+ "每种玩法都让三项真实设置直接参与操作:三段落珠逐项决定配置,胶囊机在抓取前展示完整方案,三环锻造分别锁定三项参数,三轴控制台直接调校模型与推理/速度矩阵,任务航图则把模型引擎与透明的任务路线组合起来。",
+ ],
+ },
+ {
+ heading: "结果是真实配置,不是装饰分数",
+ paragraphs: [
+ "每局结束都会从 Codex 当前真实可选项中产生一个模型、一档推理强度和一档响应速度。没有变化的项目会明确显示“保持不变”,而不是假装每局都必须修改所有字段。",
+ "写入前会逐项显示配置差异;确认“应用配置”后,通过原生控件写入三项设置并重新读取。只有 Codex 中观察到的最终状态与提案一致,操作才会被判定为成功。",
+ ],
+ },
+ {
+ heading: "本地、可恢复并自动适配主题",
+ paragraphs: [
+ "玩法不会读取、修改或提交输入内容。显示语言跟随 Codex Styler 的语言设置;颜色、材质、圆角、模糊、动态强度与帧率预算则继承当前主题。",
+ "如果无法可靠识别原生配置结构,系统不会猜测点击或静默修改设置,并始终保留明确返回 Codex 官方控件的路径。减少动态模式会用确定性的完整配置结果替代持续动画。",
+ ],
+ },
+ ],
+ },
+ },
{
slug: "create-companion",
title: {
@@ -310,7 +391,7 @@ export const docs: DocPage[] = [
{
heading: "Current Beta targets",
paragraphs: [
- "Unsigned Beta installers are available for macOS on Apple Silicon and Windows 11 x64. macOS has the current real-device evidence; Windows Beta 7 is produced and structurally checked in CI but explicitly awaits community validation. The RC gate requires at least two complete independent device reports per platform.",
+ "Unsigned Beta installers are available for macOS on Apple Silicon and Windows 11 x64. macOS has the current real-device evidence; Windows Beta 8 is produced and structurally checked in CI but explicitly awaits community validation. The RC gate requires at least two complete independent device reports per platform.",
],
},
{
@@ -324,7 +405,7 @@ export const docs: DocPage[] = [
{
heading: "当前 Beta 目标",
paragraphs: [
- "当前提供未签名的 Apple Silicon macOS 与 Windows 11 x64 Beta 安装包。macOS 已有现阶段真实设备证据;Windows Beta 7 由 CI 构建并完成结构校验,但明确等待社区实机验证。进入 RC 前,两个平台都至少需要两份独立的完整实机报告。",
+ "当前提供未签名的 Apple Silicon macOS 与 Windows 11 x64 Beta 安装包。macOS 已有现阶段真实设备证据;Windows Beta 8 由 CI 构建并完成结构校验,但明确等待社区实机验证。进入 RC 前,两个平台都至少需要两份独立的完整实机报告。",
],
},
{
diff --git a/apps/site/src/layouts/BaseLayout.astro b/apps/site/src/layouts/BaseLayout.astro
index 74abf33..057b1f3 100644
--- a/apps/site/src/layouts/BaseLayout.astro
+++ b/apps/site/src/layouts/BaseLayout.astro
@@ -19,7 +19,7 @@ const english = new URL(base + path, Astro.site);
const chinese = new URL(base + "/zh-cn" + path, Astro.site);
const socialImage = new URL(base + image, Astro.site);
const repo = "https://github.com/xuhuanstudio/codex-styler";
-const releaseTag = "v0.2.0-beta.7";
+const releaseTag = "v0.2.0-beta.8";
const downloadUrl = `${repo}/releases/tag/${releaseTag}`;
const isChinese = locale === "zh-CN";
const structuredData = {
@@ -28,7 +28,7 @@ const structuredData = {
name: "Codex Styler",
applicationCategory: "DeveloperApplication",
operatingSystem: "macOS 13 or later; Windows 11",
- softwareVersion: "0.2.0-beta.7",
+ softwareVersion: "0.2.0-beta.8",
description,
url: canonical.toString(),
downloadUrl,
diff --git a/apps/site/src/pages/docs/[...slug].astro b/apps/site/src/pages/docs/[...slug].astro
index 04e365a..962c408 100644
--- a/apps/site/src/pages/docs/[...slug].astro
+++ b/apps/site/src/pages/docs/[...slug].astro
@@ -29,6 +29,12 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "");
{doc.eyebrow.en}
{doc.title.en}
{doc.description.en}
+ {doc.media && (
+
+
+ {doc.media.caption.en}
+
+ )}
{doc.body.en.map((section, index) => (
{String(index + 1).padStart(2, "0")}
@@ -44,4 +50,3 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "");
-
diff --git a/apps/site/src/pages/zh-cn/docs/[...slug].astro b/apps/site/src/pages/zh-cn/docs/[...slug].astro
index 1c0d85f..4b4e000 100644
--- a/apps/site/src/pages/zh-cn/docs/[...slug].astro
+++ b/apps/site/src/pages/zh-cn/docs/[...slug].astro
@@ -29,6 +29,12 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "");
{doc.eyebrow["zh-CN"]}
{doc.title["zh-CN"]}
{doc.description["zh-CN"]}
+ {doc.media && (
+
+
+ {doc.media.caption["zh-CN"]}
+
+ )}
{doc.body["zh-CN"].map((section, index) => (
{String(index + 1).padStart(2, "0")}
@@ -44,4 +50,3 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "");
-
diff --git a/apps/site/src/styles/global.css b/apps/site/src/styles/global.css
index 2f6b074..8bfd37b 100644
--- a/apps/site/src/styles/global.css
+++ b/apps/site/src/styles/global.css
@@ -30,7 +30,11 @@ body {
margin: 0;
color: var(--ink);
background:
- radial-gradient(circle at 14% 0%, rgba(255, 255, 255, 0.7), transparent 26%),
+ radial-gradient(
+ circle at 14% 0%,
+ rgba(255, 255, 255, 0.7),
+ transparent 26%
+ ),
var(--paper);
}
@@ -162,9 +166,17 @@ img {
background: rgba(235, 241, 234, 0.14);
}
-.gallery-native-preview i:nth-child(1) { top: 106px; }
-.gallery-native-preview i:nth-child(2) { top: 140px; width: 61%; }
-.gallery-native-preview i:nth-child(3) { top: 174px; width: 38%; }
+.gallery-native-preview i:nth-child(1) {
+ top: 106px;
+}
+.gallery-native-preview i:nth-child(2) {
+ top: 140px;
+ width: 61%;
+}
+.gallery-native-preview i:nth-child(3) {
+ top: 174px;
+ width: 38%;
+}
.gallery-native-preview b {
position: absolute;
@@ -668,7 +680,11 @@ img {
padding: 38px;
color: #eef2ec;
background:
- radial-gradient(circle at 100% 0%, rgba(161, 189, 169, 0.14), transparent 40%),
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(161, 189, 169, 0.14),
+ transparent 40%
+ ),
#172019;
}
@@ -714,7 +730,11 @@ img {
position: relative;
overflow: hidden;
background:
- radial-gradient(circle at 72% 28%, rgba(126, 151, 134, 0.28), transparent 25%),
+ radial-gradient(
+ circle at 72% 28%,
+ rgba(126, 151, 134, 0.28),
+ transparent 25%
+ ),
linear-gradient(145deg, #d6d9d1, #edefe9);
}
@@ -908,6 +928,25 @@ img {
line-height: 1.75;
}
+.doc-media {
+ margin: -36px 0 62px;
+}
+
+.doc-media img {
+ display: block;
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ box-shadow: 0 22px 70px rgba(31, 43, 34, 0.12);
+}
+
+.doc-media figcaption {
+ margin-top: 12px;
+ color: var(--faint);
+ font-size: 9px;
+ line-height: 1.6;
+}
+
.doc-content > section {
position: relative;
padding: 36px 0 46px 55px;
@@ -946,7 +985,11 @@ img {
border-radius: 3px 18px 18px 3px;
color: #edf3ee;
background:
- radial-gradient(circle at 86% 12%, rgba(159, 193, 169, 0.2), transparent 40%),
+ radial-gradient(
+ circle at 86% 12%,
+ rgba(159, 193, 169, 0.2),
+ transparent 40%
+ ),
#172019;
}
diff --git a/docs/media/composer-interactions-capsule-crane.webp b/docs/media/composer-interactions-capsule-crane.webp
new file mode 100644
index 0000000..7ace9c9
Binary files /dev/null and b/docs/media/composer-interactions-capsule-crane.webp differ
diff --git a/docs/media/composer-interactions-manager.webp b/docs/media/composer-interactions-manager.webp
new file mode 100644
index 0000000..f140e75
Binary files /dev/null and b/docs/media/composer-interactions-manager.webp differ
diff --git a/docs/media/composer-interactions-result.webp b/docs/media/composer-interactions-result.webp
new file mode 100644
index 0000000..bf3550a
Binary files /dev/null and b/docs/media/composer-interactions-result.webp differ
diff --git a/docs/product-quality-plan.md b/docs/product-quality-plan.md
index 57462c0..c0807cc 100644
--- a/docs/product-quality-plan.md
+++ b/docs/product-quality-plan.md
@@ -259,6 +259,26 @@ working control behind another page, revert it.
assignments use the same angular sectors, so interpolated poses no longer
make a valid idle motion practically unreachable after installation.
+## Beta 8 — verified composer interactions
+
+- each Composer Interaction must give model, reasoning effort, and response
+ speed an observable role in the play action and produce one complete
+ configuration from the options visible in the current Codex control;
+- every result must expose a field-by-field diff, label unchanged values, wait
+ for explicit confirmation, write all three fields through the native control,
+ and read them back before reporting success;
+- the native menu must remain visually concealed while it is inspected and
+ must never flash before the selected experience appears;
+- interaction language follows the manager's resolved English or Simplified
+ Chinese locale, while palette, material, radius, blur, motion intensity,
+ reduced motion, and frame budget follow the active theme;
+- managed first launch must wait for a stable renderer and confirm that the
+ runtime survived Electron's bootstrap handoff without requiring a second or
+ third restart;
+- release evidence includes English manager, live interaction, and complete
+ result screenshots plus a real apply, verify, and restore pass on the current
+ macOS Codex session.
+
## RC — reliability proof
- exercise the current Codex Desktop on Home, Task, Settings, Dialog, Right
diff --git a/docs/security-model.md b/docs/security-model.md
index bd403fc..b43e304 100644
--- a/docs/security-model.md
+++ b/docs/security-model.md
@@ -11,13 +11,14 @@ Codex Styler is designed so a failed theme can be removed without repairing or r
## Trust boundaries
-| Boundary | Untrusted input | Control |
-| ---------------- | --------------------------------------- | -------------------------------------------------------------- |
-| Theme import | ZIP paths, manifest values, image bytes | Schema, allowlist, magic bytes, size and traversal checks |
-| Codex connection | local CDP targets | random loopback port, managed process, expected page target |
-| Injected runtime | theme data | JSON serialization, no script fields, one removable root |
-| Desktop commands | webview calls | Tauri capability allowlist and typed command surface |
-| Updates | GitHub release metadata | opt-in checks and signed updater artifacts for stable releases |
+| Boundary | Untrusted input | Control |
+| ----------------- | --------------------------------------- | ------------------------------------------------------------------ |
+| Theme import | ZIP paths, manifest values, image bytes | Schema, allowlist, magic bytes, size and traversal checks |
+| Codex connection | local CDP targets | random loopback port, managed process, expected page target |
+| Injected runtime | theme data | JSON serialization, no script fields, one removable root |
+| Composer settings | visible native configuration options | semantic discovery, explicit confirmation, write-back verification |
+| Desktop commands | webview calls | Tauri capability allowlist and typed command surface |
+| Updates | GitHub release metadata | opt-in checks and signed updater artifacts for stable releases |
## Theme package controls
@@ -43,6 +44,8 @@ CDP is a powerful local debugging interface. Styler exposes it only on loopback
Codex releases can change page structure independently of their version number. Enhanced mode applies the semantic adapter, verifies required anchors and computed surface styles, and falls back to the isolated scene layer only when a runtime health check fails or a later redraw removes the injected runtime. A version mismatch alone is informational and does not trigger fallback. Conservative mode always uses the isolated layer and does not apply semantic interface styling.
+Composer Interactions only inspect the model, reasoning-effort, and speed options exposed by Codex's visible native configuration control. They do not read, change, or submit prompt content. Every play produces a complete configuration diff and requires explicit confirmation. The adapter writes all three fields through the native control and accepts success only after reading the same values back. If the current control cannot be interpreted reliably, Styler does not guess a selector or silently change a setting and keeps an explicit path back to the official control.
+
## Reporting a vulnerability
Do not open a public issue. Use GitHub's private vulnerability reporting entry described in [SECURITY.md](../SECURITY.md). Include the affected version, reproduction steps, and whether Codex or local files were exposed.
diff --git a/package.json b/package.json
index cfcbbb6..7437b0c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-styler",
- "version": "0.2.0-beta.7",
+ "version": "0.2.0-beta.8",
"private": true,
"description": "Unofficial, open-source Codex theme editor and Codex skin creator for OpenAI Codex Desktop.",
"license": "Apache-2.0",
diff --git a/packages/theme-core/package.json b/packages/theme-core/package.json
index 29a7201..0c47f44 100644
--- a/packages/theme-core/package.json
+++ b/packages/theme-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@codex-styler/theme-core",
- "version": "0.2.0-beta.7",
+ "version": "0.2.0-beta.8",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/theme-core/src/builtins.ts b/packages/theme-core/src/builtins.ts
index eee8aca..295ac1a 100644
--- a/packages/theme-core/src/builtins.ts
+++ b/packages/theme-core/src/builtins.ts
@@ -90,74 +90,74 @@ export const nativeRefined: ThemeDefinition = {
variants: {
light: detailedVariant(
variant(
- "#e9e7e1",
- "#f7f5ef",
- "#20201e",
- "#605e58",
- "#c9c5ba",
- "#446f63",
- "#e9e7e1",
- 0.06,
- 0.88,
+ "#f1f3f6",
+ "#fbfcfd",
+ "#1b2027",
+ "#59626f",
+ "#c9d0d9",
+ "#4e739d",
+ "#e9edf2",
+ 0.04,
+ 0.92,
{ layout: "native", iconStyle: "contained", decorations: "subtle" },
),
{
- canvas: "#e9e7e1",
- surfaceRaised: "#fffdf7",
- surfaceOverlay: "#f1eee6",
- surfaceSunken: "#e5e2da",
- control: "#eeeae1",
- controlHover: "#e4e0d6",
- controlActive: "#d5ddd8",
- textTertiary: "#6d6a63",
- onAccent: "#f7fbf8",
- borderSubtle: "#d8d4ca",
- borderStrong: "#918d83",
- focus: "#446f63",
- success: "#2f704f",
- warning: "#87570f",
- danger: "#a33f3a",
- info: "#315f85",
- added: "#2f704f",
- modified: "#87570f",
- deleted: "#a33f3a",
+ canvas: "#f1f3f6",
+ surfaceRaised: "#ffffff",
+ surfaceOverlay: "#f6f7f9",
+ surfaceSunken: "#e8ebef",
+ control: "#eef1f4",
+ controlHover: "#e5e9ee",
+ controlActive: "#dbe4ef",
+ textTertiary: "#707a87",
+ onAccent: "#f8fbff",
+ borderSubtle: "#dce1e7",
+ borderStrong: "#7e8997",
+ focus: "#4e739d",
+ success: "#2f6f52",
+ warning: "#805a16",
+ danger: "#a64146",
+ info: "#356b9a",
+ added: "#2f6f52",
+ modified: "#805a16",
+ deleted: "#a64146",
},
{ intensity: 0.5, parallax: 3, targetFps: 30 },
12,
),
dark: detailedVariant(
variant(
- "#151716",
- "#202321",
- "#f1eee6",
- "#b3b8b2",
- "#3a3e3b",
- "#8dc9b3",
- "#0c0e0d",
- 0.18,
- 0.9,
+ "#111419",
+ "#1b1f25",
+ "#f2f4f7",
+ "#b1b8c2",
+ "#343b46",
+ "#8eb5df",
+ "#0b0e12",
+ 0.12,
+ 0.93,
{ layout: "native", iconStyle: "contained", decorations: "subtle" },
),
{
- canvas: "#151716",
- surfaceRaised: "#292d2a",
- surfaceOverlay: "#303531",
- surfaceSunken: "#171a18",
- control: "#2a2e2b",
- controlHover: "#343a36",
- controlActive: "#3d4a43",
- textTertiary: "#949b95",
- onAccent: "#10251e",
- borderSubtle: "#303632",
- borderStrong: "#5c665f",
- focus: "#8dc9b3",
- success: "#75c89c",
- warning: "#e0b35e",
- danger: "#e27b75",
- info: "#82b9df",
- added: "#75c89c",
- modified: "#e0b35e",
- deleted: "#e27b75",
+ canvas: "#111419",
+ surfaceRaised: "#252a32",
+ surfaceOverlay: "#2b313a",
+ surfaceSunken: "#11151a",
+ control: "#242a32",
+ controlHover: "#2e3540",
+ controlActive: "#34445a",
+ textTertiary: "#8b94a0",
+ onAccent: "#101a25",
+ borderSubtle: "#2a3039",
+ borderStrong: "#687382",
+ focus: "#8eb5df",
+ success: "#75c99d",
+ warning: "#e2b664",
+ danger: "#e27f7e",
+ info: "#84b7e3",
+ added: "#75c99d",
+ modified: "#e2b664",
+ deleted: "#e27f7e",
},
{ intensity: 0.5, parallax: 3, targetFps: 30 },
12,
@@ -168,7 +168,7 @@ export const nativeRefined: ThemeDefinition = {
{
id: "soft-vignette",
type: "vignette",
- opacity: 0.24,
+ opacity: 0.14,
blendMode: "multiply",
parallax: 0,
},