diff --git a/.github/release-notes/v0.2.0-beta.7.json b/.github/release-notes/v0.2.0-beta.7.json new file mode 100644 index 0000000..4e69e20 --- /dev/null +++ b/.github/release-notes/v0.2.0-beta.7.json @@ -0,0 +1,39 @@ +{ + "format": "codex-styler-release-notes-v1", + "version": "0.2.0-beta.7", + "defaultLocale": "en", + "locales": { + "en": { + "summary": "A coordinated theme system, faithful Codex previews, safe companion placement, and explicit creator-quality review bring Beta 7 to its release gate.", + "highlights": [ + "Themes now coordinate surfaces, text, icons, borders, status and diff colors, typography, materials, depth, and reduced-motion-safe animation through one preview/runtime contract.", + "Theme previews use a structurally faithful Codex shell for Home, Task and composer, Settings, Dialog, Components and states, and Right panel scenarios.", + "Companion Studio adds shared-canvas scaling and alignment assistance, placement modes, behavior audition, compiler-ready output summaries, and final edge review.", + "Theme and companion libraries use responsive master-detail workspaces with accurate previews and one persistent application action." + ], + "fixes": [ + "Fixed preview controls covering themed workspaces or relying on an invented Codex layout.", + "Fixed companion previews using arbitrary scale or placement that did not match runtime attachment rules.", + "Fixed global frame scaling allowing out-of-bounds output or per-frame size drift instead of preserving a shared canvas and ground line.", + "Fixed theme motion recipes interfering with Codex-owned transforms or ignoring reduced-motion preferences.", + "Fixed unreadable semantic text, icon, border, status, and code-change combinations by validating them against the surfaces where they actually render." + ] + }, + "zh-CN": { + "summary": "统一的主题视觉系统、忠实的 Codex 预览、安全的伙伴定位与明确的创作质量验收,让 Beta 7 达到发布门槛。", + "highlights": [ + "主题现在通过同一套预览与运行时合同统一协调表面、文字、图标、边框、状态与代码变更色、排版、材质、层次和减少动态效果。", + "主题预览使用更贴近真实结构的 Codex 骨架,覆盖主页、任务与输入区、设置、弹窗、组件状态和右侧面板。", + "伙伴工作室新增共享画布缩放与对齐辅助、定位模式、行为联调、编译输出摘要和最终边缘质量验收。", + "主题库与伙伴库使用响应式主从工作区、准确预览和唯一的全局应用操作。" + ], + "fixes": [ + "修复预览控件长期遮挡主题内容,以及使用与真实 Codex 不一致的虚构布局问题。", + "修复伙伴预览使用随意尺寸或位置、与运行时贴合规则不一致的问题。", + "修复全帧缩放导致输出越界或逐帧大小漂移,而不是保持共享画布与落脚线的问题。", + "修复主题动效干扰 Codex 自有 transform 或忽略减少动态偏好的问题。", + "修复语义文字、图标、边框、状态色与代码变更色没有依据实际承载表面进行可读性校验的问题。" + ] + } + } +} diff --git a/.github/release-notes/v0.2.0-beta.7.md b/.github/release-notes/v0.2.0-beta.7.md new file mode 100644 index 0000000..5ea0e06 --- /dev/null +++ b/.github/release-notes/v0.2.0-beta.7.md @@ -0,0 +1,81 @@ +# Codex Styler 0.2.0-beta.7 — Creator Quality & Theme System + +This release completes the Beta 7 creator-quality gate and turns themes into a coordinated visual system instead of a collection of independent color substitutions. The manager, real-Codex preview shell, injected runtime, and companion compiler now share the same placement, surface, readability, material, and motion contracts. + +Release tag: `v0.2.0-beta.7` + +## Download and install + +### macOS 13+ on Apple Silicon + +1. Download `Codex-Styler_0.2.0-beta.7_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.7_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. + +## A complete theme system + +- Theme previews now use one simplified but structurally faithful Codex shell for Home, Task & composer, Settings, Dialog, Components & states, and Right panel. +- Backgrounds, quiet and raised surfaces, text, icons, borders, semantic feedback, code changes, typography, depth, materials, and motion are derived as one coordinated palette. +- Surface-aware safety checks show the actual readability and boundary result before save or apply, including image-backed surfaces and tinted status treatments. +- Icon treatments preserve native SVG geometry while adding theme-aware contained or tinted presentation. +- Motion recipes share preview/runtime parameters, preserve Codex-owned transforms, and reduce to safe non-displacing feedback when reduced motion is enabled. + +## Companion creation and placement + +- Global frame scaling and alignment assistance keep every frame on one shared canvas and ground line without per-frame size drift. +- Free, composer-attached, and surface-attached placement modes use the same safe positioning rules in the library preview, interactive test, and injected runtime. +- Behavior audition lets creators verify pointer poses, idle motion, timing, interruption, and reduced-motion output before packaging. +- Compiler-ready summaries expose logical frames, poses, idle clips, atlas pages, encoded limits, and decoded memory before the final build. +- Edge quality is reviewed on black, white, and theme-colored surfaces and is invalidated whenever source pixels, cleanup, crop, scale, inclusion, or alignment changes. +- A bounded local preflight identifies likely retained backgrounds, canvas contact, isolated residue, and sampled color spill without pretending heuristic analysis is authoritative. + +## Manager and workflow polish + +- Theme and companion libraries use responsive master-detail workspaces with accurate previews and one persistent application action. +- Preview controls stay out of the artwork until requested, and compact layouts preserve selection, detail, and primary actions without overlap. +- Cross-platform scroll surfaces, fixed workspace navigation, keyboard focus, labels, controls, and responsive density use the same design system. +- Selected themes, variants, companions, sizes, and placement modes remain independent and observable before Codex is launched. + +## Automated evidence + +- Desktop unit and component coverage includes real preview structure, theme effect derivation, visual safety, control mappings, companion alignment, placement modes, edge analysis, quality review, motion clips, compiler output, and runtime injection. +- Theme-core validation covers all built-in semantic palettes and package constraints. +- Rust formatting, Clippy, tests, JavaScript dependency audit, production builds, local link checks, and release-version synchronization remain 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.7.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.7_aarch64-unsigned.dmg \ + --repo xuhuanstudio/codex-styler + +gh attestation verify Codex-Styler_0.2.0-beta.7_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. +- 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index f832967..970b588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,8 +69,8 @@ jobs: with: name: manager-e2e-report path: | - apps/desktop/playwright-report - apps/desktop/test-results + apps/desktop/test-results/**/*.png + apps/desktop/test-results/**/error-context.md if-no-files-found: ignore retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 825a1ae..f50a6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ All notable changes will be documented here. The project follows Semantic Versio ## [Unreleased] +## [0.2.0-beta.7] - 2026-07-20 + +### Added + +- A shared real-Codex preview shell with Home, Task & composer, Settings, + Dialog, Components & states, and Right panel scenarios across the theme + library and Theme Editor. +- Surface-aware color harmony, typography rhythm, icon roles, component + boundaries, semantic status colors, code-change colors, material recipes, + and reduced-motion-safe theme animation recipes. +- Companion placement modes, global frame scaling and alignment assistance, + behavior audition, compiler-ready output summaries, and explicit edge-quality + review on black, white, and theme-colored surfaces. +- Companion Studio now requires an explicit final edge review on black, white, + and theme-colored surfaces before building. Reviews advance between + backdrops, remain part of the local draft, and automatically expire whenever + source pixels, cleanup, shared crop, global scale, frame inclusion, or + alignment changes. +- Companion edge review now runs a bounded local preflight over neutral, + reduced-motion, direction-anchor, motion-boundary, and representative frames. + It flags retained backgrounds, canvas contact, isolated pixels, and sampled + color spill, then links each advisory finding back to an affected cleanup + frame without replacing the user's final visual review. + +### Changed + +- Theme and companion libraries now use responsive master-detail workspaces, + accurate companion scale and attachment previews, unobtrusive preview + controls, and one persistent configuration action. +- Theme rendering now treats backgrounds, quiet and raised surfaces, text, + icons, borders, feedback states, typography, depth, and motion as one + semantic system shared by local preview and the injected Codex runtime. +- Companion creation now keeps crop, baseline, scaling, inclusion, cleanup, + direction poses, idle motion, and final-quality evidence non-destructive and + observable before packaging. +- Cross-platform scroll surfaces, fixed workspace navigation, compact editor + layouts, and keyboard focus behavior now share the desktop design system. + +### Fixed + +- Theme previews no longer use an invented application skeleton or let preview + controls permanently cover the themed workspace. +- Companion previews no longer place characters at arbitrary scales or outside + the same safe composer attachment rules used by the runtime. +- Global companion scaling preserves a shared canvas, ground line, and frame + alignment instead of allowing per-frame size drift or out-of-bounds output. +- Motion recipes preserve Codex-owned transforms and automatically reduce to + non-displacing feedback when reduced motion is requested. +- Visual safety checks report readable text, icon, border, status, diff, and + image-surface results before a theme is saved or applied. + ## [0.2.0-beta.6] - 2026-07-19 ### Added diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index f735744..65645fc 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -2,7 +2,7 @@ Compatibility is claimed only after real-device validation. -| Platform | Architecture | Beta 6 status | RC requirement | +| Platform | Architecture | Beta 7 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 2535734..a73843c 100644 --- a/README.md +++ b/README.md @@ -15,21 +15,21 @@

简体中文 · - Download Beta · + Download Beta · Website · Documentation

CI - Preview version + Preview version Apache-2.0 CC BY 4.0 assets macOS and Windows

> [!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 6 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 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. ![Gilded Grandeur Codex theme with the Reset God companion](docs/media/codex-theme-gilded-grandeur.webp) @@ -97,14 +97,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 6 +## Download Creator Beta 7 -- **[macOS 13+ / Apple Silicon — DMG](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.6/Codex-Styler_0.2.0-beta.6_aarch64-unsigned.dmg)** -- **[Windows 11 / x64 — installer EXE](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.6/Codex-Styler_0.2.0-beta.6_x64-unsigned-setup.exe)** +- **[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)** 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.6) includes SHA-256 checksums, an SPDX SBOM, build attestations, updater artifacts, tested scope, and known limitations. Intel macOS is not included. Windows Beta 6 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.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. ## Run from source diff --git a/README.zh-CN.md b/README.zh-CN.md index 5111b14..84d169b 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 6 延续已重构的 Store 启动与有界重启链路,但仍等待社区实机验证,完成证据闭环和双平台签名前不会标记为稳定版。 +> Codex Styler 0.2 当前处于创作器 Beta 阶段。macOS DMG 采用临时 ad-hoc 签名但尚未公证,Windows EXE 尚未进行 Authenticode 代码签名;Windows Beta 7 延续已重构的 Store 启动与有界重启链路,但仍等待社区实机验证,完成证据闭环和双平台签名前不会标记为稳定版。 ![金辉盛境 Codex 主题与 Reset God 互动伙伴](docs/media/codex-theme-gilded-grandeur.webp) @@ -89,14 +89,14 @@ Codex Styler 管理器可以跟随系统,也可以固定使用亮色或暗色 所有随项目分发的图片和序列帧都是原创资源。参考仓库只用于研究思路,不复制或重新分发其代码与素材。 -## 下载创作器 Beta 6 +## 下载创作器 Beta 7 -- **[macOS 13+ / Apple Silicon — DMG](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.6/Codex-Styler_0.2.0-beta.6_aarch64-unsigned.dmg)** -- **[Windows 11 / x64 — 安装 EXE](https://github.com/xuhuanstudio/codex-styler/releases/download/v0.2.0-beta.6/Codex-Styler_0.2.0-beta.6_x64-unsigned-setup.exe)** +- **[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 上打开 DMG,把 Codex Styler 拖入“应用程序”,首次启动时按住 Control 点击应用并选择“打开”。Windows 版因为当前 Beta 尚无 Authenticode 证书,SmartScreen 可能显示提醒;继续前请先核对 Release 中的校验和与构建证明。不要全局关闭 Gatekeeper 或 SmartScreen。 -[预发布页面](https://github.com/xuhuanstudio/codex-styler/releases/tag/v0.2.0-beta.6)同时提供 SHA-256 校验文件、SPDX SBOM、构建证明、更新包、已验证范围和已知限制。本次不提供 Intel Mac 安装包;Windows Beta 6 由 CI 构建并完成结构校验,但仍明确标记为等待社区实机验证。 +[预发布页面](https://github.com/xuhuanstudio/codex-styler/releases/tag/v0.2.0-beta.7)同时提供 SHA-256 校验文件、SPDX SBOM、构建证明、更新包、已验证范围和已知限制。本次不提供 Intel Mac 安装包;Windows Beta 7 由 CI 构建并完成结构校验,但仍明确标记为等待社区实机验证。 ## 从源码运行 diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/companion-list-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/companion-list-en-dark.png index 0376a4b..92ca55f 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/companion-list-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/companion-list-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/companions-en-dark-1280x720.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/companions-en-dark-1280x720.png new file mode 100644 index 0000000..c5ce2e2 Binary files /dev/null and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/companions-en-dark-1280x720.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-align-en-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-align-en-dark-960x680.png new file mode 100644 index 0000000..ea68eb0 Binary files /dev/null and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-align-en-dark-960x680.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-align-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-align-en-dark.png index 64795c2..5336fd9 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-align-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-align-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-calibrate-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-calibrate-en-dark.png index 349627a..b91dd6d 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-calibrate-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-calibrate-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-dark-960x680.png index d4d1153..2f4373f 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-dark-960x680.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-dark-960x680.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-dark.png index a2c4869..ef63d54 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-light.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-light.png index 991f608..2efa037 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-light.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-cleanup-en-light.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-extract-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-extract-en-dark.png index 8a69095..be444ef 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-extract-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-extract-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-import-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-import-en-dark.png index 704b82e..e13d091 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-import-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-import-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-motions-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-motions-en-dark.png index 68685b5..cfdfb5a 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-motions-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-motions-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-test-en-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-test-en-dark-960x680.png index b9fee10..2d9b8ab 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-test-en-dark-960x680.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-test-en-dark-960x680.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-test-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-test-en-dark.png index 38b2ba2..7b9fb12 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-test-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-test-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-video-extract-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-video-extract-en-dark.png index 84ecf43..0fa9813 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-video-extract-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-video-extract-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-video-frames-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-video-frames-en-dark.png new file mode 100644 index 0000000..0d09d73 Binary files /dev/null and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/creator-video-frames-en-dark.png differ 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 14c4d36..dfc1f3f 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-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-dark-960x680.png index 2dc8b3c..43ec06b 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-dark-960x680.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-en-dark-960x680.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 fad46d8..cf0d63e 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-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-zh-dark-960x680.png index 42a38e4..32fc57a 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-zh-dark-960x680.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/manager-zh-dark-960x680.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 d917bc9..cf9267c 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/__screenshots__/manager.spec.ts/settings-en-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/settings-en-dark-960x680.png index a48282e..ada1b9b 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/settings-en-dark-960x680.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/settings-en-dark-960x680.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/settings-en-dark.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/settings-en-dark.png index d6b35ce..8f26ee0 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/settings-en-dark.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/settings-en-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/theme-editor-en-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/theme-editor-en-dark-960x680.png index 3849af8..058d687 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/theme-editor-en-dark-960x680.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/theme-editor-en-dark-960x680.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-detail-en-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-detail-en-dark-960x680.png new file mode 100644 index 0000000..f5a55d3 Binary files /dev/null and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-detail-en-dark-960x680.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-1280x720.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-1280x720.png new file mode 100644 index 0000000..aee7d2c Binary files /dev/null and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-1280x720.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-1320x840.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-1320x840.png index 3e5073c..48c3873 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-1320x840.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-1320x840.png differ diff --git a/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-960x680.png b/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-960x680.png index 58edd76..53b3d1d 100644 Binary files a/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-960x680.png and b/apps/desktop/e2e/__screenshots__/manager.spec.ts/themes-en-dark-960x680.png differ diff --git a/apps/desktop/e2e/manager.spec.ts b/apps/desktop/e2e/manager.spec.ts index 7f9cf91..c0fd4cc 100644 --- a/apps/desktop/e2e/manager.spec.ts +++ b/apps/desktop/e2e/manager.spec.ts @@ -89,12 +89,234 @@ test("companion cards use independent portraits", async ({ page }) => { await expect(list).toHaveScreenshot("companion-list-en-dark.png"); }); +test("compact companion details preserve a usable live preview", async ({ + page, +}) => { + await openManager(page, "en", "dark", { width: 960, height: 680 }); + await page.getByRole("button", { name: "Companions", exact: true }).click(); + await page.locator(".companion-option").nth(1).click(); + + const detail = page.locator( + ".companions-layout--detail .companion-preview-panel", + ); + const preview = detail.locator(".companion-preview-stage"); + const summary = detail.locator(".companion-detail-summary"); + const entity = preview.locator(".scene-entity"); + await expect(detail).toBeVisible(); + await expect(entity).toBeVisible(); + + const geometry = await detail.evaluate((panel) => { + const stage = panel.querySelector(".companion-preview-stage")!; + const summaryPanel = panel.querySelector( + ".companion-detail-summary", + )!; + const sprite = stage.querySelector(".scene-entity")!; + const stageBounds = stage.getBoundingClientRect(); + const summaryBounds = summaryPanel.getBoundingClientRect(); + const spriteBounds = sprite.getBoundingClientRect(); + return { + stageHeight: stageBounds.height, + stageWidth: stageBounds.width, + summaryWidth: summaryBounds.width, + summaryContained: + summaryPanel.scrollHeight <= summaryPanel.clientHeight + 1 && + summaryPanel.scrollWidth <= summaryPanel.clientWidth + 1, + entityInsideStage: + spriteBounds.top >= stageBounds.top && + spriteBounds.left >= stageBounds.left && + spriteBounds.right <= stageBounds.right && + spriteBounds.bottom <= stageBounds.bottom, + }; + }); + expect(geometry.stageHeight).toBeGreaterThanOrEqual(220); + expect(geometry.stageWidth).toBeGreaterThanOrEqual( + geometry.summaryWidth - 20, + ); + expect(geometry.summaryContained).toBe(true); + expect(geometry.entityInsideStage).toBe(true); + await expect(preview).toBeVisible(); + await expect(summary).toBeVisible(); +}); + +test("sidebar hover remains distinct from the active destination", async ({ + page, +}) => { + await openManager(page, "en", "dark", { width: 960, height: 680 }); + const active = page.getByRole("button", { name: "Home", exact: true }); + const inactive = page.getByRole("button", { name: "Themes", exact: true }); + const activeStyle = await active.evaluate((element) => { + const style = getComputedStyle(element); + return { background: style.backgroundColor }; + }); + + await inactive.hover(); + const hoveredBackground = await inactive.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + expect(hoveredBackground).not.toBe(activeStyle.background); + + await active.hover(); + await expect + .poll(() => + active.evaluate((element) => getComputedStyle(element).backgroundColor), + ) + .toBe(activeStyle.background); +}); + +test("resource libraries fit above the persistent setup bar", async ({ + page, +}) => { + await openManager(page, "en", "dark", { width: 1280, height: 720 }); + + const expectWorkspaceFit = async (selector: string) => { + const geometry = await page.locator(selector).evaluate((element) => { + const viewport = document.querySelector( + ".app-main__viewport", + )!; + const dock = document.querySelector(".configuration-dock")!; + const pageElement = element.closest(".page")!; + const bounds = element.getBoundingClientRect(); + const viewportBounds = viewport.getBoundingClientRect(); + const dockBounds = dock.getBoundingClientRect(); + const pageBounds = pageElement.getBoundingClientRect(); + return { + pageFitsViewport: pageBounds.bottom <= viewportBounds.bottom + 1, + workspaceClearsViewport: bounds.bottom <= viewportBounds.bottom - 16, + workspaceClearsDock: bounds.bottom <= dockBounds.top - 20, + }; + }); + expect(geometry).toEqual({ + pageFitsViewport: true, + workspaceClearsViewport: true, + workspaceClearsDock: true, + }); + }; + + const expectCompanionComposition = async (selector: string) => { + const composition = await page.locator(selector).evaluate((scope) => { + const preview = scope.querySelector(".workspace-preview")!; + const entity = scope.querySelector(".scene-entity")!; + const sprite = entity.querySelector( + ".scene-entity__sprite, .scene-entity__image", + )!; + const composer = scope.querySelector( + ".workspace-composer__field", + )!; + const previewBounds = preview.getBoundingClientRect(); + const entityBounds = entity.getBoundingClientRect(); + const spriteBounds = sprite.getBoundingClientRect(); + const composerBounds = composer.getBoundingClientRect(); + return { + heightOccupancy: entityBounds.height / previewBounds.height, + groundedDistance: Math.abs(entityBounds.bottom - composerBounds.top), + entityInsidePreview: + entityBounds.top >= previewBounds.top && + entityBounds.right <= previewBounds.right, + spriteMatchesEntity: + Math.abs(spriteBounds.width - entityBounds.width) < 1 && + Math.abs(spriteBounds.height - entityBounds.height) < 1, + }; + }); + expect(composition.heightOccupancy).toBeGreaterThanOrEqual(0.14); + expect(composition.heightOccupancy).toBeLessThanOrEqual(0.25); + expect(composition.groundedDistance).toBeLessThanOrEqual(2); + expect(composition.entityInsidePreview).toBe(true); + expect(composition.spriteMatchesEntity).toBe(true); + }; + + await page.getByRole("button", { name: "Themes", exact: true }).click(); + await expectWorkspaceFit(".theme-library-workspace"); + await expectCompanionComposition(".featured-theme__preview"); + const themeDetails = page.locator(".featured-theme__copy"); + await expect(themeDetails).toHaveCSS("overflow-y", "hidden"); + await expect(page).toHaveScreenshot("themes-en-dark-1280x720.png"); + + await page.getByRole("button", { name: "Companions", exact: true }).click(); + await expectWorkspaceFit(".companions-layout"); + await expectCompanionComposition(".companion-preview-panel"); + await expect(page).toHaveScreenshot("companions-en-dark-1280x720.png"); + + await page.setViewportSize({ width: 960, height: 680 }); + await expectWorkspaceFit(".companions-layout"); + const compactViewport = page.locator(".app-main__viewport"); + await expect + .poll(() => + compactViewport.evaluate( + (element) => element.scrollHeight === element.clientHeight, + ), + ) + .toBe(true); +}); + test("theme library and focused editor remain usable at compact sizes", async ({ page, }) => { await openManager(page, "en", "dark", { width: 1320, height: 840 }); await page.getByRole("button", { name: "Themes", exact: true }).click(); await expect(page.locator(".theme-library-workspace")).toBeVisible(); + const appearanceComparison = page.getByRole("group", { + name: "Compare appearance", + }); + const previewSurface = page.locator(".featured-theme__preview"); + const previewControlsTrigger = page.getByRole("button", { + name: "Task & composer", + }); + await previewSurface.hover(); + await expect(previewControlsTrigger).toHaveCSS("opacity", "1"); + await previewControlsTrigger.click(); + await appearanceComparison.getByRole("button", { name: "Official" }).click(); + await expect( + page.locator(".featured-theme__preview .workspace-preview"), + ).toHaveAttribute("data-preview-presentation", "official"); + await expect( + page.locator(".featured-theme__preview .workspace-entity"), + ).toHaveCount(0); + await previewSurface.hover(); + await previewControlsTrigger.click(); + await appearanceComparison.getByRole("button", { name: "Styled" }).click(); + await previewSurface.hover(); + await expect(previewControlsTrigger).toBeVisible(); + await expect(previewControlsTrigger).toHaveCSS("opacity", "1"); + await expect(previewControlsTrigger).toHaveAttribute( + "aria-expanded", + "false", + ); + await expect + .poll(() => + previewControlsTrigger.evaluate((trigger) => { + const bounds = trigger.getBoundingClientRect(); + const topmostElement = document.elementFromPoint( + bounds.left + bounds.width / 2, + bounds.top + bounds.height / 2, + ); + return ( + topmostElement === trigger || + (topmostElement !== null && trigger.contains(topmostElement)) + ); + }), + ) + .toBe(true); + const desktopThemeDetail = page.locator(".featured-theme__copy"); + const desktopThemeDetailFit = await desktopThemeDetail.evaluate((element) => { + const panel = element.getBoundingClientRect(); + const action = element.querySelector( + ".theme-detail-card__title-row .secondary-button", + )!; + const coverage = element.querySelector( + ".theme-effect-summary", + )!; + return { + overflowIsContained: getComputedStyle(element).overflowY === "hidden", + actionVisible: action.getBoundingClientRect().bottom <= panel.bottom + 1, + coverageVisible: + coverage.getBoundingClientRect().bottom <= panel.bottom + 1, + }; + }); + expect(desktopThemeDetailFit).toEqual({ + overflowIsContained: true, + actionVisible: true, + coverageVisible: true, + }); await expect(page).toHaveScreenshot("themes-en-dark-1320x840.png"); await page.setViewportSize({ width: 960, height: 680 }); @@ -102,6 +324,45 @@ test("theme library and focused editor remain usable at compact sizes", async ({ await expect(page.locator(".featured-theme")).toBeHidden(); await expect(page).toHaveScreenshot("themes-en-dark-960x680.png"); + await page.locator(".theme-row__select").first().click(); + const compactThemeDetail = page.locator( + ".theme-library-workspace--detail .featured-theme__copy", + ); + await expect(compactThemeDetail).toBeVisible(); + const compactTitlePosition = await compactThemeDetail.evaluate((element) => { + const title = element.querySelector("h2"); + return { + panelTop: element.getBoundingClientRect().top, + titleTop: title?.getBoundingClientRect().top ?? -1, + }; + }); + expect(compactTitlePosition.titleTop).toBeGreaterThanOrEqual( + compactTitlePosition.panelTop, + ); + const compactThemeDetailFit = await compactThemeDetail.evaluate((element) => { + const panel = element.getBoundingClientRect(); + const description = element.querySelector("p")!; + const action = element.querySelector( + ".theme-detail-card__title-row .secondary-button", + )!; + const coverage = element.querySelector( + ".theme-effect-summary", + )!; + return { + descriptionHeight: description.getBoundingClientRect().height, + actionVisible: action.getBoundingClientRect().bottom <= panel.bottom + 1, + contentIsContained: + getComputedStyle(element).overflowY === "hidden" && + coverage.getBoundingClientRect().bottom <= panel.bottom + 1 && + element.scrollWidth <= element.clientWidth + 1, + }; + }); + expect(compactThemeDetailFit.descriptionHeight).toBeGreaterThanOrEqual(20); + expect(compactThemeDetailFit.actionVisible).toBe(true); + expect(compactThemeDetailFit.contentIsContained).toBe(true); + await expect(page).toHaveScreenshot("themes-detail-en-dark-960x680.png"); + await page.getByRole("button", { name: "Back to themes" }).click(); + await page.getByRole("button", { name: "New theme" }).click(); await page.getByRole("button", { name: /Start blank/ }).click(); await expect(page.locator(".editor-page")).toBeVisible(); @@ -111,13 +372,26 @@ test("theme library and focused editor remain usable at compact sizes", async ({ ).toBeHidden(); await expect( page.getByRole("region", { name: "Live effect mapped" }), - ).toContainText("5 preview views"); + ).toContainText("8 preview views"); const saveDraft = page.getByRole("button", { name: "Save draft" }); await expect(saveDraft).toBeDisabled(); await expect(saveDraft).toHaveAttribute("data-dirty", "false"); await expect(page).toHaveScreenshot("theme-editor-en-dark-960x680.png"); - await page.getByRole("button", { name: "Surfaces" }).click(); + const previewScenario = page.getByRole("combobox", { + name: "Preview scenario", + }); + await previewScenario.selectOption("changes"); + await expect(page.locator(".workspace-change-review")).toBeVisible(); + await expect(page.getByRole("tab", { name: "Changes" })).toHaveAttribute( + "aria-selected", + "true", + ); + await page.getByRole("tab", { name: "Terminal" }).click(); + await expect(previewScenario).toHaveValue("terminal"); + await expect(page.locator(".workspace-terminal-activity")).toBeVisible(); + + await page.getByRole("button", { name: "Interface system" }).click(); await expect( page.getByRole("button", { name: "Appearance" }), ).toHaveAttribute("aria-expanded", "true"); @@ -125,17 +399,36 @@ test("theme library and focused editor remain usable at compact sizes", async ({ page.getByRole("region", { name: "Enhanced mode effect" }), ).toBeVisible(); await page - .getByRole("button", { name: "Preview on Task & composer" }) + .getByRole("button", { name: "Preview on Components & states" }) .click(); await expect(page.locator(".workspace-preview")).toHaveAttribute( "data-preview-scenario", - "task", + "components", ); + await expect(page.locator(".workspace-component-grid")).toBeVisible(); await expect(page.locator('[data-theme-control^="surfaces."]')).toHaveCount( - 6, + 13, ); + await expect( + page.locator('[data-theme-control="surfaces.treatment"]'), + ).toBeVisible(); const savedPreview = page.getByRole("button", { name: "Saved" }); await expect(savedPreview).toBeDisabled(); + const frostedMaterial = page.getByRole("button", { name: "Frosted" }); + await frostedMaterial.click(); + await expect(frostedMaterial).toHaveAttribute("aria-pressed", "true"); + await page.locator("summary").filter({ hasText: "Fine tune" }).click(); + await expect( + page.getByRole("slider", { name: /Focused panel opacity/ }), + ).toHaveValue("0.92"); + await expect(page.getByRole("slider", { name: /Glass blur/ })).toHaveValue( + "20", + ); + await expect(page.locator(".workspace-preview")).toHaveCSS( + "--preview-focus-blur", + "20px", + ); + await expect(savedPreview).toBeEnabled(); await page .getByRole("combobox", { name: "Workspace layout" }) .selectOption("immersive"); @@ -169,6 +462,32 @@ test("theme library and focused editor remain usable at compact sizes", async ({ ); await expect(page.locator(".inspector-content")).not.toHaveAttribute("inert"); + await page + .locator(".canvas-version-control") + .getByRole("button", { name: "Official", exact: true }) + .click(); + await expect(page.locator(".canvas-stage")).toHaveAttribute( + "data-preview-version", + "official", + ); + await expect(page.locator(".workspace-preview")).toHaveAttribute( + "data-preview-presentation", + "official", + ); + await expect( + page.getByText("Official Codex reference", { exact: true }), + ).toBeVisible(); + await expect(page.locator(".inspector-content")).toHaveAttribute("inert"); + await page.getByRole("button", { name: "Return" }).click(); + await expect(page.locator(".canvas-stage")).toHaveAttribute( + "data-preview-version", + "current", + ); + await expect(page.locator(".workspace-preview")).toHaveAttribute( + "data-preview-presentation", + "styled", + ); + await page.getByRole("button", { name: "Back to themes" }).click(); const unsavedDialog = page.getByRole("dialog", { name: "Save this theme before leaving?", @@ -186,21 +505,36 @@ test("scroll surfaces use product chrome instead of platform defaults", async ({ page, }) => { await openManager(page, "en", "dark", { width: 960, height: 680 }); + await expect + .poll(() => + page + .locator("html") + .evaluate((element) => getComputedStyle(element).colorScheme), + ) + .toBe("dark"); await page.getByRole("button", { name: "Themes", exact: true }).click(); const themeList = page.locator(".theme-list"); await expect(themeList).toBeVisible(); const themeScrollChrome = await themeList.evaluate((element) => { const style = getComputedStyle(element); + const scrollbar = getComputedStyle(element, "::-webkit-scrollbar"); + const track = getComputedStyle(element, "::-webkit-scrollbar-track"); const thumb = getComputedStyle(element, "::-webkit-scrollbar-thumb"); return { color: style.scrollbarColor, + gutter: style.scrollbarGutter, width: style.scrollbarWidth, + nativeWidth: scrollbar.width, + trackBackground: track.backgroundColor, thumbRadius: thumb.borderRadius, }; }); expect(themeScrollChrome.color).not.toBe("auto"); + expect(themeScrollChrome.gutter).toBe("auto"); expect(themeScrollChrome.width).toBe("thin"); + expect(themeScrollChrome.nativeWidth).toBe("8px"); + expect(themeScrollChrome.trackBackground).toBe("rgba(0, 0, 0, 0)"); expect(themeScrollChrome.thumbRadius).toBe("999px"); await page.getByRole("button", { name: "Companions" }).click(); @@ -215,15 +549,68 @@ test("scroll surfaces use product chrome instead of platform defaults", async ({ .not.toBe("auto"); await page.getByRole("button", { name: "New companion" }).click(); + const appViewport = page.locator(".app-main__viewport"); const creatorSteps = page.locator(".creator-steps"); + const creatorWorkbench = page.locator(".creator-workbench"); + const creatorFooter = page.locator(".creator-footer"); await expect(creatorSteps).toBeVisible(); - await expect - .poll(() => - creatorSteps.evaluate( - (element) => getComputedStyle(element).scrollbarColor, - ), - ) - .not.toBe("auto"); + const creatorLayout = await page.evaluate(() => { + const viewport = document.querySelector( + ".app-main__viewport", + )!; + const steps = document.querySelector(".creator-steps")!; + const workbench = + document.querySelector(".creator-workbench")!; + const footer = document.querySelector(".creator-footer")!; + const stepsScrollbar = getComputedStyle(steps, "::-webkit-scrollbar"); + const stepsTrack = getComputedStyle(steps, "::-webkit-scrollbar-track"); + const viewportStyle = getComputedStyle(viewport); + const workbenchStyle = getComputedStyle(workbench); + const stepsRect = steps.getBoundingClientRect(); + const workbenchRect = workbench.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); + return { + viewportOverflowX: viewportStyle.overflowX, + viewportOverflowY: viewportStyle.overflowY, + workbenchOverflowY: workbenchStyle.overflowY, + stepsScrollbarHeight: stepsScrollbar.height, + stepsTrackBackground: stepsTrack.backgroundColor, + stepsFitWithoutRail: steps.scrollWidth <= steps.clientWidth, + workbenchStartsAfterSteps: workbenchRect.top >= stepsRect.bottom - 1, + workbenchEndsBeforeFooter: workbenchRect.bottom <= footerRect.top + 1, + footerEndsInsideViewport: footerRect.bottom <= window.innerHeight + 1, + }; + }); + expect(creatorLayout).toEqual({ + viewportOverflowX: "hidden", + viewportOverflowY: "hidden", + workbenchOverflowY: "auto", + stepsScrollbarHeight: "6px", + stepsTrackBackground: "rgba(0, 0, 0, 0)", + stepsFitWithoutRail: true, + workbenchStartsAfterSteps: true, + workbenchEndsBeforeFooter: true, + footerEndsInsideViewport: true, + }); + await expect(appViewport).toHaveAttribute("data-view", "companion-editor"); + await expect(creatorWorkbench).toBeVisible(); + await expect(creatorFooter).toBeVisible(); + + const lateCascadeFallback = await page.evaluate(() => { + const onboardingContent = document.createElement("div"); + onboardingContent.className = "onboarding-content"; + onboardingContent.dataset.scrollSurface = "panel"; + document.body.append(onboardingContent); + const style = getComputedStyle(onboardingContent); + const result = { + color: style.scrollbarColor, + gutter: style.scrollbarGutter, + }; + onboardingContent.remove(); + return result; + }); + expect(lateCascadeFallback.color).not.toBe("auto"); + expect(lateCascadeFallback.gutter).toBe("auto"); }); test("settings controls share a stable professional layout", async ({ @@ -342,7 +729,9 @@ test("keyboard companion creator completes a static-image project", async ({ await expect(generateFrames).toBeVisible(); await expect(page).toHaveScreenshot("creator-extract-en-dark.png"); await generateFrames.press("Enter"); - await expect(page.getByRole("heading", { name: "Clean up" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Clean up" })).toBeVisible({ + timeout: 20_000, + }); await expect(page.getByText("Processing is synchronized")).toBeVisible(); await page.getByRole("button", { name: "Import", exact: true }).click(); await page @@ -398,6 +787,33 @@ test("keyboard companion creator completes a static-image project", async ({ page.getByRole("heading", { name: "Shared canvas ready" }), ).toBeVisible(); await expect(page).toHaveScreenshot("creator-align-en-dark.png"); + await page.setViewportSize({ width: 960, height: 680 }); + await expect(page).toHaveScreenshot("creator-align-en-dark-960x680.png"); + await page.setViewportSize({ width: 1320, height: 840 }); + const globalSize = page.getByLabel("Global size percentage"); + await globalSize.fill("125"); + await expect( + page.getByRole("heading", { name: "Alignment assistant found an issue" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Fit all frames to canvas" }).click(); + await expect( + page.getByRole("heading", { name: "Shared canvas ready" }), + ).toBeVisible(); + await expect + .poll(async () => Number(await globalSize.inputValue())) + .toBeLessThanOrEqual(100); + await expect( + page.locator(".alignment-current-frame").locator("xpath=.."), + ).toHaveAttribute("transform", /scale\(0\./); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(globalSize).toHaveValue("125"); + await expect( + page.getByRole("heading", { name: "Alignment assistant found an issue" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Redo" }).click(); + await expect( + page.getByRole("heading", { name: "Shared canvas ready" }), + ).toBeVisible(); await page .getByRole("button", { name: "Current frame", exact: true }) .click(); @@ -421,7 +837,7 @@ test("keyboard companion creator completes a static-image project", async ({ .poll(async () => Number(await baselineX.inputValue())) .not.toBe(baselineXBefore); await expect( - page.getByRole("heading", { name: "Some frames need alignment" }), + page.getByRole("heading", { name: "Alignment assistant found an issue" }), ).toBeVisible(); await page.getByRole("button", { name: "Snap current frame" }).click(); await expect( @@ -509,6 +925,22 @@ test("keyboard companion creator completes a static-image project", async ({ .fill("A keyboard-tested local companion."); await page.getByLabel("Author").fill("Test creator"); await page.getByLabel("Asset license").selectOption("CC0-1.0"); + + await reviewSetup.press("Enter"); + const edgeReview = page.getByRole("group", { + name: "Edge review backgrounds", + }); + for (const backdrop of ["Black", "White", "Theme color"] as const) { + await edgeReview + .getByRole("button", { name: backdrop, exact: true }) + .click(); + await page + .getByRole("button", { + name: new RegExp(`Confirm edges on ${backdrop.toLowerCase()}`), + }) + .click(); + } + const buildCompanion = page.getByRole("button", { name: /Build and install companion/, }); @@ -561,12 +993,40 @@ test("standard H.264 MP4 extracts without conversion", async ({ page }) => { await expect(page.getByText("short-h264.mp4", { exact: true })).toBeVisible(); await page.getByRole("button", { name: /Next/ }).click(); await expect(page.getByRole("button", { name: "Set in" })).toBeVisible(); + await page + .locator(".creator-control-row", { hasText: "Extraction FPS" }) + .locator('input[type="range"]') + .fill("60"); + await expect(page.getByText("60 FPS", { exact: true })).toBeVisible(); await expect(page).toHaveScreenshot("creator-video-extract-en-dark.png"); await page.getByRole("button", { name: /Extract video frames/ }).click(); + await expect(page.getByRole("heading", { name: "Clean up" })).toBeVisible({ + timeout: 20_000, + }); await expect(page.getByText(/\d+ frames/u)).toBeVisible(); await expect( page.getByText(/could not be decoded|codec inside/u), ).toHaveCount(0); + const frameStrip = page.locator(".creator-frame-strip__items"); + await expect(frameStrip).toBeVisible(); + const frameStripChrome = await frameStrip.evaluate((element) => { + const scrollbar = getComputedStyle(element, "::-webkit-scrollbar"); + const track = getComputedStyle(element, "::-webkit-scrollbar-track"); + const firstFrame = element.firstElementChild!.getBoundingClientRect(); + const bounds = element.getBoundingClientRect(); + return { + scrollbarHeight: scrollbar.height, + trackBackground: track.backgroundColor, + bottomClearance: Math.round(bounds.bottom - firstFrame.bottom), + overflowsHorizontally: element.scrollWidth > element.clientWidth, + }; + }); + expect(frameStripChrome.scrollbarHeight).toBe("6px"); + expect(frameStripChrome.trackBackground).toBe("rgba(0, 0, 0, 0)"); + expect(frameStripChrome.bottomClearance).toBeGreaterThanOrEqual(8); + expect(frameStripChrome.overflowsHorizontally).toBe(true); + await frameStrip.scrollIntoViewIfNeeded(); + await expect(page).toHaveScreenshot("creator-video-frames-en-dark.png"); }); test("light companion cleanup remains legible", async ({ page }) => { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 99482e5..baf6c2f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@codex-styler/desktop", - "version": "0.2.0-beta.6", + "version": "0.2.0-beta.7", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 8c5517d..04c16f7 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -392,7 +392,7 @@ dependencies = [ [[package]] name = "codex-styler" -version = "0.2.0-beta.6" +version = "0.2.0-beta.7" dependencies = [ "futures-util", "reqwest", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index b029e97..e282fed 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.6" +version = "0.2.0-beta.7" description = "Unofficial local-first theme and interactive scene editor for OpenAI Codex Desktop" authors = ["Codex Styler contributors"] license = "Apache-2.0" diff --git a/apps/desktop/src-tauri/src/runtime.js b/apps/desktop/src-tauri/src/runtime.js index f881789..908df70 100644 --- a/apps/desktop/src-tauri/src/runtime.js +++ b/apps/desktop/src-tauri/src/runtime.js @@ -1,5 +1,5 @@ (() => { - if (window.__CODEX_STYLER_RUNTIME__?.version === 18) return; + if (window.__CODEX_STYLER_RUNTIME__?.version === 36) return; window.__CODEX_STYLER_RUNTIME__?.restore?.(); const BACKDROP_ID = "codex-styler-scene-root"; @@ -35,6 +35,7 @@ ]); let pointerHandler = null; let scenePointerHandler = null; + let sceneResetHandler = null; let sceneAnimationFrame = null; let resizeHandler = null; let layoutResizeHandler = null; @@ -52,6 +53,112 @@ value >= minimum && value <= maximum; + const ENTITY_SAFE_INSET = 8; + const clampEntityCenter = (value, extent, viewportExtent) => { + const viewport = Math.max(0, viewportExtent); + const halfExtent = Math.max(0, extent) / 2; + const minimum = ENTITY_SAFE_INSET + halfExtent; + const maximum = viewport - ENTITY_SAFE_INSET - halfExtent; + if (minimum > maximum) return viewport / 2; + return Math.max(minimum, Math.min(maximum, value)); + }; + const safeFreeEntityPosition = ( + x, + y, + width, + height, + viewportWidth, + viewportHeight, + ) => ({ + x: clampEntityCenter(x, width, viewportWidth), + y: clampEntityCenter(y, height, viewportHeight), + }); + const safeAttachedEntityPosition = ( + target, + attachment, + width, + height, + viewportWidth, + viewportHeight, + ) => { + const rawY = + (attachment.edge === "bottom" ? target.bottom : target.top) + + attachment.offset.y; + const minimumY = + attachment.edge === "top" + ? ENTITY_SAFE_INSET + height + : ENTITY_SAFE_INSET; + const maximumY = + attachment.edge === "top" + ? viewportHeight - ENTITY_SAFE_INSET + : viewportHeight - ENTITY_SAFE_INSET - height; + const y = + minimumY > maximumY + ? attachment.edge === "top" + ? viewportHeight - ENTITY_SAFE_INSET + : ENTITY_SAFE_INSET + : Math.max(minimumY, Math.min(maximumY, rawY)); + return { + x: clampEntityCenter( + target.left + target.width * attachment.align + attachment.offset.x, + width, + viewportWidth, + ), + y, + }; + }; + + const resolveMaterialCharacter = (appearance) => { + const opacity = Math.min( + appearance.surfaceOpacity, + appearance.focusOpacity, + ); + if (appearance.focusBlur <= 4 && opacity >= 0.93) return "solid"; + if (appearance.focusBlur >= 16 || opacity <= 0.82) return "frosted"; + return "layered"; + }; + + // Keep these thresholds aligned with resolveThemeMotionProfile in the + // desktop preview. Motion may change paint and transform only; it must not + // alter Codex layout measurements or hit targets. + const resolveMotionProfile = (intensity) => { + const normalized = Math.max(0, Math.min(1, intensity)); + if (normalized <= 0.05) { + return { + character: "still", + duration: 0, + lift: 0, + pressScale: 1, + overlayOpacity: 1, + }; + } + if (normalized < 0.4) { + return { + character: "calm", + duration: 140, + lift: 1, + pressScale: 0.995, + overlayOpacity: 0.96, + }; + } + if (normalized < 0.72) { + return { + character: "fluid", + duration: 190, + lift: 2, + pressScale: 0.99, + overlayOpacity: 0.9, + }; + } + return { + character: "expressive", + duration: 240, + lift: 3, + pressScale: 0.985, + overlayOpacity: 0.84, + }; + }; + const rgb = (hex) => [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)); const toHex = (channels) => @@ -202,6 +309,7 @@ !finiteBetween(background.blur, 0, 40) || !finiteBetween(background.overlayOpacity, 0, 1) || !finiteBetween(appearance.surfaceOpacity, 0, 1) || + !finiteBetween(appearance.focusOpacity, 0, 1) || !finiteBetween(appearance.radius, 0, 32) || !finiteBetween(appearance.focusBlur, 0, 32) ) { @@ -396,8 +504,13 @@ const remove = () => { if (scenePointerHandler) window.removeEventListener("pointermove", scenePointerHandler); + if (sceneResetHandler) { + window.removeEventListener("blur", sceneResetHandler); + document.removeEventListener("pointerleave", sceneResetHandler, true); + } if (sceneAnimationFrame !== null) cancelAnimationFrame(sceneAnimationFrame); scenePointerHandler = null; + sceneResetHandler = null; sceneAnimationFrame = null; mutationObserver?.disconnect(); mutationObserver = null; @@ -418,6 +531,10 @@ document.documentElement.removeAttribute("data-codex-styler-layout"); document.documentElement.removeAttribute("data-codex-styler-icons"); document.documentElement.removeAttribute("data-codex-styler-decorations"); + document.documentElement.removeAttribute("data-codex-styler-geometry"); + document.documentElement.removeAttribute("data-codex-styler-material"); + document.documentElement.removeAttribute("data-codex-styler-typography"); + document.documentElement.removeAttribute("data-codex-styler-motion"); document.documentElement.removeAttribute("data-codex-styler-variant"); document.documentElement.removeAttribute("data-codex-styler-contrast"); document.documentElement.removeAttribute( @@ -566,6 +683,7 @@ 0.98, Math.max( authoredSurfaceOpacity, + appearance.focusOpacity, quietSurfaceOpacity + (imageBacked ? 0.12 : 0.08), ), ); @@ -733,11 +851,48 @@ return true; }; - const semanticPalette = (appearance, background, variant, contrastSystem) => { + const semanticPalette = (appearance, background, contrastSystem) => { const custom = appearance.palette || {}; + const strength = { + none: { + raised: 0.04, + overlay: 0.07, + control: 0.05, + controlHover: 0.08, + controlActive: 0.12, + }, + subtle: { + raised: 0.06, + overlay: 0.1, + control: 0.08, + controlHover: 0.13, + controlActive: 0.18, + }, + expressive: { + raised: 0.1, + overlay: 0.16, + control: 0.12, + controlHover: 0.2, + controlActive: 0.28, + }, + }[appearance.decorations || "none"] || { + raised: 0.04, + overlay: 0.07, + control: 0.05, + controlHover: 0.08, + controlActive: 0.12, + }; const textPrimary = contrastSystem.textPrimary; const textSecondary = contrastSystem.textSecondary; const textTertiary = contrastSystem.textTertiary; + const preferredIconColor = (emphasis = false) => { + const style = appearance.iconStyle || "native"; + const base = emphasis ? textPrimary : textSecondary; + if (style === "native") return base; + const accentAmount = + style === "themed" ? (emphasis ? 0.72 : 0.58) : emphasis ? 0.5 : 0.36; + return mix(base, appearance.accent, accentAmount); + }; const readableSurface = (surface, minimum = 4.5) => contrast(textPrimary, surface) >= minimum && contrast(textSecondary, surface) >= minimum && @@ -749,11 +904,9 @@ return readableSurface(fallback, minimum) ? fallback : appearance.surface; }; const safeForeground = (candidate, backgrounds, fallback, minimum = 4.5) => - candidate && minimumContrast(candidate, backgrounds) >= minimum - ? candidate - : readable(fallback, backgrounds, minimum); + readable(candidate || fallback, backgrounds, minimum); const statusDefaults = - variant === "dark" + contrastSystem.tone === "light" ? { success: "#4BC47D", warning: "#E7A645", @@ -766,70 +919,123 @@ danger: "#B93232", info: "#1F5F99", }; - const success = safeForeground( - custom.success, - contrastSystem.strongBackgrounds, - statusDefaults.success, - ); - const warning = safeForeground( - custom.warning, - contrastSystem.strongBackgrounds, - statusDefaults.warning, - ); - const danger = safeForeground( - custom.danger, - contrastSystem.strongBackgrounds, - statusDefaults.danger, - ); - const info = safeForeground( - custom.info || appearance.accent, - contrastSystem.strongBackgrounds, - statusDefaults.info, - ); + const preferredFeedbackColor = (role) => { + if (role === "success") return custom.success || statusDefaults.success; + if (role === "warning") return custom.warning || statusDefaults.warning; + if (role === "danger") return custom.danger || statusDefaults.danger; + if (role === "info") return custom.info || appearance.accent; + if (role === "added") + return custom.added || custom.success || statusDefaults.success; + if (role === "modified") + return custom.modified || custom.warning || statusDefaults.warning; + return custom.deleted || custom.danger || statusDefaults.danger; + }; const onAccent = safeForeground( custom.onAccent, [appearance.accent], readable(appearance.surface, appearance.accent), ); + const canvas = safeSurface(custom.canvas, background.color); + const surfaceRaised = safeSurface( + custom.surfaceRaised, + mix(appearance.surface, appearance.accent, strength.raised), + ); + const surfaceOverlay = safeSurface( + custom.surfaceOverlay, + mix(appearance.surface, appearance.accent, strength.overlay), + ); + const surfaceSunken = safeSurface( + custom.surfaceSunken, + mix(appearance.surface, background.color, 0.12), + ); + const control = safeSurface( + custom.control, + mix(appearance.surface, appearance.accent, strength.control), + ); + const controlHover = safeSurface( + custom.controlHover, + mix(appearance.surface, appearance.accent, strength.controlHover), + ); + const controlActive = safeSurface( + custom.controlActive, + mix(appearance.surface, appearance.accent, strength.controlActive), + ); + const boundaryBackgrounds = [ + appearance.surface, + surfaceRaised, + surfaceOverlay, + surfaceSunken, + control, + controlHover, + controlActive, + ]; + // Mirrors the desktop preview contract: quiet separators stay subtle, + // while regular and strong component boundaries cannot disappear. + const border = readable(appearance.border, boundaryBackgrounds, 1.5); + const borderSubtle = readable( + custom.borderSubtle || mix(appearance.surface, border, 0.72), + boundaryBackgrounds, + 1.25, + ); + const borderStrong = readable( + custom.borderStrong || mix(border, textPrimary, 0.3), + boundaryBackgrounds, + custom.borderStrong ? 3 : 2.5, + ); + const icon = readable(preferredIconColor(), boundaryBackgrounds, 3); + const iconEmphasis = readable( + preferredIconColor(true), + boundaryBackgrounds, + 3, + ); + const feedbackSurfaces = boundaryBackgrounds; + // Mirrors the foreground-bearing inline diff (11%) and status (16%) + // surfaces. Stronger editor fills use normal text, not the status hue. + const feedbackTintAmounts = [0.11, 0.16]; + const tintedFeedbackBackgrounds = (foreground) => + feedbackSurfaces.flatMap((surface) => + feedbackTintAmounts.map((amount) => mix(surface, foreground, amount)), + ); + const feedbackColor = (role) => { + let resolved = readable( + preferredFeedbackColor(role), + feedbackSurfaces, + 4.5, + ); + for (let iteration = 0; iteration < 8; iteration += 1) { + const next = readable( + resolved, + [...feedbackSurfaces, ...tintedFeedbackBackgrounds(resolved)], + 4.5, + ); + if (next === resolved) break; + resolved = next; + } + return resolved; + }; + const success = feedbackColor("success"); + const warning = feedbackColor("warning"); + const danger = feedbackColor("danger"); + const info = feedbackColor("info"); return { - canvas: safeSurface(custom.canvas, background.color), + canvas, surface: appearance.surface, - surfaceRaised: safeSurface( - custom.surfaceRaised, - mix(appearance.surface, appearance.text, 0.08), - ), - surfaceOverlay: safeSurface( - custom.surfaceOverlay, - mix(appearance.surface, appearance.text, 0.14), - ), - surfaceSunken: safeSurface( - custom.surfaceSunken, - mix(appearance.surface, background.color, 0.12), - ), - control: safeSurface( - custom.control, - mix(appearance.surface, appearance.text, 0.1), - ), - controlHover: safeSurface( - custom.controlHover, - mix(appearance.surface, appearance.text, 0.16), - ), - controlActive: safeSurface( - custom.controlActive, - mix(appearance.surface, appearance.text, 0.24), - ), + surfaceRaised, + surfaceOverlay, + surfaceSunken, + control, + controlHover, + controlActive, textPrimary, textSecondary, textTertiary, + icon, + iconEmphasis, accent: appearance.accent, onAccent, - border: appearance.border, - borderSubtle: - custom.borderSubtle || - `color-mix(in srgb, ${appearance.border} 52%, transparent)`, - borderStrong: - custom.borderStrong || - `color-mix(in srgb, ${appearance.border} 70%, ${appearance.text})`, + border, + borderSubtle, + borderStrong, focus: safeForeground( custom.focus || appearance.accent, contrastSystem.strongBackgrounds, @@ -839,21 +1045,9 @@ warning, danger, info, - added: safeForeground( - custom.added, - contrastSystem.strongBackgrounds, - success, - ), - modified: safeForeground( - custom.modified, - contrastSystem.strongBackgrounds, - warning, - ), - deleted: safeForeground( - custom.deleted, - contrastSystem.strongBackgrounds, - danger, - ), + added: feedbackColor("added"), + modified: feedbackColor("modified"), + deleted: feedbackColor("deleted"), }; }; @@ -870,6 +1064,8 @@ --codex-styler-text-primary: ${palette.textPrimary}; --codex-styler-text-secondary: ${palette.textSecondary}; --codex-styler-text-tertiary: ${palette.textTertiary}; + --codex-styler-icon: ${palette.icon}; + --codex-styler-icon-emphasis: ${palette.iconEmphasis}; --codex-styler-accent: ${palette.accent}; --codex-styler-on-accent: ${palette.onAccent}; --codex-styler-border: ${palette.border}; @@ -912,8 +1108,8 @@ --color-text-button-primary: var(--codex-styler-on-accent); --color-text-button-secondary: var(--codex-styler-text-primary); --color-text-button-tertiary: var(--codex-styler-text-secondary); - --color-icon-primary: var(--codex-styler-text-primary); - --color-icon-secondary: var(--codex-styler-text-secondary); + --color-icon-primary: var(--codex-styler-icon-emphasis); + --color-icon-secondary: var(--codex-styler-icon); --color-icon-tertiary: var(--codex-styler-text-tertiary); --color-border: var(--codex-styler-border); --color-border-light: var(--codex-styler-border-subtle); @@ -1077,7 +1273,11 @@ const installStyles = (theme, variant, safeMode) => { const visual = theme.variants[variant]; - const { appearance, background } = visual; + const { background } = visual; + const authoredAppearance = visual.appearance; + const motionProfile = resolveMotionProfile(visual.motion.intensity); + const motionDuration = motionProfile.duration; + const interactionLift = motionProfile.lift; const contrastSystem = resolveContrastSystem(theme, variant); const protectedText = contrastSystem.textPrimary; const quietSurfacePercent = Math.round( @@ -1086,13 +1286,16 @@ const strongSurfacePercent = Math.round( contrastSystem.strongSurfaceOpacity * 100, ); - const accentText = readable(appearance.surface, appearance.accent); + const accentText = readable( + authoredAppearance.surface, + authoredAppearance.accent, + ); const palette = semanticPalette( - appearance, + authoredAppearance, background, - variant, contrastSystem, ); + const appearance = { ...authoredAppearance, border: palette.border }; const style = document.createElement("style"); style.id = STYLE_ID; const semantic = safeMode @@ -1102,13 +1305,108 @@ html[data-codex-styler][data-codex-styler-mode="semantic"] body, html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] { color-scheme: ${variant} !important; + --codex-styler-motion-duration: ${motionDuration}ms; + --codex-styler-motion-lift: ${motionProfile.lift}px; + --codex-styler-motion-press-scale: ${motionProfile.pressScale}; + --codex-styler-motion-overlay-opacity: ${motionProfile.overlayOpacity}; + --codex-styler-scrollbar-thumb: color-mix(in srgb, var(--codex-styler-accent) 26%, var(--codex-styler-border-strong)); + --codex-styler-scrollbar-thumb-hover: color-mix(in srgb, var(--codex-styler-accent) 52%, var(--codex-styler-border-strong)); + --codex-styler-material-raised: color-mix(in srgb, var(--codex-styler-surface-raised) 92%, transparent); + --codex-styler-material-overlay: color-mix(in srgb, var(--codex-styler-surface-overlay) 96%, transparent); + --codex-styler-material-sunken: color-mix(in srgb, var(--codex-styler-surface-sunken) 92%, transparent); + --codex-styler-material-border: var(--codex-styler-border); + --codex-styler-material-filter: saturate(1.04) blur(${Math.max(4, Math.round(appearance.focusBlur * 0.65))}px); + --codex-styler-material-raised-shadow: 0 10px 28px rgb(0 0 0 / 9%); + --codex-styler-material-overlay-shadow: inset 0 1px color-mix(in srgb, var(--codex-styler-text-primary) 5%, transparent), 0 20px 54px rgb(0 0 0 / 17%); ${codexColorTokenDeclarations(palette)} } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-material="solid"] { + --codex-styler-material-raised: var(--codex-styler-surface-raised); + --codex-styler-material-overlay: var(--codex-styler-surface-overlay); + --codex-styler-material-sunken: var(--codex-styler-surface-sunken); + --codex-styler-material-border: var(--codex-styler-border-strong); + --codex-styler-material-filter: none; + --codex-styler-material-raised-shadow: 0 5px 16px rgb(0 0 0 / 8%); + --codex-styler-material-overlay-shadow: 0 14px 34px rgb(0 0 0 / 16%); + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-material="frosted"] { + --codex-styler-material-raised: linear-gradient(145deg, color-mix(in srgb, var(--codex-styler-surface-raised) 86%, transparent), color-mix(in srgb, var(--codex-styler-surface-overlay) 70%, transparent)); + --codex-styler-material-overlay: linear-gradient(145deg, color-mix(in srgb, var(--codex-styler-surface-overlay) 88%, transparent), color-mix(in srgb, var(--codex-styler-surface-raised) 68%, transparent)); + --codex-styler-material-sunken: color-mix(in srgb, var(--codex-styler-surface-sunken) 82%, transparent); + --codex-styler-material-border: color-mix(in srgb, var(--codex-styler-accent) 22%, var(--codex-styler-border)); + --codex-styler-material-filter: saturate(1.14) blur(${Math.max(14, appearance.focusBlur)}px); + --codex-styler-material-raised-shadow: inset 0 1px color-mix(in srgb, var(--codex-styler-text-primary) 7%, transparent), 0 15px 38px rgb(0 0 0 / 13%); + --codex-styler-material-overlay-shadow: inset 0 1px color-mix(in srgb, var(--codex-styler-text-primary) 9%, transparent), 0 24px 64px rgb(0 0 0 / 21%); + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="subtle"] { + --codex-styler-scrollbar-thumb: color-mix(in srgb, var(--codex-styler-accent) 38%, var(--codex-styler-border-strong)); + --codex-styler-scrollbar-thumb-hover: color-mix(in srgb, var(--codex-styler-accent) 64%, var(--codex-styler-border-strong)); + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="expressive"] { + --codex-styler-scrollbar-thumb: color-mix(in srgb, var(--codex-styler-accent) 54%, var(--codex-styler-border-strong)); + --codex-styler-scrollbar-thumb-hover: color-mix(in srgb, var(--codex-styler-accent) 78%, var(--codex-styler-text-primary)); + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-typography="balanced"] { + --codex-styler-content-leading: 1.5; + --codex-styler-heading-leading: 1.28; + --codex-styler-heading-weight: 650; + --codex-styler-heading-tracking: -.008em; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-typography="editorial"] { + --codex-styler-content-leading: 1.58; + --codex-styler-heading-leading: 1.2; + --codex-styler-heading-weight: 690; + --codex-styler-heading-tracking: -.018em; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-typography="cinematic"] { + --codex-styler-content-leading: 1.64; + --codex-styler-heading-leading: 1.16; + --codex-styler-heading-weight: 720; + --codex-styler-heading-tracking: -.026em; + } html[data-codex-styler][data-codex-styler-mode="semantic"] body, html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] { background: transparent !important; color: var(--codex-styler-text-primary) !important; } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}], + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] *, + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}], + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] * { + scrollbar-width: thin; + scrollbar-color: var(--codex-styler-scrollbar-thumb) transparent; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] *::-webkit-scrollbar, + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-track, + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-track { + background: transparent; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-thumb, + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-thumb { + min-width: 28px; + min-height: 28px; + border: 2px solid transparent; + border-radius: 999px; + background: var(--codex-styler-scrollbar-thumb) padding-box; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-thumb:hover, + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-thumb:hover { + background: var(--codex-styler-scrollbar-thumb-hover) padding-box; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-button, + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-corner, + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] *::-webkit-scrollbar-corner { + background: transparent; + } html[data-codex-styler][data-codex-styler-mode="semantic"] aside.app-shell-left-panel { color: var(--codex-styler-text-primary) !important; background: linear-gradient(180deg, color-mix(in srgb, ${appearance.surface} ${Math.min(99, strongSurfacePercent + 2)}%, transparent), color-mix(in srgb, ${appearance.surface} ${strongSurfacePercent}%, transparent)) !important; @@ -1119,7 +1417,7 @@ background: transparent !important; } html[data-codex-styler][data-codex-styler-mode="semantic"] aside.app-shell-left-panel button { - transition: color 150ms ease, background 150ms ease, box-shadow 150ms ease !important; + transition: color var(--codex-styler-motion-duration) ease, background var(--codex-styler-motion-duration) ease, box-shadow var(--codex-styler-motion-duration) ease !important; } html[data-codex-styler][data-codex-styler-mode="semantic"] aside.app-shell-left-panel button:hover { background: color-mix(in srgb, ${appearance.accent} 10%, transparent) !important; @@ -1184,16 +1482,24 @@ } html[data-codex-styler][data-codex-styler-mode="semantic"] main.main-surface article, html[data-codex-styler][data-codex-styler-mode="semantic"] main.main-surface [data-message-author-role], - html[data-codex-styler][data-codex-styler-mode="semantic"] .composer-surface-chrome, + html[data-codex-styler][data-codex-styler-mode="semantic"] .composer-surface-chrome { + background: var(--codex-styler-material-raised) !important; + border-color: var(--codex-styler-material-border) !important; + border-radius: ${appearance.radius}px !important; + color: var(--codex-styler-text-primary) !important; + box-shadow: var(--codex-styler-material-raised-shadow) !important; + backdrop-filter: var(--codex-styler-material-filter) !important; + } html[data-codex-styler][data-codex-styler-mode="semantic"] [role="dialog"], + html[data-codex-styler][data-codex-styler-mode="semantic"] [role="alertdialog"], html[data-codex-styler][data-codex-styler-mode="semantic"] [role="menu"], html[data-codex-styler][data-codex-styler-mode="semantic"] [role="listbox"] { - background: color-mix(in srgb, ${appearance.surface} ${strongSurfacePercent}%, transparent) !important; - border-color: ${appearance.border} !important; + background: var(--codex-styler-material-overlay) !important; + border-color: var(--codex-styler-material-border) !important; border-radius: ${appearance.radius}px !important; color: var(--codex-styler-text-primary) !important; - box-shadow: 0 1px 0 color-mix(in srgb, ${protectedText} 5%, transparent), 0 14px 40px rgb(0 0 0 / 9%) !important; - backdrop-filter: saturate(1.08) blur(${appearance.focusBlur}px) !important; + box-shadow: var(--codex-styler-material-overlay-shadow) !important; + backdrop-filter: var(--codex-styler-material-filter) !important; } html[data-codex-styler][data-codex-styler-mode="semantic"] .composer-surface-chrome:focus-within { border-color: color-mix(in srgb, ${appearance.accent} 55%, ${appearance.border}) !important; @@ -1210,11 +1516,11 @@ } html[data-codex-styler][data-codex-styler-mode="semantic"] [data-pip-obstacle="thread-summary-panel"] { color: var(--codex-styler-text-primary) !important; - background: color-mix(in srgb, ${appearance.surface} ${strongSurfacePercent}%, transparent) !important; - border: 1px solid color-mix(in srgb, ${appearance.border} 88%, transparent) !important; + background: var(--codex-styler-material-overlay) !important; + border: 1px solid var(--codex-styler-material-border) !important; border-radius: ${Math.max(12, appearance.radius + 4)}px !important; - box-shadow: 0 22px 70px rgb(0 0 0 / 16%), inset 0 1px color-mix(in srgb, ${protectedText} 5%, transparent) !important; - backdrop-filter: saturate(1.1) blur(${Math.max(12, appearance.focusBlur + 6)}px) !important; + box-shadow: var(--codex-styler-material-overlay-shadow) !important; + backdrop-filter: var(--codex-styler-material-filter) !important; } html[data-codex-styler][data-codex-styler-mode="semantic"] [data-pip-obstacle="thread-summary-panel"] > div, html[data-codex-styler][data-codex-styler-mode="semantic"] [data-pip-obstacle="thread-summary-panel"] section { @@ -1223,17 +1529,36 @@ html[data-codex-styler][data-codex-styler-mode="semantic"] [data-pip-obstacle="thread-summary-panel"] [data-slot="thread-summary-panel-item"], html[data-codex-styler][data-codex-styler-mode="semantic"] [data-pip-obstacle="thread-summary-panel"] [data-slot="thread-summary-panel-item-button"] { border-radius: ${Math.max(8, appearance.radius - 3)}px !important; - transition: background 150ms ease, transform 150ms ease !important; + transition: background var(--codex-styler-motion-duration) ease, transform var(--codex-styler-motion-duration) ease !important; } html[data-codex-styler][data-codex-styler-mode="semantic"] [data-pip-obstacle="thread-summary-panel"] [data-slot="thread-summary-panel-item-button"]:hover { background: color-mix(in srgb, ${appearance.accent} 11%, transparent) !important; - transform: translateX(2px); + transform: translateX(${Math.round(interactionLift * 0.5)}px); + } + /* Full-page settings replaces Codex's semantic
with a routed + div. Keep that route inside the same material and palette system + instead of letting it fall back to the native Codex surface. */ + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="settings"] body > [${APP_ROOT_ATTRIBUTE}] .main-surface { + color: var(--codex-styler-text-primary) !important; + background: color-mix(in srgb, ${appearance.surface} ${quietSurfacePercent}%, transparent) !important; + box-shadow: inset 0 0 0 1px color-mix(in srgb, ${appearance.border} 84%, transparent) !important; + backdrop-filter: saturate(1.04) blur(${Math.max(2, Math.round(appearance.focusBlur * 0.35))}px) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="settings"] body > [${APP_ROOT_ATTRIBUTE}] .main-surface > :is(nav, [role="tablist"], [role="tabpanel"], section) { + color: var(--codex-styler-text-primary) !important; + background: var(--codex-styler-material-raised) !important; + box-shadow: inset 0 0 0 1px var(--codex-styler-material-border), var(--codex-styler-material-raised-shadow) !important; + backdrop-filter: var(--codex-styler-material-filter) !important; } html[data-codex-styler][data-codex-styler-mode="semantic"] :is(input, textarea, select) { color: var(--codex-styler-text-primary) !important; + caret-color: ${appearance.accent} !important; border-color: ${appearance.border} !important; background-color: color-mix(in srgb, ${appearance.surface} ${strongSurfacePercent}%, transparent) !important; } + html[data-codex-styler][data-codex-styler-mode="semantic"] [contenteditable="true"] { + caret-color: ${appearance.accent} !important; + } /* Codex's own appearance can leave utility classes with a stale foreground after a Styler theme changes. Exact semantic roles take precedence here; links, statuses, diffs, and accents keep meaning. */ @@ -1279,24 +1604,211 @@ opacity: 1 !important; } html[data-codex-styler][data-codex-styler-mode="semantic"] :is(a, [role="link"]) { + color: var(--codex-styler-info) !important; text-decoration-color: color-mix(in srgb, ${appearance.accent} 55%, transparent); text-underline-offset: 3px; } + html[data-codex-styler][data-codex-styler-mode="semantic"] :is(a, [role="link"]):hover { + color: color-mix(in srgb, var(--codex-styler-info) 72%, var(--codex-styler-text-primary)) !important; + text-decoration-color: ${appearance.accent}; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is( + button, + a, + input, + textarea, + select, + [role="button"], + [role="tab"], + [role="option"], + [role="switch"] + ) { + 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) { + html[data-codex-styler][data-codex-styler-mode="semantic"]:not([data-codex-styler-motion="still"]) body > [${APP_ROOT_ATTRIBUTE}] :is( + aside.app-shell-left-panel nav button, + .composer-surface-chrome button, + [role="tab"], + [role="option"] + ):not(:disabled):not([aria-disabled="true"]):hover { + transform: translateY(calc(-1 * var(--codex-styler-motion-lift))) scale(1) !important; + } + } + html[data-codex-styler][data-codex-styler-mode="semantic"]:not([data-codex-styler-motion="still"]) body > [${APP_ROOT_ATTRIBUTE}] :is( + aside.app-shell-left-panel nav button, + .composer-surface-chrome button, + [role="tab"], + [role="option"] + ):not(:disabled):not([aria-disabled="true"]):active { + transform: translateY(0) scale(var(--codex-styler-motion-press-scale)) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is( + button, + a, + input, + textarea, + select, + [role="button"], + [role="tab"], + [role="option"], + [role="switch"] + ):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; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="tablist"] { + border-color: color-mix(in srgb, ${appearance.border} 78%, transparent) !important; + background: color-mix(in srgb, ${appearance.surface} ${Math.max(48, quietSurfacePercent - 12)}%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="tab"] { + color: var(--codex-styler-text-secondary) !important; + border-color: transparent !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="tab"]:hover { + color: var(--codex-styler-text-primary) !important; + background: color-mix(in srgb, ${appearance.accent} 8%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="tab"][aria-selected="true"] { + color: var(--codex-styler-text-primary) !important; + background: color-mix(in srgb, ${appearance.accent} 13%, transparent) !important; + box-shadow: inset 0 -2px ${appearance.accent} !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="option"][aria-selected="true"] { + color: var(--codex-styler-text-primary) !important; + background: color-mix(in srgb, ${appearance.accent} 14%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is( + input[type="checkbox"], + input[type="radio"], + input[type="range"] + ) { + accent-color: ${appearance.accent} !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] [role="switch"][aria-checked="true"] { + 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}] :is( + button, + input, + textarea, + select, + [role="button"], + [role="tab"], + [role="option"], + [role="switch"], + [role="checkbox"], + [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; + box-shadow: none !important; + filter: saturate(.72); + opacity: .58 !important; + } + 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( + [aria-pressed="true"], + [aria-checked="true"], + [data-state="active"], + [data-state="open"], + [data-state="checked"], + [data-state="on"] + ) { + color: var(--codex-styler-text-primary) !important; + border-color: color-mix(in srgb, ${appearance.accent} 54%, ${appearance.border}) !important; + background-color: var(--codex-styler-control-active) !important; + box-shadow: inset 0 -2px color-mix(in srgb, ${appearance.accent} 82%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is( + input, + textarea, + select, + [role="textbox"], + [role="combobox"] + )[aria-invalid="true"] { + border-color: color-mix(in srgb, var(--codex-styler-danger) 70%, ${appearance.border}) !important; + background-color: color-mix(in srgb, var(--codex-styler-danger) 7%, var(--codex-styler-control)) !important; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--codex-styler-danger) 12%, transparent) !important; + } html[data-codex-styler][data-codex-styler-mode="semantic"] :is([role="dialog"], [role="menu"], [role="listbox"]) button:hover { background-color: color-mix(in srgb, ${appearance.accent} 10%, transparent) !important; } - - /* Icon treatments preserve every native SVG dimension and padding. */ - html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] aside.app-shell-left-panel button > svg:first-child, - html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="contained"] [data-pip-obstacle="thread-summary-panel"] button > svg:first-child { - color: ${appearance.accent} !important; - filter: drop-shadow(0 2px 5px color-mix(in srgb, ${appearance.accent} 22%, transparent)); + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is( + [role="dialog"], + [role="alertdialog"], + [role="menu"], + [role="listbox"], + [role="tooltip"], + [role="alert"], + [role="status"], + [data-sonner-toast] + ) { + color: var(--codex-styler-text-primary) !important; + border-color: color-mix(in srgb, ${appearance.border} 88%, transparent) !important; + background: color-mix(in srgb, var(--codex-styler-surface-overlay) 96%, transparent) !important; + box-shadow: 0 20px 62px rgb(0 0 0 / 22%), inset 0 1px color-mix(in srgb, ${protectedText} 6%, transparent) !important; + backdrop-filter: saturate(1.08) blur(${Math.max(12, appearance.focusBlur + 4)}px) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"]:is( + [data-codex-styler-motion="fluid"], + [data-codex-styler-motion="expressive"] + ) body > [${OVERLAY_ROOT_ATTRIBUTE}] :is( + [role="dialog"], + [role="alertdialog"], + [role="menu"], + [role="listbox"] + ) { + animation: codex-styler-surface-enter var(--codex-styler-motion-duration) cubic-bezier(.2, .72, .2, 1) both !important; } - html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] aside.app-shell-left-panel button > svg:first-child, - html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-icons="themed"] [data-pip-obstacle="thread-summary-panel"] button > svg:first-child { - color: color-mix(in srgb, ${appearance.accent} 86%, ${protectedText}) !important; - stroke-width: 2.25px; - filter: drop-shadow(0 3px 7px color-mix(in srgb, ${appearance.accent} 34%, transparent)); + @keyframes codex-styler-surface-enter { + from { + opacity: var(--codex-styler-motion-overlay-opacity); + } + to { + opacity: 1; + } + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] [role="tooltip"] { + border-radius: ${Math.max(6, appearance.radius - 5)}px !important; + box-shadow: 0 10px 28px rgb(0 0 0 / 20%), inset 0 1px color-mix(in srgb, ${protectedText} 6%, transparent) !important; + } + + /* + * Icon treatments are semantic and geometry-safe. They cover native + * navigation, top bars, tabs, composer actions, side panels and + * transient overlays without changing SVG dimensions, padding or + * 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 { + 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 { + overflow: visible; + border-radius: 999px; + color: var(--codex-styler-icon-emphasis) !important; + box-shadow: 0 0 0 3px color-mix(in srgb, ${appearance.accent} 13%, transparent), 0 0 12px color-mix(in srgb, ${appearance.accent} 18%, transparent); + filter: drop-shadow(0 3px 7px color-mix(in srgb, ${appearance.accent} 30%, transparent)); } /* Full home composition: hero, suggestions, project rail, and composer. */ @@ -1357,16 +1869,16 @@ position: relative !important; min-height: clamp(96px, 14cqh, 118px) !important; padding: clamp(12px, 1.8cqh, 15px) !important; - border: 1px solid color-mix(in srgb, ${appearance.accent} 20%, ${appearance.border}) !important; + border: 1px solid var(--codex-styler-material-border) !important; border-radius: ${Math.max(16, appearance.radius + 5)}px !important; color: ${protectedText} !important; - background: linear-gradient(145deg, color-mix(in srgb, ${appearance.surface} ${Math.min(98, strongSurfacePercent + 4)}%, transparent), color-mix(in srgb, ${appearance.surface} ${Math.max(56, strongSurfacePercent - 8)}%, transparent)) !important; - box-shadow: 0 12px 30px rgb(0 0 0 / 12%), inset 0 1px color-mix(in srgb, ${protectedText} 6%, transparent) !important; - backdrop-filter: blur(${Math.max(10, appearance.focusBlur)}px) saturate(1.08) !important; - transition: transform 170ms ease, border-color 170ms ease, box-shadow 170ms ease !important; + background: var(--codex-styler-material-raised) !important; + box-shadow: var(--codex-styler-material-raised-shadow) !important; + backdrop-filter: var(--codex-styler-material-filter) !important; + transition: transform var(--codex-styler-motion-duration) ease, border-color var(--codex-styler-motion-duration) ease, box-shadow var(--codex-styler-motion-duration) ease !important; } html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="home"] .group\\/home-suggestions button:hover { - transform: translateY(-4px) !important; + transform: translateY(-${interactionLift}px) !important; border-color: color-mix(in srgb, ${appearance.accent} 58%, ${appearance.border}) !important; box-shadow: 0 18px 38px rgb(0 0 0 / 16%), 0 0 0 3px color-mix(in srgb, ${appearance.accent} 8%, transparent) !important; } @@ -1385,11 +1897,11 @@ /* Codex intentionally tucks the rail shell behind the composer. Keep its lower safe area so the interactive row never follows. */ padding: clamp(26px, 4cqh, 32px) 10px 27px !important; - border: 1px solid color-mix(in srgb, ${appearance.accent} 14%, ${appearance.border}) !important; + border: 1px solid var(--codex-styler-material-border) !important; border-bottom: 0 !important; border-radius: ${Math.max(14, appearance.radius + 3)}px ${Math.max(14, appearance.radius + 3)}px 0 0; - background: color-mix(in srgb, ${appearance.surface} ${strongSurfacePercent}%, transparent) !important; - backdrop-filter: blur(${appearance.focusBlur}px) !important; + background: var(--codex-styler-material-raised) !important; + backdrop-filter: var(--codex-styler-material-filter) !important; } html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="home"] div:has(> .horizontal-scroll-fade-mask .group\\/project-selector)::before { content: "WORKSPACE"; @@ -1452,19 +1964,288 @@ } html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="task"] [data-message-author-role="user"] { border-left: 3px solid ${appearance.accent} !important; + border-radius: ${Math.max(8, appearance.radius - 2)}px !important; + background: color-mix(in srgb, var(--codex-styler-control-active) 72%, transparent) !important; + box-shadow: inset 0 1px color-mix(in srgb, ${appearance.accent} 10%, transparent), 0 8px 22px rgb(0 0 0 / 7%) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="task"][data-codex-styler-decorations="expressive"] [data-message-author-role="user"] { + border-left-color: color-mix(in srgb, ${appearance.accent} 82%, var(--codex-styler-text-primary)) !important; + box-shadow: inset 0 1px color-mix(in srgb, ${appearance.accent} 18%, transparent), 0 12px 30px rgb(0 0 0 / 11%) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] pre { + overflow: auto; + border: 1px solid var(--codex-styler-material-border) !important; + background: var(--codex-styler-material-sunken) !important; + box-shadow: inset 0 1px color-mix(in srgb, ${protectedText} 4%, transparent) !important; + scrollbar-color: color-mix(in srgb, ${appearance.accent} 42%, transparent) transparent; } html[data-codex-styler][data-codex-styler-mode="semantic"] pre, - html[data-codex-styler][data-codex-styler-mode="semantic"] code { + html[data-codex-styler][data-codex-styler-mode="semantic"] code, + html[data-codex-styler][data-codex-styler-mode="semantic"] kbd { border-color: color-mix(in srgb, ${appearance.border} 75%, transparent) !important; border-radius: ${Math.max(6, appearance.radius - 4)}px !important; } + html[data-codex-styler][data-codex-styler-mode="semantic"] :not(pre) > code, + html[data-codex-styler][data-codex-styler-mode="semantic"] kbd { + color: var(--codex-styler-text-primary) !important; + background: var(--codex-styler-material-raised) !important; + box-shadow: inset 0 0 0 1px color-mix(in srgb, ${appearance.border} 70%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] blockquote { + border-inline-start-color: color-mix(in srgb, ${appearance.accent} 68%, ${appearance.border}) !important; + background: color-mix(in srgb, ${appearance.accent} 6%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] hr { + border-color: color-mix(in srgb, ${appearance.border} 72%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] table { + overflow: hidden; + border: 1px solid var(--codex-styler-material-border) !important; + border-collapse: separate !important; + border-spacing: 0 !important; + border-radius: ${Math.max(8, appearance.radius - 2)}px !important; + color: var(--codex-styler-text-primary) !important; + background: var(--codex-styler-material-raised) !important; + box-shadow: var(--codex-styler-material-raised-shadow) !important; + backdrop-filter: var(--codex-styler-material-filter) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(th, td) { + border-color: color-mix(in srgb, ${appearance.border} 68%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] th { + color: var(--codex-styler-text-secondary) !important; + background: color-mix(in srgb, ${appearance.accent} 7%, var(--codex-styler-surface-sunken)) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] tbody tr:hover > :is(th, td) { + background: color-mix(in srgb, ${appearance.accent} 6%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] details { + overflow: hidden; + border-color: var(--codex-styler-material-border) !important; + border-radius: ${Math.max(8, appearance.radius - 2)}px !important; + color: var(--codex-styler-text-primary) !important; + background: var(--codex-styler-material-raised) !important; + box-shadow: var(--codex-styler-material-raised-shadow) !important; + backdrop-filter: var(--codex-styler-material-filter) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] summary { + color: var(--codex-styler-text-primary) !important; + cursor: pointer; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] details[open] > summary { + border-bottom-color: color-mix(in srgb, ${appearance.border} 66%, transparent) !important; + background: color-mix(in srgb, ${appearance.accent} 5%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(progress, meter, [role="progressbar"]) { + accent-color: ${appearance.accent} !important; + --codex-styler-progress-accent: ${appearance.accent}; + --codex-styler-progress-track: color-mix(in srgb, ${appearance.border} 52%, transparent); + overflow: hidden; + border-radius: 999px !important; + background: var(--codex-styler-progress-track) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] progress::-webkit-progress-bar { + border-radius: 999px; + background: var(--codex-styler-progress-track) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] progress::-webkit-progress-value { + border-radius: 999px; + background: var(--codex-styler-progress-accent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] mark { + color: var(--codex-styler-text-primary) !important; + background: color-mix(in srgb, ${appearance.accent} 24%, transparent) !important; + border-radius: 4px; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] ins { + color: var(--codex-styler-added) !important; + background: color-mix(in srgb, var(--codex-styler-added) 11%, transparent) !important; + text-decoration: none !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] del { + color: var(--codex-styler-deleted) !important; + background: color-mix(in srgb, var(--codex-styler-deleted) 11%, transparent) !important; + text-decoration-color: color-mix(in srgb, var(--codex-styler-deleted) 68%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(samp, output) { + color: var(--codex-styler-text-secondary) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] pre :is(samp, output) { + font-family: inherit !important; + } + /* Theme typography is content-scoped and geometry-safe: it changes + reading rhythm without replacing fonts, resizing text, or moving + controls. Codex keeps ownership of the underlying typefaces. */ + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(article, [data-message-author-role]) :is(p, li, blockquote), + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] .ProseMirror, + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is([role="dialog"], [role="tabpanel"]) :is(p, li) { + line-height: var(--codex-styler-content-leading) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(article, [data-message-author-role]) :is(h1, h2, h3), + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is([role="dialog"], [role="tabpanel"]) :is(h1, h2, h3) { + font-weight: var(--codex-styler-heading-weight) !important; + line-height: var(--codex-styler-heading-leading) !important; + letter-spacing: var(--codex-styler-heading-tracking) !important; + } + html:lang(zh)[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(article, [data-message-author-role]) :is(h1, h2, h3), + html:lang(zh)[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is([role="dialog"], [role="tabpanel"]) :is(h1, h2, h3) { + letter-spacing: normal !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(fieldset, [role="tree"], [role="grid"]) { + border-color: color-mix(in srgb, ${appearance.border} 78%, transparent) !important; + color: var(--codex-styler-text-primary) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(legend, figcaption, dt, [role="columnheader"], [role="rowheader"]) { + color: var(--codex-styler-text-secondary) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is(dd, output, samp, [role="cell"], [role="gridcell"]) { + color: var(--codex-styler-text-primary) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is([role="treeitem"], [role="row"]) { + border-radius: ${Math.max(6, appearance.radius - 5)}px !important; + transition: background-color 150ms ease, color 150ms ease !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is([role="treeitem"], [role="row"]):hover { + background: color-mix(in srgb, ${appearance.accent} 6%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is( + [role="treeitem"][aria-selected="true"], + [role="row"][aria-selected="true"], + [aria-current]:not([aria-current="false"]) + ) { + color: var(--codex-styler-text-primary) !important; + background: color-mix(in srgb, ${appearance.accent} 12%, transparent) !important; + box-shadow: inset 3px 0 color-mix(in srgb, ${appearance.accent} 76%, ${appearance.border}) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="subtle"] [role="tab"][aria-selected="true"], + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="subtle"] pre { + border-color: color-mix(in srgb, ${appearance.accent} 28%, ${appearance.border}) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="expressive"] [role="tab"][aria-selected="true"] { + box-shadow: inset 0 -2px ${appearance.accent}, 0 0 16px color-mix(in srgb, ${appearance.accent} 12%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="expressive"] pre { + border-color: color-mix(in srgb, ${appearance.accent} 42%, ${appearance.border}) !important; + box-shadow: inset 3px 0 ${appearance.accent}, inset 0 1px color-mix(in srgb, ${protectedText} 5%, transparent) !important; + } + /* Geometry is derived from the portable radius instead of theme ids, + so imported themes receive the same coherent component treatment. */ + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-geometry="precise"] body > [${APP_ROOT_ATTRIBUTE}] [role="tablist"] { + border-radius: ${Math.max(6, appearance.radius - 4)}px !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-geometry="precise"] body > [${APP_ROOT_ATTRIBUTE}] [role="tab"] { + border-radius: ${Math.max(4, appearance.radius - 6)}px !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-geometry="precise"] body > [${APP_ROOT_ATTRIBUTE}] [role="tab"][aria-selected="true"] { + box-shadow: inset 3px 0 ${appearance.accent}, inset 0 0 0 1px color-mix(in srgb, ${appearance.accent} 24%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-geometry="precise"] :is( + .composer-surface-chrome, + [role="dialog"], + [role="menu"], + [role="listbox"], + [data-pip-obstacle="thread-summary-panel"] + ) { + border-radius: ${Math.max(7, appearance.radius - 3)}px !important; + box-shadow: inset 2px 0 color-mix(in srgb, ${appearance.accent} 30%, transparent), 0 16px 42px rgb(0 0 0 / 14%) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-geometry="soft"] body > [${APP_ROOT_ATTRIBUTE}] [role="tablist"] { + padding: 3px !important; + border-radius: 999px !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-geometry="soft"] body > [${APP_ROOT_ATTRIBUTE}] [role="tab"] { + border-radius: 999px !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-geometry="soft"] body > [${APP_ROOT_ATTRIBUTE}] [role="tab"][aria-selected="true"] { + box-shadow: inset 0 -2px ${appearance.accent}, 0 7px 18px color-mix(in srgb, ${appearance.accent} 16%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-geometry="soft"] :is( + .composer-surface-chrome, + [role="dialog"], + [role="menu"], + [role="listbox"], + [data-pip-obstacle="thread-summary-panel"] + ) { + border-radius: ${Math.min(28, appearance.radius + 4)}px !important; + box-shadow: inset 0 1px color-mix(in srgb, ${protectedText} 7%, transparent), 0 22px 58px rgb(0 0 0 / 19%), 0 0 0 3px color-mix(in srgb, ${appearance.accent} 6%, transparent) !important; + } + /* Decoration depth is intentionally geometry-safe and comes after + shape rules so the selected detail level remains visible on every + Codex focus surface. It never changes layout, stacking or size. */ + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="subtle"] :is( + .composer-surface-chrome, + [role="dialog"], + [role="alertdialog"], + [role="menu"], + [role="listbox"], + [data-pip-obstacle="thread-summary-panel"] + ) { + border-color: color-mix(in srgb, ${appearance.accent} 28%, ${appearance.border}) !important; + box-shadow: inset 0 1px color-mix(in srgb, ${appearance.accent} 12%, transparent), 0 16px 42px rgb(0 0 0 / 13%), 0 0 0 2px color-mix(in srgb, ${appearance.accent} 5%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="settings"][data-codex-styler-decorations="subtle"] body > [${APP_ROOT_ATTRIBUTE}] .main-surface > :is(nav, [role="tablist"], [role="tabpanel"], section) { + box-shadow: inset 0 0 0 1px color-mix(in srgb, ${appearance.accent} 22%, ${appearance.border}), inset 0 1px color-mix(in srgb, ${appearance.accent} 8%, transparent) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="expressive"] :is( + .composer-surface-chrome, + [role="dialog"], + [role="alertdialog"], + [role="menu"], + [role="listbox"], + [data-pip-obstacle="thread-summary-panel"] + ) { + border-color: color-mix(in srgb, ${appearance.accent} 48%, ${appearance.border}) !important; + box-shadow: inset 0 2px color-mix(in srgb, ${appearance.accent} 20%, transparent), 0 24px 64px rgb(0 0 0 / 20%), 0 0 0 3px color-mix(in srgb, ${appearance.accent} 8%, transparent) !important; + backdrop-filter: saturate(1.14) blur(${Math.max(12, appearance.focusBlur + 6)}px) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="settings"][data-codex-styler-decorations="expressive"] body > [${APP_ROOT_ATTRIBUTE}] .main-surface > :is(nav, [role="tablist"], [role="tabpanel"], section) { + background: color-mix(in srgb, var(--codex-styler-surface-raised) 84%, transparent) !important; + box-shadow: inset 3px 0 color-mix(in srgb, ${appearance.accent} 68%, ${appearance.border}), inset 0 1px color-mix(in srgb, ${appearance.accent} 14%, transparent), 0 18px 46px rgb(0 0 0 / 13%) !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-decorations="expressive"] :is( + [role="treeitem"][aria-selected="true"], + [role="row"][aria-selected="true"], + [aria-current]:not([aria-current="false"]) + ) { + box-shadow: inset 3px 0 ${appearance.accent}, inset 0 0 0 1px color-mix(in srgb, ${appearance.accent} 18%, transparent), 0 8px 20px rgb(0 0 0 / 9%) !important; + } html[data-codex-styler][data-codex-styler-mode="semantic"] ::selection { + color: var(--codex-styler-text-primary); background: color-mix(in srgb, ${appearance.accent} 28%, transparent); + text-shadow: none; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] ::target-text { + color: var(--codex-styler-text-primary); + background: color-mix(in srgb, ${appearance.accent} 24%, transparent); } @media (prefers-reduced-motion: reduce) { - html[data-codex-styler][data-codex-styler-mode="semantic"] aside.app-shell-left-panel button { + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${APP_ROOT_ATTRIBUTE}] :is( + button, + a, + input, + textarea, + select, + [role="button"], + [role="tab"], + [role="option"], + [role="switch"], + [role="treeitem"], + [role="row"] + ) { transition: none !important; } + html[data-codex-styler][data-codex-styler-mode="semantic"] [data-pip-obstacle="thread-summary-panel"] [data-slot="thread-summary-panel-item-button"]:hover, + html[data-codex-styler][data-codex-styler-mode="semantic"][data-codex-styler-page="home"] .group\\/home-suggestions button:hover { + transform: none !important; + } + html[data-codex-styler][data-codex-styler-mode="semantic"] body > [${OVERLAY_ROOT_ATTRIBUTE}] :is( + [role="dialog"], + [role="alertdialog"], + [role="menu"], + [role="listbox"] + ) { + animation: none !important; + } } `; style.textContent = ` @@ -1506,6 +2287,10 @@ background-size: cover; transform: scale(1.015); } + #${BACKDROP_ID} [data-parallax] { + will-change: transform; + transition: transform var(--codex-styler-motion-duration) cubic-bezier(.2, .8, .2, 1); + } #${BACKDROP_ID} .cs-layer-gradient { background: linear-gradient(135deg, color-mix(in srgb, ${appearance.accent} 22%, transparent), transparent 42%), @@ -1681,29 +2466,50 @@ typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => entityPositioner?.()); + const positionAtAnchor = () => { + const point = safeFreeEntityPosition( + (entity.anchor.x / 100) * window.innerWidth, + (entity.anchor.y / 100) * window.innerHeight, + size, + logicalHeight, + window.innerWidth, + window.innerHeight, + ); + canvas.style.left = `${point.x}px`; + canvas.style.top = `${point.y}px`; + canvas.style.transform = "translate(-50%, -50%)"; + }; const position = () => { const attachment = entity.attachment; if (!attachment) { - canvas.style.left = `${entity.anchor.x}%`; - canvas.style.top = `${entity.anchor.y}%`; - canvas.style.transform = "translate(-50%, -50%)"; + positionAtAnchor(); if (watchedTarget) attachmentObserver?.unobserve(watchedTarget); watchedTarget = null; return; } const target = entityTarget(attachment.target); - if (!target) return; + if (!target) { + positionAtAnchor(); + if (watchedTarget) attachmentObserver?.unobserve(watchedTarget); + watchedTarget = null; + return; + } if (watchedTarget !== target) { if (watchedTarget) attachmentObserver?.unobserve(watchedTarget); watchedTarget = target; attachmentObserver?.observe(target); } const bounds = target.getBoundingClientRect(); - canvas.style.left = `${bounds.left + bounds.width * attachment.align + attachment.offset.x}px`; - canvas.style.top = `${ - (attachment.edge === "bottom" ? bounds.bottom : bounds.top) + - attachment.offset.y - }px`; + const point = safeAttachedEntityPosition( + bounds, + attachment, + size, + logicalHeight, + window.innerWidth, + window.innerHeight, + ); + canvas.style.left = `${point.x}px`; + canvas.style.top = `${point.y}px`; canvas.style.transform = attachment.edge === "bottom" ? "translate(-50%, 0)" @@ -1742,15 +2548,18 @@ return; } delete entity.attachment; - const x = Math.max( - 3, - Math.min(97, (event.clientX / window.innerWidth) * 100), + const point = safeFreeEntityPosition( + event.clientX, + event.clientY, + size, + logicalHeight, + window.innerWidth, + window.innerHeight, ); - const y = Math.max( - 5, - Math.min(95, (event.clientY / window.innerHeight) * 100), - ); - entity.anchor = { x, y }; + entity.anchor = { + x: window.innerWidth > 0 ? (point.x / window.innerWidth) * 100 : 50, + y: window.innerHeight > 0 ? (point.y / window.innerHeight) * 100 : 50, + }; position(); }; const onUp = (event) => { @@ -2037,6 +2846,11 @@ scheduleDraw(); }; window.addEventListener("resize", resizeHandler, { passive: true }); + const scrollHandler = () => position(); + document.addEventListener("scroll", scrollHandler, { + capture: true, + passive: true, + }); entityCleanup = () => { cancelIdle(); document.removeEventListener("visibilitychange", onVisibilityChange); @@ -2044,6 +2858,7 @@ entry.image.removeAttribute("src"); pageCache.clear(); attachmentObserver?.disconnect(); + document.removeEventListener("scroll", scrollHandler, true); canvas.removeEventListener("pointerdown", onDown); canvas.removeEventListener("pointermove", onMove); canvas.removeEventListener("pointerup", onUp); @@ -2173,6 +2988,30 @@ "data-codex-styler-decorations", appearance.decorations || "none", ); + document.documentElement.setAttribute( + "data-codex-styler-geometry", + appearance.radius <= 11 + ? "precise" + : appearance.radius >= 17 + ? "soft" + : "balanced", + ); + document.documentElement.setAttribute( + "data-codex-styler-material", + resolveMaterialCharacter(appearance), + ); + document.documentElement.setAttribute( + "data-codex-styler-typography", + appearance.layout === "editorial" + ? "editorial" + : appearance.layout === "immersive" + ? "cinematic" + : "balanced", + ); + document.documentElement.setAttribute( + "data-codex-styler-motion", + resolveMotionProfile(theme.variants[variant].motion.intensity).character, + ); updateStackingRoots(); installStyles(theme, variant, Boolean(safeMode)); updatePageKind(); @@ -2231,13 +3070,16 @@ globalParallax > 0 && sceneLayers.some((element) => Number(element.dataset.parallax) !== 0) ) { + let pointerX = 0; + let pointerY = 0; scenePointerHandler = (event) => { - if (sceneAnimationFrame !== null) - cancelAnimationFrame(sceneAnimationFrame); + pointerX = event.clientX; + pointerY = event.clientY; + if (sceneAnimationFrame !== null) return; sceneAnimationFrame = requestAnimationFrame(() => { sceneAnimationFrame = null; - const x = event.clientX / Math.max(1, window.innerWidth) - 0.5; - const y = event.clientY / Math.max(1, window.innerHeight) - 0.5; + const x = pointerX / Math.max(1, window.innerWidth) - 0.5; + const y = pointerY / Math.max(1, window.innerHeight) - 0.5; sceneLayers.forEach((element) => { const authoredDepth = Number(element.dataset.parallax || 0); const cappedDepth = @@ -2252,9 +3094,20 @@ }); }); }; + sceneResetHandler = () => { + if (sceneAnimationFrame !== null) { + cancelAnimationFrame(sceneAnimationFrame); + sceneAnimationFrame = null; + } + sceneLayers.forEach((element) => { + element.style.transform = ""; + }); + }; window.addEventListener("pointermove", scenePointerHandler, { passive: true, }); + window.addEventListener("blur", sceneResetHandler); + document.addEventListener("pointerleave", sceneResetHandler, true); } const entityRoot = document.createElement("div"); @@ -2496,8 +3349,12 @@ }; }; + if (typeof process !== "undefined" && process.env?.NODE_ENV === "test") { + window.__CODEX_STYLER_RUNTIME_INTERNALS__ = { semanticPalette }; + } + window.__CODEX_STYLER_RUNTIME__ = { - version: 18, + version: 36, apply, updateEntity, pause: remove, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index f16a9a4..35db2c6 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.6", + "version": "0.2.0-beta.7", "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 99f96c5..3920f21 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -6,7 +6,7 @@ import { within, waitFor, } from "@testing-library/react"; -import { builtinThemes } from "@codex-styler/theme-core"; +import { builtinCompanions, builtinThemes } from "@codex-styler/theme-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; import { createCompanionProject } from "./features/companion-creator/model"; @@ -226,6 +226,55 @@ describe("Codex Styler shell", () => { ).toBeInTheDocument(); }); + it("browses another theme collection without silently changing the setup", () => { + const localTheme = structuredClone(builtinThemes[0]); + localTheme.id = "local.library-preview"; + localTheme.locales.en = { + name: "Library Preview", + description: "A local theme shown without applying it.", + }; + localStorage.setItem( + "codex-styler.local-themes.v1", + JSON.stringify([localTheme]), + ); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Themes" })); + fireEvent.click(screen.getByRole("tab", { name: /My themes/i })); + + expect(screen.getAllByText("Library Preview").length).toBeGreaterThan(0); + expect( + within(screen.getByRole("region", { name: "Current setup" })).getByText( + "Native Refined", + ), + ).toBeVisible(); + }); + + it("previews another companion collection without replacing the selection", () => { + const localCompanion = structuredClone(builtinCompanions[0]); + localCompanion.id = "local.companion-preview"; + localCompanion.name = "Orbit Fox"; + localCompanion.locales.en = { + name: "Orbit Fox", + description: "A local companion shown without applying it.", + }; + localStorage.setItem( + "codex-styler.local-companions.v1", + JSON.stringify([localCompanion]), + ); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Companions" })); + fireEvent.click(screen.getByRole("tab", { name: /My companions/i })); + + expect(screen.getAllByText("Orbit Fox").length).toBeGreaterThan(0); + expect( + within(screen.getByRole("region", { name: "Current setup" })).getByText( + "Moss", + ), + ).toBeVisible(); + }); + it("routes a missing Codex installation to location settings", async () => { vi.mocked(detectCodex).mockResolvedValue({ installed: false, @@ -447,7 +496,8 @@ describe("Codex Styler shell", () => { }); expect(applied).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Surfaces" })); + fireEvent.click(screen.getByRole("button", { name: "Interface system" })); + fireEvent.click(screen.getByText("Fine tune")); fireEvent.change( screen.getByRole("combobox", { name: "Workspace layout" }), { target: { value: "immersive" } }, @@ -470,7 +520,9 @@ describe("Codex Styler shell", () => { state: "connected", }); vi.mocked(applyConfiguration).mockRejectedValueOnce( - new Error("Could not open the Codex debugging socket: connection refused"), + new Error( + "Could not open the Codex debugging socket: connection refused", + ), ); render(); @@ -578,7 +630,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.6")).toBeInTheDocument(); + expect(screen.getByText("Codex Styler 0.2.0-beta.7")).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(); @@ -1092,11 +1144,17 @@ describe("Codex Styler shell", () => { expect(screen.getByLabelText("Token Thief")).toBeInTheDocument(); }); - it("reports theme capabilities independently from the selected companion", () => { + it("reports the theme visual personality without coupling it to a companion", () => { const { container } = render(); fireEvent.click(screen.getByRole("button", { name: "Themes" })); const facts = container.querySelector(".theme-facts"); - expect(facts).toHaveTextContent("Interactive—"); + expect( + container.querySelector(".featured-theme__preview .workspace-preview"), + ).toHaveClass("workspace-preview--compact"); + expect(facts).toHaveTextContent("CompositionNative rhythm · Balanced"); + expect(facts).toHaveTextContent("Icon treatmentContained"); + expect(facts).toHaveTextContent("Motion styleFluid"); + expect(facts).not.toHaveTextContent("Companion"); }); it("keeps companions independent and exposes draggable placement", () => { @@ -1109,6 +1167,24 @@ describe("Codex Styler shell", () => { expect( document.querySelector('.scene-entity[data-draggable="true"]'), ).toBeInTheDocument(); + fireEvent.change(screen.getByRole("slider", { name: "Companion size" }), { + target: { value: "180" }, + }); + expect(document.querySelector(".scene-entity")).toHaveStyle({ + "--entity-size": "180px", + }); + expect( + screen.getByRole("button", { name: "Reset size & placement" }), + ).toBeEnabled(); + fireEvent.click( + screen.getByRole("button", { name: "Reset size & placement" }), + ); + expect(document.querySelector(".scene-entity")).toHaveStyle({ + "--entity-size": "136px", + }); + expect( + screen.getByRole("button", { name: "Reset size & placement" }), + ).toBeDisabled(); }); it("uses independent portraits instead of decoding atlases in the list", () => { @@ -1145,28 +1221,38 @@ describe("Codex Styler shell", () => { fireEvent.click(screen.getByRole("button", { name: "Themes" })); fireEvent.click(screen.getByRole("button", { name: "New theme" })); fireEvent.click(screen.getByRole("button", { name: /Start blank/ })); - fireEvent.click(screen.getByRole("button", { name: "Surfaces" })); - expect(screen.getByText("Contrast protected")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Interface system" })); + expect(screen.getByText("Visual safety")).toBeInTheDocument(); + expect(screen.getByText("Verified")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Visual safety")); + expect(screen.getByText("Primary text")).toBeInTheDocument(); + expect(screen.getByText("Interface icons")).toBeInTheDocument(); + expect(screen.getByText("Status feedback")).toBeInTheDocument(); + expect(screen.getByText("Code changes")).toBeInTheDocument(); + expect(screen.getByText("Accent controls")).toBeInTheDocument(); + expect(screen.getByText("Component boundaries")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "More actions" })); expect( screen.getByRole("menuitem", { name: "Revert to saved" }), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "More actions" })); - fireEvent.change( - screen.getByRole("combobox", { name: "Workspace layout" }), - { target: { value: "immersive" } }, - ); - fireEvent.change(screen.getByRole("combobox", { name: "Icon treatment" }), { - target: { value: "themed" }, - }); - fireEvent.change( - screen.getByRole("combobox", { name: "Detail treatment" }), - { target: { value: "expressive" } }, - ); + fireEvent.click(screen.getByRole("button", { name: "Editorial" })); const preview = container.querySelector(".workspace-preview"); + expect(preview).toHaveAttribute("data-layout", "editorial"); + expect(preview).toHaveAttribute("data-icon-style", "themed"); + expect(preview).toHaveAttribute("data-decorations", "subtle"); + fireEvent.click(screen.getByRole("button", { name: "Immersive" })); expect(preview).toHaveAttribute("data-layout", "immersive"); expect(preview).toHaveAttribute("data-icon-style", "themed"); expect(preview).toHaveAttribute("data-decorations", "expressive"); + fireEvent.click(screen.getByRole("button", { name: "Undo theme change" })); + expect(preview).toHaveAttribute("data-layout", "editorial"); + expect(preview).toHaveAttribute("data-icon-style", "themed"); + expect(preview).toHaveAttribute("data-decorations", "subtle"); + fireEvent.click(screen.getByRole("button", { name: "Undo theme change" })); + expect(preview).toHaveAttribute("data-layout", "native"); + expect(preview).toHaveAttribute("data-icon-style", "native"); + expect(preview).toHaveAttribute("data-decorations", "none"); fireEvent.click(screen.getByRole("button", { name: "Add layer" })); expect( screen.getByRole("menuitem", { name: "Gradient layer" }), @@ -1189,25 +1275,72 @@ describe("Codex Styler shell", () => { expect( screen.getByRole("region", { name: "Live effect mapped" }), - ).toHaveTextContent("5 preview views"); + ).toHaveTextContent("8 preview views"); expect( container.querySelector('[data-theme-control="background.brightness"]'), ).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Surfaces" })); + fireEvent.click(screen.getByRole("button", { name: "Interface system" })); expect( screen.getByRole("region", { name: "Enhanced mode effect" }), - ).toHaveTextContent("Panels, controls, icons, and layout update together"); + ).toHaveTextContent( + "Panels, controls, selected and disabled states, status feedback, diffs, icons, and layout update together", + ); fireEvent.click( - screen.getByRole("button", { name: "Preview on Task & composer" }), + screen.getByRole("button", { name: "Preview on Components & states" }), ); expect(container.querySelector(".workspace-preview")).toHaveAttribute( "data-preview-scenario", - "task", + "components", ); + expect( + container.querySelector(".workspace-component-grid"), + ).toBeInTheDocument(); expect( container.querySelector('[data-theme-control="surfaces.layout"]'), ).toBeInTheDocument(); + expect( + container.querySelector('[data-theme-control="surfaces.treatment"]'), + ).toBeInTheDocument(); + expect( + container.querySelector('[data-theme-control="surfaces.material"]'), + ).toBeInTheDocument(); + expect( + container.querySelector('[data-theme-control="surfaces.focus-opacity"]'), + ).toBeInTheDocument(); + const harmony = container.querySelector( + '[data-theme-control="surfaces.color-harmony"]', + ); + expect(harmony).toHaveTextContent("Automatic"); + expect(harmony).toHaveTextContent("Tonal harmony"); + expect(harmony).toHaveTextContent("Clear hierarchy"); + const preview = container.querySelector( + ".workspace-preview", + ) as HTMLElement; + const automaticBorder = preview.style.getPropertyValue( + "--preview-border-strong", + ); + const clearHierarchy = screen.getByRole("button", { + name: /Clear hierarchy/, + }); + fireEvent.click(clearHierarchy); + expect(clearHierarchy).toHaveAttribute("aria-pressed", "true"); + expect(preview.style.getPropertyValue("--preview-border-strong")).not.toBe( + automaticBorder, + ); + + const frostedMaterial = screen.getByRole("button", { name: "Frosted" }); + fireEvent.click(frostedMaterial); + expect(frostedMaterial).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("slider", { name: /Focused panel opacity/ }), + ).toHaveValue("0.92"); + expect(screen.getByRole("slider", { name: /Glass blur/ })).toHaveValue( + "20", + ); + expect(container.querySelector(".workspace-preview")).toHaveStyle({ + "--preview-focus-blur": "20px", + }); fireEvent.click( screen.getByRole("button", { name: "Recommended pairing" }), @@ -1231,7 +1364,7 @@ describe("Codex Styler shell", () => { expect(appearance).toHaveAttribute("aria-expanded", "false"); expect(editorLayout).not.toHaveClass("editor-layout--inspector-open"); - fireEvent.click(screen.getByRole("button", { name: "Surfaces" })); + fireEvent.click(screen.getByRole("button", { name: "Interface system" })); expect(appearance).toHaveAttribute("aria-expanded", "true"); expect(editorLayout).toHaveClass("editor-layout--inspector-open"); @@ -1250,7 +1383,8 @@ describe("Codex Styler shell", () => { const savedPreview = screen.getByRole("button", { name: "Saved" }); expect(savedPreview).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Surfaces" })); + fireEvent.click(screen.getByRole("button", { name: "Interface system" })); + fireEvent.click(screen.getByText("Fine tune")); fireEvent.change( screen.getByRole("combobox", { name: "Workspace layout" }), { target: { value: "immersive" } }, @@ -1293,6 +1427,36 @@ describe("Codex Styler shell", () => { "inert", ); + const versionControl = container.querySelector(".canvas-version-control"); + expect(versionControl).not.toBeNull(); + fireEvent.click( + within(versionControl as HTMLElement).getByRole("button", { + name: "Official", + }), + ); + expect(container.querySelector(".canvas-stage")).toHaveAttribute( + "data-preview-version", + "official", + ); + expect(container.querySelector(".workspace-preview")).toHaveAttribute( + "data-preview-presentation", + "official", + ); + expect(screen.getByText("Official Codex reference")).toBeInTheDocument(); + expect(container.querySelector(".inspector-content")).toHaveAttribute( + "inert", + ); + + fireEvent.click(screen.getByRole("button", { name: "Return" })); + expect(container.querySelector(".canvas-stage")).toHaveAttribute( + "data-preview-version", + "current", + ); + expect(container.querySelector(".workspace-preview")).toHaveAttribute( + "data-preview-presentation", + "styled", + ); + fireEvent.click(savedPreview); fireEvent.click(screen.getByRole("button", { name: "Background" })); expect(container.querySelector(".canvas-stage")).toHaveAttribute( @@ -1306,7 +1470,8 @@ describe("Codex Styler shell", () => { fireEvent.click(screen.getByRole("button", { name: "Themes" })); fireEvent.click(screen.getByRole("button", { name: "New theme" })); fireEvent.click(screen.getByRole("button", { name: /Start blank/ })); - fireEvent.click(screen.getByRole("button", { name: "Surfaces" })); + fireEvent.click(screen.getByRole("button", { name: "Interface system" })); + fireEvent.click(screen.getByText("Fine tune")); const layout = screen.getByRole("combobox", { name: "Workspace layout" }); const undo = screen.getByRole("button", { name: "Undo theme change" }); @@ -1363,7 +1528,8 @@ describe("Codex Styler shell", () => { fireEvent.click(screen.getByRole("button", { name: "Themes" })); fireEvent.click(screen.getByRole("button", { name: "New theme" })); fireEvent.click(screen.getByRole("button", { name: /Start blank/ })); - fireEvent.click(screen.getByRole("button", { name: "Surfaces" })); + fireEvent.click(screen.getByRole("button", { name: "Interface system" })); + fireEvent.click(screen.getByText("Fine tune")); fireEvent.change( screen.getByRole("combobox", { name: "Workspace layout" }), { target: { value: "immersive" } }, @@ -1408,7 +1574,8 @@ describe("Codex Styler shell", () => { fireEvent.click(screen.getByRole("button", { name: "Themes" })); fireEvent.click(screen.getByRole("button", { name: "New theme" })); fireEvent.click(screen.getByRole("button", { name: /Start blank/ })); - fireEvent.click(screen.getByRole("button", { name: "Surfaces" })); + fireEvent.click(screen.getByRole("button", { name: "Interface system" })); + fireEvent.click(screen.getByText("Fine tune")); fireEvent.change( screen.getByRole("combobox", { name: "Workspace layout" }), { target: { value: "immersive" } }, @@ -1481,6 +1648,80 @@ describe("Codex Styler shell", () => { ).toBeInTheDocument(); }); + it("compares a styled theme with an uncluttered official baseline", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Themes" })); + const previewControls = screen.getByRole("button", { + name: "Task & composer", + }); + expect(previewControls).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(previewControls); + expect(previewControls).toHaveAttribute("aria-expanded", "true"); + + const comparison = screen.getByRole("group", { + name: "Compare appearance", + }); + const styled = within(comparison).getByRole("button", { name: "Styled" }); + const official = within(comparison).getByRole("button", { + name: "Official", + }); + const preview = document.querySelector( + ".featured-theme__preview .workspace-preview", + ); + + expect(styled).toHaveAttribute("aria-pressed", "true"); + expect(preview).toHaveAttribute("data-preview-presentation", "styled"); + + fireEvent.click(official); + expect(official).toHaveAttribute("aria-pressed", "true"); + expect(preview).toHaveAttribute("data-preview-presentation", "official"); + expect(preview).toHaveAttribute("data-layout", "native"); + expect( + document.querySelector(".featured-theme__preview .workspace-entity"), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Task & composer" })); + fireEvent.click(styled); + expect(preview).toHaveAttribute("data-preview-presentation", "styled"); + }); + + it("previews representative Codex surfaces before applying a theme", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Themes" })); + + const preview = document.querySelector( + ".featured-theme__preview .workspace-preview", + ); + + expect(preview).toHaveAttribute("data-preview-scenario", "task"); + fireEvent.click(screen.getByRole("button", { name: "Task & composer" })); + fireEvent.change( + screen.getByRole("combobox", { name: "Preview scenario" }), + { target: { value: "settings" } }, + ); + expect(preview).toHaveAttribute("data-preview-scenario", "settings"); + + fireEvent.click( + document.querySelector( + ".theme-preview-controls__trigger", + ) as HTMLButtonElement, + ); + fireEvent.change( + screen.getByRole("combobox", { name: "Preview scenario" }), + { target: { value: "dialog" } }, + ); + expect(preview).toHaveAttribute("data-preview-scenario", "dialog"); + + const controlsEffect = screen.getByRole("button", { + name: "Inspect this effect: Controls", + }); + fireEvent.click(controlsEffect); + expect(preview).toHaveAttribute("data-preview-scenario", "components"); + expect( + screen.getByRole("button", { name: "Components & states" }), + ).toHaveAttribute("aria-expanded", "false"); + }); + it("deletes a local theme after confirmation", async () => { const localTheme = structuredClone(builtinThemes[0]); localTheme.id = "local.theme-to-delete"; @@ -1534,7 +1775,9 @@ describe("Codex Styler shell", () => { ); expect( screen.getByRole("dialog", { name: "Delete this creator draft?" }), - ).toHaveTextContent("An installed companion with the same name will not be deleted."); + ).toHaveTextContent( + "An installed companion with the same name will not be deleted.", + ); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(deleteCompanionProject).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 966c05e..11d7ae2 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -118,6 +118,16 @@ import { persistGeneratedTheme, type ThemeAssetMap, } from "./lib/theme-files"; +import { assignThemeVariantField } from "./lib/theme-draft"; +import { + assignThemeColorHarmony, + type ThemeColorHarmonyId, +} from "./lib/theme-color-harmony"; +import { + applyCompanionPlacementMode, + resolveCompanionPlacementMode, + type SelectableCompanionPlacementMode, +} from "./lib/companion-placement-modes"; const CompanionCreator = lazy(() => import("./features/companion-creator/CompanionCreator").then((module) => ({ @@ -281,7 +291,7 @@ export function App() { } | null>(null); const [installPathBusy, setInstallPathBusy] = useState(false); const [toast, setToast] = useState(null); - const [currentVersion, setCurrentVersion] = useState("0.2.0-beta.6"); + const [currentVersion, setCurrentVersion] = useState("0.2.0-beta.7"); const [updateStatus, setUpdateStatus] = useState("idle"); const [availableUpdate, setAvailableUpdate] = useState(null); @@ -1133,17 +1143,20 @@ export function App() { ) { commitThemeDraft( (next) => { - const target = next.variants[variant][section] as unknown as Record< - string, - number | string | object - >; - target[key] = value; + assignThemeVariantField(next, variant, section, key, value); return next; }, historyGroup ?? `${variant}.${section}.${key}`, ); } + function setThemeColorHarmony(recipe: ThemeColorHarmonyId) { + commitThemeDraft((next) => { + assignThemeColorHarmony(next, variant, recipe); + return next; + }, `${variant}.appearance.color-harmony`); + } + function addSceneLayer(type: "image" | "gradient" | "vignette") { commitThemeDraft((next) => { if (next.scene.layers.length >= 8) return next; @@ -1196,6 +1209,15 @@ export function App() { }); } + function commitSelectedCompanionOverrides(next: UserSettings) { + if (!selectedCompanion) return; + dispatchSession({ + type: "selection/companion-overrides", + companionOverrides: companionOverridesFor(selectedCompanion, next), + }); + scheduleLiveCompanionSync(next); + } + function updateEntitySize(size: number) { if (!selectedCompanion) return; const current = settingsRef.current; @@ -1205,11 +1227,7 @@ export function App() { [selectedCompanion.id]: size, }, }); - dispatchSession({ - type: "selection/companion-overrides", - companionOverrides: companionOverridesFor(selectedCompanion, next), - }); - scheduleLiveCompanionSync(next); + commitSelectedCompanionOverrides(next); } function updateEntityAnchor(anchor: { x: number; y: number }) { @@ -1221,11 +1239,7 @@ export function App() { [selectedCompanion.id]: anchor, }, }); - dispatchSession({ - type: "selection/companion-overrides", - companionOverrides: companionOverridesFor(selectedCompanion, next), - }); - scheduleLiveCompanionSync(next); + commitSelectedCompanionOverrides(next); } function updateEntityAttachment(attachment: EntityAttachment | null) { @@ -1237,11 +1251,60 @@ export function App() { [selectedCompanion.id]: attachment, }, }); - dispatchSession({ - type: "selection/companion-overrides", - companionOverrides: companionOverridesFor(selectedCompanion, next), + commitSelectedCompanionOverrides(next); + } + + function setEntityPlacementMode(mode: SelectableCompanionPlacementMode) { + if (!selectedCompanion) return; + const current = settingsRef.current; + const hasAttachmentOverride = Object.prototype.hasOwnProperty.call( + current.companionAttachments, + selectedCompanion.id, + ); + const currentPlacement = { + anchor: + current.companionAnchors[selectedCompanion.id] ?? + selectedCompanion.entity.anchor, + attachment: hasAttachmentOverride + ? current.companionAttachments[selectedCompanion.id] + : (selectedCompanion.entity.attachment ?? null), + }; + if (resolveCompanionPlacementMode(currentPlacement.attachment) === mode) { + return; + } + const placement = applyCompanionPlacementMode( + mode, + currentPlacement, + selectedCompanion.entity.attachment, + ); + const next = updateSettings({ + companionAnchors: { + ...current.companionAnchors, + [selectedCompanion.id]: placement.anchor, + }, + companionAttachments: { + ...current.companionAttachments, + [selectedCompanion.id]: placement.attachment, + }, }); - scheduleLiveCompanionSync(next); + commitSelectedCompanionOverrides(next); + } + + function resetEntityPlacement() { + if (!selectedCompanion) return; + const current = settingsRef.current; + const companionAnchors = { ...current.companionAnchors }; + const companionSizes = { ...current.companionSizes }; + const companionAttachments = { ...current.companionAttachments }; + delete companionAnchors[selectedCompanion.id]; + delete companionSizes[selectedCompanion.id]; + delete companionAttachments[selectedCompanion.id]; + const next = updateSettings({ + companionAnchors, + companionSizes, + companionAttachments, + }); + commitSelectedCompanionOverrides(next); } function selectCompanion(companion: CompanionDefinition | null) { @@ -1687,8 +1750,6 @@ export function App() { function changeThemeCollection(collection: ThemeCollection) { setThemeCollection(collection); - const first = collection === "builtIn" ? builtinThemes[0] : localThemes[0]; - if (first) chooseTheme(first); } async function handleDeleteTheme() { @@ -1831,7 +1892,16 @@ export function App() { -
+
{view === "home" && ( composeTheme(theme)} localThemes={localThemes} collection={themeCollection} variant={variant} @@ -1881,7 +1951,9 @@ export function App() { + composeTheme(selectedTheme, companion) + } localCompanions={localCompanions} projects={companionProjects} collection={companionCollection} @@ -1902,6 +1974,42 @@ export function App() { onImport={() => companionImportRef.current?.click()} onExport={(companion) => void handleExportCompanion(companion)} onDelete={setPendingCompanionDelete} + selectedSize={ + selectedCompanion + ? (settings.companionSizes[selectedCompanion.id] ?? + selectedCompanion.entity.size) + : null + } + placementCustomized={Boolean( + selectedCompanion && + (Object.prototype.hasOwnProperty.call( + settings.companionAnchors, + selectedCompanion.id, + ) || + Object.prototype.hasOwnProperty.call( + settings.companionSizes, + selectedCompanion.id, + ) || + Object.prototype.hasOwnProperty.call( + settings.companionAttachments, + selectedCompanion.id, + )), + )} + placementMode={ + selectedCompanion + ? resolveCompanionPlacementMode( + Object.prototype.hasOwnProperty.call( + settings.companionAttachments, + selectedCompanion.id, + ) + ? settings.companionAttachments[selectedCompanion.id] + : selectedCompanion.entity.attachment, + ) + : "free" + } + onSizeChange={updateEntitySize} + onPlacementModeChange={setEntityPlacementMode} + onResetPlacement={resetEntityPlacement} onAnchorChange={updateEntityAnchor} onAttachmentChange={updateEntityAttachment} resolveAsset={resolveAsset} @@ -1947,6 +2055,7 @@ export function App() { reduceMotion={settings.reduceMotion} t={t} onUpdateVariant={updateVariant} + onSetColorHarmony={setThemeColorHarmony} onAddSceneLayer={addSceneLayer} onUpdateSceneLayer={updateSceneLayer} onRemoveSceneLayer={removeSceneLayer} @@ -1995,6 +2104,7 @@ export function App() { {view === "settings" && ( -

- {t("deleteDraftTitle")} -

+

{t("deleteDraftTitle")}

{t("deleteDraftBody")}

{pendingCompanionProjectDelete.name}
@@ -2332,6 +2440,7 @@ export function App() { role="dialog" aria-modal="true" aria-labelledby="diagnostics-title" + data-scroll-surface="panel" >
@@ -2361,7 +2470,9 @@ export function App() {
{t("previewDiagnosticText")} -
{diagnosticsSummary(diagnosticsReport)}
+
+                {diagnosticsSummary(diagnosticsReport)}
+              
) : ( -
+
{themes.map((theme) => { const copy = theme.locales[locale] ?? theme.locales.en; const preview = theme.metadata.preview diff --git a/apps/desktop/src/components/Onboarding.tsx b/apps/desktop/src/components/Onboarding.tsx index cd431a6..53d8a98 100644 --- a/apps/desktop/src/components/Onboarding.tsx +++ b/apps/desktop/src/components/Onboarding.tsx @@ -111,7 +111,7 @@ export function Onboarding({ ))} -
+
{step === 0 && (
diff --git a/apps/desktop/src/components/PreviewWorkspace.test.tsx b/apps/desktop/src/components/PreviewWorkspace.test.tsx new file mode 100644 index 0000000..d739fad --- /dev/null +++ b/apps/desktop/src/components/PreviewWorkspace.test.tsx @@ -0,0 +1,396 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { + composeThemeWithCompanion, + gildedGrandeur, + merryBigTop, + mossCompanion, + nativeRefined, +} from "@codex-styler/theme-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PreviewWorkspace } from "./PreviewWorkspace"; +import { resolveThemeContrast } from "../lib/theme-contrast"; +import { resolveThemePreviewPalette } from "../lib/theme-preview-palette"; + +describe("PreviewWorkspace task views", () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("lets the theme editor move between conversation, changes, and terminal", () => { + const onScenarioChange = vi.fn(); + render( + path} + scenario="task" + onScenarioChange={onScenarioChange} + />, + ); + + expect(screen.getByRole("tab", { name: "Conversation" })).toHaveAttribute( + "aria-selected", + "true", + ); + fireEvent.click(screen.getByRole("tab", { name: "Changes" })); + fireEvent.click(screen.getByRole("tab", { name: "Terminal" })); + + expect(onScenarioChange).toHaveBeenNthCalledWith(1, "changes"); + expect(onScenarioChange).toHaveBeenNthCalledWith(2, "terminal"); + }); + + it("renders dedicated changes and terminal compositions", () => { + const { rerender } = render( + path} + scenario="changes" + />, + ); + + expect(screen.getByText("Working tree")).toBeInTheDocument(); + + rerender( + path} + scenario="terminal" + />, + ); + + expect(screen.getByText("Theme verification")).toBeInTheDocument(); + }); + + it("uses the versioned Codex shell without editor-only navigation in library previews", () => { + const { container } = render( + path} + scenario="task" + />, + ); + + expect( + container.querySelector('[data-codex-preview-shell="2026-07"]'), + ).toBeTruthy(); + expect(container.querySelector(".workspace-sidebar__primary")).toBeTruthy(); + expect( + container.querySelector(".workspace-header__navigation"), + ).toBeTruthy(); + expect(container.querySelector(".workspace-content-shell")).toBeTruthy(); + expect(container.querySelector(".workspace-composer__field")).toBeTruthy(); + expect( + container.querySelector( + '.workspace-context-tabs[aria-label="Task views"]', + ), + ).not.toBeInTheDocument(); + }); + + it("exposes the derived geometry, material, typography, and motion character", () => { + const { container, rerender } = render( + path} + />, + ); + const preview = container.querySelector(".workspace-preview"); + + expect(preview).toHaveAttribute("data-geometry", "precise"); + expect(preview).toHaveAttribute("data-material", "frosted"); + expect(preview).toHaveAttribute("data-typography", "editorial"); + expect(preview).toHaveAttribute("data-motion-character", "fluid"); + + rerender( + path} + />, + ); + + expect(preview).toHaveAttribute("data-geometry", "soft"); + expect(preview).toHaveAttribute("data-material", "frosted"); + expect(preview).toHaveAttribute("data-typography", "cinematic"); + expect(preview).toHaveAttribute("data-motion-character", "expressive"); + }); + + it("collapses theme motion to still when reduced motion is enabled", () => { + const { container } = render( + path} + />, + ); + + const preview = container.querySelector(".workspace-preview"); + expect(preview).toHaveAttribute("data-motion-character", "still"); + expect(preview).toHaveStyle("--preview-motion-duration: 0ms"); + }); + + it("keeps the official comparison outside Styler material and typography", () => { + const { container } = render( + path} + presentation="official" + />, + ); + + expect(container.querySelector(".workspace-preview")).not.toHaveAttribute( + "data-typography", + ); + expect(container.querySelector(".workspace-preview")).not.toHaveAttribute( + "data-material", + ); + }); + + it("keeps expressive material depth available in every focus scenario", () => { + const { container, rerender } = render( + path} + scenario="settings" + />, + ); + const preview = container.querySelector(".workspace-preview"); + + expect(preview).toHaveAttribute("data-decorations", "expressive"); + expect(container.querySelector(".workspace-settings-state")).toBeTruthy(); + + rerender( + path} + scenario="right-panel" + />, + ); + expect(container.querySelector(".workspace-right-panel")).toBeTruthy(); + + rerender( + path} + scenario="dialog" + />, + ); + expect(container.querySelector(".workspace-dialog-layer")).toBeTruthy(); + }); + + it("previews the interaction chrome derived from the theme accent", () => { + const { container } = render( + path} + scenario="components" + />, + ); + + expect(container.querySelector(".workspace-component-caret")).toBeTruthy(); + expect( + container.querySelector(".workspace-component-selection"), + ).toHaveTextContent("Selected text"); + expect( + container.querySelector(".workspace-component-link"), + ).toHaveTextContent("View guide"); + expect( + container.querySelector(".workspace-component-scroll-sample"), + ).toBeTruthy(); + }); + + it("consumes the complete semantic text and border contract", () => { + const theme = structuredClone(nativeRefined); + const appearance = theme.variants.dark.appearance; + appearance.palette = { + ...appearance.palette, + textTertiary: "#A7AFBD", + borderSubtle: "#303640", + borderStrong: "#667084", + }; + const contrastSystem = resolveThemeContrast(theme, "dark"); + const palette = resolveThemePreviewPalette( + appearance, + theme.variants.dark.background.color, + contrastSystem, + ); + const { container } = render( + path} + scenario="settings" + />, + ); + const preview = container.querySelector(".workspace-preview"); + + expect(preview?.style.getPropertyValue("--preview-tertiary")).toBe( + palette.textTertiary, + ); + expect(preview?.style.getPropertyValue("--preview-border-subtle")).toBe( + palette.borderSubtle, + ); + expect(preview?.style.getPropertyValue("--preview-border-strong")).toBe( + palette.borderStrong, + ); + expect(preview?.style.getPropertyValue("--preview-icon")).toBe( + palette.icon, + ); + expect(preview?.style.getPropertyValue("--preview-icon-emphasis")).toBe( + palette.iconEmphasis, + ); + }); + + it("runs a controlled motion preview and preserves each scene layer scale", () => { + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 42), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + const { container } = render( + path} + motionPreviewRevision={1} + />, + ); + + const preview = container.querySelector(".workspace-preview"); + const backdrop = container.querySelector( + ".workspace-preview__backdrop", + ); + expect(preview).toHaveAttribute("data-motion-preview", "playing"); + + fireEvent.mouseMove(preview as Element, { clientX: 100, clientY: 80 }); + + expect(cancelAnimationFrame).toHaveBeenCalledWith(42); + expect(backdrop?.style.transform).toContain( + "scale(var(--preview-parallax-scale, 1.015))", + ); + }); + + it("does not run guided motion when Reduce Motion is enabled", () => { + const requestAnimationFrame = vi.fn(() => 42); + vi.stubGlobal("requestAnimationFrame", requestAnimationFrame); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + const { container } = render( + path} + motionPreviewRevision={1} + />, + ); + + expect(requestAnimationFrame).not.toHaveBeenCalled(); + expect(container.querySelector(".workspace-preview")).not.toHaveAttribute( + "data-motion-preview", + ); + }); + + it("keeps an attached companion grounded after React rerenders the preview", async () => { + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation( + function (this: HTMLElement) { + return this.classList.contains("workspace-preview") ? 800 : 0; + }, + ); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation( + function (this: HTMLElement) { + return this.classList.contains("workspace-preview") ? 400 : 0; + }, + ); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.classList.contains("workspace-preview")) { + return DOMRect.fromRect({ x: 0, y: 0, width: 800, height: 400 }); + } + if (this.classList.contains("workspace-composer__field")) { + return DOMRect.fromRect({ x: 200, y: 320, width: 500, height: 45 }); + } + return DOMRect.fromRect(); + }, + ); + const theme = composeThemeWithCompanion(nativeRefined, mossCompanion); + const { container, rerender } = render( + path} + compact + />, + ); + + const entity = container.querySelector(".scene-entity"); + await waitFor(() => { + expect(entity?.style.getPropertyValue("--entity-x")).toBe("610px"); + expect( + Number.parseFloat(entity?.style.getPropertyValue("--entity-y") ?? "0"), + ).toBeCloseTo(321.58, 1); + }); + + rerender( + path} + compact + />, + ); + + expect(entity?.style.getPropertyValue("--entity-x")).toBe("610px"); + expect( + Number.parseFloat(entity?.style.getPropertyValue("--entity-y") ?? "0"), + ).toBeCloseTo(321.58, 1); + }); +}); diff --git a/apps/desktop/src/components/PreviewWorkspace.tsx b/apps/desktop/src/components/PreviewWorkspace.tsx index 195b220..8cdda5e 100644 --- a/apps/desktop/src/components/PreviewWorkspace.tsx +++ b/apps/desktop/src/components/PreviewWorkspace.tsx @@ -1,5 +1,7 @@ import { + useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -7,20 +9,7 @@ import { type MouseEvent, type PointerEvent, } from "react"; -import { - Check, - ChevronDown, - Command, - GitBranch, - MoreHorizontal, - PanelRight, - Plus, - Search, - Send, - Settings, - SlidersHorizontal, - Sparkles, -} from "lucide-react"; +import { MousePointer2 } from "lucide-react"; import { pointerDirectionFrame, type EntityAttachment, @@ -28,8 +17,20 @@ import { } from "@codex-styler/theme-core"; import type { Locale } from "../lib/i18n"; import type { PreviewScenario } from "../lib/storage"; +import { + resolveAttachedEntityPosition, + resolveFreeEntityAnchor, + resolveFreeEntityPosition, +} from "../lib/entity-placement"; +import { previewEntityDimensions } from "../lib/preview-entity-layout"; import { drawSpriteFrame } from "../lib/sprite-normalization"; import { resolveThemeContrast } from "../lib/theme-contrast"; +import { + resolveThemeMotionProfile, + resolveThemeVisualPersonality, +} from "../lib/theme-effects"; +import { resolveThemePreviewPalette } from "../lib/theme-preview-palette"; +import { CodexPreviewShell } from "./preview/CodexPreviewShell"; interface PreviewWorkspaceProps { theme: ThemeDefinition; @@ -37,10 +38,13 @@ interface PreviewWorkspaceProps { locale: Locale; reduceMotion: boolean; resolveAsset: (theme: ThemeDefinition, path: string) => string; + presentation?: "styled" | "official"; compact?: boolean; scenario?: PreviewScenario; + onScenarioChange?: (scenario: PreviewScenario) => void; onEntityAnchorChange?: (anchor: { x: number; y: number }) => void; onEntityAttachmentChange?: (attachment: EntityAttachment | null) => void; + motionPreviewRevision?: number; } export function PreviewWorkspace({ @@ -49,30 +53,61 @@ export function PreviewWorkspace({ locale, reduceMotion, resolveAsset, + presentation = "styled", compact = false, scenario = "task", + onScenarioChange, onEntityAnchorChange, onEntityAttachmentChange, + motionPreviewRevision = 0, }: PreviewWorkspaceProps) { + const officialPreview = presentation === "official"; const previewRef = useRef(null); const mainRef = useRef(null); const composerRef = useRef(null); const entityRef = useRef(null); const spriteCanvasRef = useRef(null); + const motionCursorRef = useRef(null); + const motionPreviewFrameRef = useRef(null); + const motionPreviewHandledRevisionRef = useRef(0); const spriteImageRefs = useRef([]); const [direction, setDirection] = useState(0); const [dragging, setDragging] = useState(false); const [spriteReady, setSpriteReady] = useState(0); - const interactive = Boolean(onEntityAnchorChange || onEntityAttachmentChange); + const [attachedPosition, setAttachedPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [previewViewport, setPreviewViewport] = useState({ + width: 0, + height: 0, + }); + const interactive = Boolean( + onScenarioChange || onEntityAnchorChange || onEntityAttachmentChange, + ); const visual = theme.variants[variant]; - const entity = theme.scene.entities[0]; - const backgroundImage = visual.background.image - ? resolveAsset(theme, visual.background.image) - : undefined; - const backgroundLayer = theme.scene.layers.find( - (layer) => - layer.type === "image" && layer.asset === visual.background.image, + const visualPersonality = useMemo( + () => resolveThemeVisualPersonality(theme, variant), + [theme, variant], + ); + const motionProfile = useMemo( + () => + resolveThemeMotionProfile( + officialPreview || reduceMotion ? 0 : visual.motion.intensity, + ), + [officialPreview, reduceMotion, visual.motion.intensity], ); + const entity = officialPreview ? undefined : theme.scene.entities[0]; + const backgroundImage = + !officialPreview && visual.background.image + ? resolveAsset(theme, visual.background.image) + : undefined; + const backgroundLayer = officialPreview + ? undefined + : theme.scene.layers.find( + (layer) => + layer.type === "image" && layer.asset === visual.background.image, + ); const entityImage = entity ? resolveAsset(theme, entity.renderer.asset) : undefined; @@ -87,6 +122,120 @@ export function PreviewWorkspace({ () => resolveThemeContrast(theme, variant), [theme, variant], ); + const semanticPalette = useMemo( + () => + resolveThemePreviewPalette( + visual.appearance, + visual.background.color, + contrastSystem, + ), + [contrastSystem, visual.appearance, visual.background.color], + ); + const sourceEntityHeight = entity + ? entity.renderer.type === "sprite-atlas" + ? entity.size * (entity.renderer.frameHeight / entity.renderer.frameWidth) + : entity.size + : 0; + const previewEntity = entity + ? previewEntityDimensions( + entity.size, + sourceEntityHeight, + previewViewport.height, + ) + : null; + + useLayoutEffect(() => { + const preview = previewRef.current; + if (!preview) return; + const update = () => { + const nextViewport = { + width: preview.clientWidth, + height: preview.clientHeight, + }; + if (nextViewport.width > 0 && nextViewport.height > 0) { + setPreviewViewport((current) => + current.width === nextViewport.width && + current.height === nextViewport.height + ? current + : nextViewport, + ); + } + }; + update(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(update); + observer.observe(preview); + return () => observer.disconnect(); + }, []); + + const clearSceneParallax = useCallback(() => { + previewRef.current + ?.querySelectorAll("[data-scene-parallax]") + .forEach((layer) => layer.style.removeProperty("transform")); + }, []); + + const applySceneParallax = useCallback( + (x: number, y: number) => { + previewRef.current + ?.querySelectorAll("[data-scene-parallax]") + .forEach((layer) => { + const authoredDepth = Number(layer.dataset.sceneParallax || 0); + const cappedDepth = + Math.sign(authoredDepth) * + Math.min( + Math.abs(authoredDepth), + Math.max(0, visual.motion.parallax ?? 0), + ); + const depth = cappedDepth * visual.motion.intensity; + if (depth === 0) { + layer.style.removeProperty("transform"); + return; + } + layer.style.transform = `translate(${-x * depth}px, ${-y * depth}px) scale(var(--preview-parallax-scale, 1.015))`; + }); + }, + [visual.motion.intensity, visual.motion.parallax], + ); + + const applyEntityDirection = useCallback( + (clientX: number, clientY: number) => { + if ( + !entity || + dragging || + reduceMotion || + entity.renderer.type !== "sprite-atlas" + ) { + return; + } + const bounds = entityRef.current?.getBoundingClientRect(); + if (!bounds) return; + setDirection( + pointerDirectionFrame( + clientX, + clientY, + bounds.left + bounds.width / 2, + bounds.top + bounds.height / 2, + entity.renderer.directions, + entity.renderer.frameAngles, + ), + ); + }, + [dragging, entity, reduceMotion], + ); + + const stopMotionPreview = useCallback( + (clearParallax = true) => { + if (motionPreviewFrameRef.current !== null) { + window.cancelAnimationFrame(motionPreviewFrameRef.current); + motionPreviewFrameRef.current = null; + } + previewRef.current?.removeAttribute("data-motion-preview"); + motionCursorRef.current?.style.removeProperty("left"); + motionCursorRef.current?.style.removeProperty("top"); + if (clearParallax) clearSceneParallax(); + }, + [clearSceneParallax], + ); useEffect(() => { if (!entityPageImages.length || entity?.renderer.type !== "sprite-atlas") { @@ -122,17 +271,24 @@ export function PreviewWorkspace({ const pageIndex = Math.floor(direction / framesPerPage); const image = spriteImageRefs.current[pageIndex]; if (!image?.complete || !image.naturalWidth) return; - const height = - entity.size * (entity.renderer.frameHeight / entity.renderer.frameWidth); + const width = previewEntity?.width ?? entity.size; + const height = previewEntity?.height ?? sourceEntityHeight; drawSpriteFrame( spriteCanvasRef.current, image, entity.renderer, direction % framesPerPage, - entity.size, + width, height, ); - }, [direction, entity, spriteReady]); + }, [ + direction, + entity, + previewEntity?.height, + previewEntity?.width, + sourceEntityHeight, + spriteReady, + ]); useEffect(() => { if ( @@ -142,75 +298,323 @@ export function PreviewWorkspace({ ) { return; } - previewRef.current - ?.querySelectorAll("[data-scene-parallax]") - .forEach((layer) => layer.style.removeProperty("transform")); - }, [reduceMotion, visual.motion.intensity, visual.motion.parallax]); + stopMotionPreview(); + }, [ + reduceMotion, + stopMotionPreview, + visual.motion.intensity, + visual.motion.parallax, + ]); useEffect(() => { - const element = entityRef.current; + if ( + motionPreviewRevision <= 0 || + motionPreviewRevision <= motionPreviewHandledRevisionRef.current || + reduceMotion || + officialPreview || + visual.motion.intensity <= 0 || + (visual.motion.parallax ?? 0) <= 0 + ) { + return; + } + + motionPreviewHandledRevisionRef.current = motionPreviewRevision; + stopMotionPreview(); + const preview = previewRef.current; + if (!preview) return; + preview.dataset.motionPreview = "playing"; + const duration = 1_450; + const keyframes = [ + { at: 0, x: 0, y: 0 }, + { at: 0.3, x: 0.46, y: -0.32 }, + { at: 0.7, x: -0.42, y: 0.28 }, + { at: 1, x: 0, y: 0 }, + ]; + let startedAt: number | null = null; + + const animate = (timestamp: number) => { + if (startedAt === null) startedAt = timestamp; + const progress = Math.min(1, (timestamp - startedAt) / duration); + let nextIndex = keyframes.findIndex((frame) => progress <= frame.at); + if (nextIndex < 0) nextIndex = keyframes.length - 1; + const to = keyframes[nextIndex]; + const from = keyframes[Math.max(0, nextIndex - 1)]; + const interval = Math.max(0.001, to.at - from.at); + const localProgress = Math.min( + 1, + Math.max(0, (progress - from.at) / interval), + ); + const eased = localProgress * localProgress * (3 - 2 * localProgress); + const x = from.x + (to.x - from.x) * eased; + const y = from.y + (to.y - from.y) * eased; + applySceneParallax(x, y); + if (motionCursorRef.current) { + motionCursorRef.current.style.left = `${(x + 0.5) * 100}%`; + motionCursorRef.current.style.top = `${(y + 0.5) * 100}%`; + } + const bounds = preview.getBoundingClientRect(); + applyEntityDirection( + bounds.left + (x + 0.5) * bounds.width, + bounds.top + (y + 0.5) * bounds.height, + ); + + if (progress < 1) { + motionPreviewFrameRef.current = window.requestAnimationFrame(animate); + } else { + stopMotionPreview(); + } + }; + + motionPreviewFrameRef.current = window.requestAnimationFrame(animate); + return () => stopMotionPreview(); + }, [ + applyEntityDirection, + applySceneParallax, + motionPreviewRevision, + officialPreview, + reduceMotion, + stopMotionPreview, + visual.motion.intensity, + visual.motion.parallax, + ]); + + useLayoutEffect(() => { const preview = previewRef.current; const attachment = entity?.attachment; - if (!element || !preview || !attachment) return; + if (!preview || !attachment) { + setAttachedPosition(null); + return; + } const target = attachment.target === "composer" ? composerRef.current : attachment.target === "main-surface" ? mainRef.current : null; - if (!target) return; + if (!target) { + setAttachedPosition(null); + return; + } const update = () => { const previewBounds = preview.getBoundingClientRect(); const targetBounds = target.getBoundingClientRect(); - const edgeY = - attachment.edge === "bottom" ? targetBounds.bottom : targetBounds.top; - element.style.left = - targetBounds.left - - previewBounds.left + - targetBounds.width * attachment.align + - attachment.offset.x + - "px"; - element.style.top = - edgeY - previewBounds.top + attachment.offset.y + "px"; + const scaleX = preview.clientWidth + ? previewBounds.width / preview.clientWidth + : 1; + const scaleY = preview.clientHeight + ? previewBounds.height / preview.clientHeight + : 1; + const position = resolveAttachedEntityPosition( + { + x: (targetBounds.left - previewBounds.left) / scaleX, + y: (targetBounds.top - previewBounds.top) / scaleY, + width: targetBounds.width / scaleX, + height: targetBounds.height / scaleY, + }, + { + ...attachment, + offset: { + x: attachment.offset.x * (previewEntity?.scale ?? 1), + y: attachment.offset.y * (previewEntity?.scale ?? 1), + }, + }, + { + width: previewEntity?.width ?? entity.size, + height: previewEntity?.height ?? sourceEntityHeight, + }, + { + width: preview.clientWidth, + height: preview.clientHeight, + }, + ); + setAttachedPosition((current) => + current && current.x === position.x && current.y === position.y + ? current + : position, + ); }; update(); - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(update); - observer.observe(preview); - observer.observe(target); - return () => observer.disconnect(); - }, [entity]); + let settleFrame = window.requestAnimationFrame(() => { + update(); + settleFrame = window.requestAnimationFrame(update); + }); + window.addEventListener("resize", update); + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); + observer?.observe(preview); + observer?.observe(target); + if (mainRef.current) observer?.observe(mainRef.current); + return () => { + window.cancelAnimationFrame(settleFrame); + window.removeEventListener("resize", update); + observer?.disconnect(); + }; + }, [ + entity, + previewEntity?.height, + previewEntity?.scale, + previewEntity?.width, + scenario, + sourceEntityHeight, + ]); - const style = useMemo( - () => - ({ - "--preview-bg": visual.background.color, - "--preview-image": backgroundImage - ? "url(" + backgroundImage + ")" - : "none", - "--preview-image-x": visual.background.position.x + "%", - "--preview-image-y": visual.background.position.y + "%", - "--preview-brightness": visual.background.brightness, - "--preview-blur": visual.background.blur + "px", - "--preview-overlay": visual.background.overlay, - "--preview-overlay-opacity": visual.background.overlayOpacity, - "--preview-surface": visual.appearance.surface, - "--preview-surface-opacity": contrastSystem.quietSurfaceOpacity, - "--preview-surface-strong-opacity": contrastSystem.strongSurfaceOpacity, - "--preview-text": contrastSystem.textPrimary, - "--preview-muted": contrastSystem.textSecondary, - "--preview-border": visual.appearance.border, - "--preview-accent": visual.appearance.accent, - "--preview-radius": visual.appearance.radius + "px", - "--preview-focus-blur": visual.appearance.focusBlur + "px", - }) as CSSProperties, - [backgroundImage, contrastSystem, visual], - ); + const style = useMemo(() => { + const officialPalette = + variant === "dark" + ? { + background: "#17181b", + surface: "#202226", + text: "#f4f4f3", + muted: "#a0a3aa", + border: "#35383e", + accent: "#747980", + } + : { + background: "#f4f5f6", + surface: "#ffffff", + text: "#202226", + muted: "#6e7279", + border: "#d9dce1", + accent: "#666c74", + }; + return { + "--preview-bg": officialPreview + ? officialPalette.background + : visual.background.color, + "--preview-image": backgroundImage + ? "url(" + backgroundImage + ")" + : "none", + "--preview-image-x": visual.background.position.x + "%", + "--preview-image-y": visual.background.position.y + "%", + "--preview-brightness": visual.background.brightness, + "--preview-blur": visual.background.blur + "px", + "--preview-overlay": visual.background.overlay, + "--preview-overlay-opacity": officialPreview + ? 0 + : visual.background.overlayOpacity, + "--preview-surface": officialPreview + ? officialPalette.surface + : semanticPalette.surface, + "--preview-surface-raised": officialPreview + ? officialPalette.surface + : semanticPalette.surfaceRaised, + "--preview-surface-overlay": officialPreview + ? officialPalette.surface + : semanticPalette.surfaceOverlay, + "--preview-surface-sunken": officialPreview + ? officialPalette.background + : semanticPalette.surfaceSunken, + "--preview-control": officialPreview + ? officialPalette.surface + : semanticPalette.control, + "--preview-control-hover": officialPreview + ? officialPalette.border + : semanticPalette.controlHover, + "--preview-control-active": officialPreview + ? officialPalette.accent + : semanticPalette.controlActive, + "--preview-surface-opacity": officialPreview + ? 1 + : contrastSystem.quietSurfaceOpacity, + "--preview-surface-strong-opacity": officialPreview + ? 1 + : contrastSystem.strongSurfaceOpacity, + "--preview-text": officialPreview + ? officialPalette.text + : semanticPalette.textPrimary, + "--preview-muted": officialPreview + ? officialPalette.muted + : semanticPalette.textSecondary, + "--preview-tertiary": officialPreview + ? officialPalette.muted + : semanticPalette.textTertiary, + "--preview-icon": officialPreview + ? officialPalette.muted + : semanticPalette.icon, + "--preview-icon-emphasis": officialPreview + ? officialPalette.text + : semanticPalette.iconEmphasis, + "--preview-border": officialPreview + ? officialPalette.border + : semanticPalette.border, + "--preview-border-subtle": officialPreview + ? `color-mix(in srgb, ${officialPalette.border} 60%, transparent)` + : semanticPalette.borderSubtle, + "--preview-border-strong": officialPreview + ? officialPalette.border + : semanticPalette.borderStrong, + "--preview-accent": officialPreview + ? officialPalette.accent + : semanticPalette.accent, + "--preview-on-accent": officialPreview + ? variant === "dark" + ? "#ffffff" + : "#17181b" + : semanticPalette.onAccent, + "--preview-focus": officialPreview + ? officialPalette.accent + : semanticPalette.focus, + "--preview-success": officialPreview + ? variant === "dark" + ? "#77c894" + : "#287a48" + : semanticPalette.success, + "--preview-warning": officialPreview + ? variant === "dark" + ? "#d2aa60" + : "#8c641d" + : semanticPalette.warning, + "--preview-danger": officialPreview + ? variant === "dark" + ? "#dc7c79" + : "#a94242" + : semanticPalette.danger, + "--preview-info": officialPreview + ? officialPalette.accent + : semanticPalette.info, + "--preview-added": officialPreview + ? variant === "dark" + ? "#77c894" + : "#287a48" + : semanticPalette.added, + "--preview-modified": officialPreview + ? variant === "dark" + ? "#d2aa60" + : "#8c641d" + : semanticPalette.modified, + "--preview-deleted": officialPreview + ? variant === "dark" + ? "#dc7c79" + : "#a94242" + : semanticPalette.deleted, + "--preview-radius": officialPreview + ? "10px" + : visual.appearance.radius + "px", + "--preview-focus-blur": officialPreview + ? "0px" + : visual.appearance.focusBlur + "px", + "--preview-motion-duration": motionProfile.durationMs + "ms", + "--preview-motion-lift": motionProfile.hoverLiftPx + "px", + "--preview-motion-press-scale": motionProfile.pressScale, + "--preview-motion-overlay-opacity": motionProfile.overlayOpacity, + "--preview-motion-overlay-offset": motionProfile.overlayOffsetPx + "px", + "--preview-motion-overlay-scale": motionProfile.overlayScale, + } as CSSProperties; + }, [ + backgroundImage, + contrastSystem, + officialPreview, + motionProfile, + semanticPalette, + variant, + visual, + ]); function handlePointer(event: MouseEvent) { const previewBounds = previewRef.current?.getBoundingClientRect(); if (previewBounds && !reduceMotion) { + stopMotionPreview(false); const x = (event.clientX - previewBounds.left) / Math.max(1, previewBounds.width) - @@ -219,45 +623,9 @@ export function PreviewWorkspace({ (event.clientY - previewBounds.top) / Math.max(1, previewBounds.height) - 0.5; - previewRef.current - ?.querySelectorAll("[data-scene-parallax]") - .forEach((layer) => { - const authoredDepth = Number(layer.dataset.sceneParallax || 0); - const cappedDepth = - Math.sign(authoredDepth) * - Math.min( - Math.abs(authoredDepth), - Math.max(0, visual.motion.parallax ?? 0), - ); - const depth = cappedDepth * visual.motion.intensity; - if (depth === 0) { - layer.style.removeProperty("transform"); - return; - } - layer.style.transform = `translate(${-x * depth}px, ${-y * depth}px) scale(1.015)`; - }); + applySceneParallax(x, y); } - if ( - !entity || - dragging || - reduceMotion || - entity.renderer.type !== "sprite-atlas" - ) - return; - const bounds = entityRef.current?.getBoundingClientRect(); - if (!bounds) return; - const anchorX = bounds.left + bounds.width / 2; - const anchorY = bounds.top + bounds.height / 2; - setDirection( - pointerDirectionFrame( - event.clientX, - event.clientY, - anchorX, - anchorY, - entity.renderer.directions, - entity.renderer.frameAngles, - ), - ); + applyEntityDirection(event.clientX, event.clientY); } function handleEntityPointerDown(event: PointerEvent) { @@ -269,9 +637,10 @@ export function PreviewWorkspace({ } function handleEntityPointerMove(event: PointerEvent) { - if (!dragging) return; - const bounds = previewRef.current?.getBoundingClientRect(); - if (!bounds) return; + if (!dragging || !entity) return; + const preview = previewRef.current; + const bounds = preview?.getBoundingClientRect(); + if (!preview || !bounds) return; const composerBounds = composerRef.current?.getBoundingClientRect(); if ( composerBounds && @@ -294,16 +663,24 @@ export function PreviewWorkspace({ } onEntityAttachmentChange?.(null); if (!onEntityAnchorChange) return; - onEntityAnchorChange({ - x: Math.max( - 4, - Math.min(96, ((event.clientX - bounds.left) / bounds.width) * 100), + const scaleX = bounds.width / Math.max(1, preview.clientWidth); + const scaleY = bounds.height / Math.max(1, preview.clientHeight); + onEntityAnchorChange( + resolveFreeEntityAnchor( + { + x: (event.clientX - bounds.left) / scaleX, + y: (event.clientY - bounds.top) / scaleY, + }, + { + width: previewEntity?.width ?? entity.size, + height: previewEntity?.height ?? sourceEntityHeight, + }, + { + width: preview.clientWidth, + height: preview.clientHeight, + }, ), - y: Math.max( - 6, - Math.min(94, ((event.clientY - bounds.top) / bounds.height) * 100), - ), - }); + ); } function handleEntityPointerUp(event: PointerEvent) { @@ -315,21 +692,77 @@ export function PreviewWorkspace({ } const isChinese = locale === "zh-CN"; + const safeFreePosition = + entity && previewEntity && previewViewport.width > 0 + ? resolveFreeEntityPosition( + { + x: (entity.anchor.x / 100) * previewViewport.width, + y: (entity.anchor.y / 100) * previewViewport.height, + }, + previewEntity, + previewViewport, + ) + : null; const entityStyle = entity ? ({ "--entity-image": entityImage ? "url(" + entityImage + ")" : "none", - "--entity-x": entity.anchor.x + "%", - "--entity-y": entity.anchor.y + "%", - "--entity-size": entity.size + "px", - "--entity-height": - entity.renderer.type === "sprite-atlas" - ? entity.size * - (entity.renderer.frameHeight / entity.renderer.frameWidth) + - "px" - : entity.size + "px", + "--entity-x": entity.attachment + ? attachedPosition + ? `${attachedPosition.x}px` + : entity.anchor.x + "%" + : safeFreePosition + ? `${safeFreePosition.x}px` + : entity.anchor.x + "%", + "--entity-y": entity.attachment + ? attachedPosition + ? `${attachedPosition.y}px` + : entity.anchor.y + "%" + : safeFreePosition + ? `${safeFreePosition.y}px` + : entity.anchor.y + "%", + "--entity-size": (previewEntity?.width ?? entity.size) + "px", + "--entity-height": (previewEntity?.height ?? sourceEntityHeight) + "px", "--entity-opacity": entity.opacity, } as CSSProperties) : undefined; + const entityOverlay = entity && scenario !== "components" && ( +
+ {entityImage && entity.renderer.type === "sprite-atlas" ? ( + 0 ? "true" : "false"} + style={{ width: "100%", height: "100%" }} + /> + ) : entityImage ? ( +
+ ) : ( +
+ + + + + +
+ )} +
+ ); return (
+
- {theme.scene.layers.map((layer) => { - if (layer.type === "image" && layer.asset === visual.background.image) { - return null; - } - const layerImage = - layer.type === "image" && layer.asset - ? resolveAsset(theme, layer.asset) - : undefined; - return ( -
- ); - })} + {!officialPreview && + theme.scene.layers.map((layer) => { + if ( + layer.type === "image" && + layer.asset === visual.background.image + ) { + return null; + } + const layerImage = + layer.type === "image" && layer.asset + ? resolveAsset(theme, layer.asset) + : undefined; + return ( +
+ ); + })}
-
- - -
-
-
- - {scenario === "home" - ? isChinese - ? "新任务" - : "New task" - : scenario === "settings" - ? isChinese - ? "设置" - : "Settings" - : scenario === "dialog" - ? isChinese - ? "工作区" - : "Workspace" - : scenario === "right-panel" - ? isChinese - ? "审查更改" - : "Review changes" - : isChinese - ? "主题架构与安全模型" - : "Theme architecture & safety"} - - - - main - -
- -
- -
- {scenario === "home" ? ( -
- - - {isChinese ? "我们应该构建什么?" : "What should we build?"} - -
- {[ - isChinese ? "探索代码" : "Explore code", - isChinese ? "构建功能" : "Build feature", - isChinese ? "审查更改" : "Review changes", - isChinese ? "修复问题" : "Fix issue", - ].map((label) => ( - {label} - ))} -
-
- ) : scenario === "settings" ? ( -
- -
- - {isChinese ? "应用设置" : "Application settings"} - - - - -
-
- ) : ( - <> -
-
- -
-
- - {isChinese ? "方案已建立" : "Foundation is in place"} - -

- {isChinese - ? "主题保持纯数据,互动层不会接管任何点击事件。" - : "Themes remain data-only and the scene never captures clicks."} -

-
-
-
- CS -
- Codex Styler -

- {isChinese - ? "接下来把背景、表面和行为组合成可恢复的运行时。" - : "Next, compose background, surfaces, and behavior into a reversible runtime."} -

- -
-
- - )} -
- - {scenario !== "settings" && ( -
-
- - {isChinese - ? "让 Codex 继续完善这个主题…" - : "Ask Codex to refine this theme…"} - - -
-
- )} - - {scenario === "right-panel" && ( - - )} -
- - {scenario === "dialog" && ( -
-
- - - {isChinese ? "确认应用主题" : "Apply this theme?"} - -

- {isChinese - ? "当前 Codex 会话将立即更新。" - : "The current Codex session will update immediately."} -

- - - - -
-
- )} - - {entity && ( -
- {entityImage && entity.renderer.type === "sprite-atlas" ? ( - 0 ? "true" : "false"} - /> - ) : entityImage ? ( -
- ) : ( -
- - - - - -
- )} -
- )} -
+
); } diff --git a/apps/desktop/src/components/preview/ChangeReviewPreview.test.tsx b/apps/desktop/src/components/preview/ChangeReviewPreview.test.tsx new file mode 100644 index 0000000..1e7f15b --- /dev/null +++ b/apps/desktop/src/components/preview/ChangeReviewPreview.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ChangeReviewPreview } from "./ChangeReviewPreview"; + +describe("ChangeReviewPreview", () => { + it("shows files, semantic diff lines, and restore safety", () => { + const { container } = render(); + + expect(screen.getByText("Working tree")).toBeInTheDocument(); + expect(screen.getAllByText("runtime.js")).toHaveLength(2); + expect(screen.getByText("Safe to restore")).toBeInTheDocument(); + expect(container.querySelectorAll('[data-change="added"]')).toHaveLength(3); + expect(container.querySelectorAll('[data-change="deleted"]')).toHaveLength( + 2, + ); + }); +}); diff --git a/apps/desktop/src/components/preview/ChangeReviewPreview.tsx b/apps/desktop/src/components/preview/ChangeReviewPreview.tsx new file mode 100644 index 0000000..2f0086d --- /dev/null +++ b/apps/desktop/src/components/preview/ChangeReviewPreview.tsx @@ -0,0 +1,65 @@ +import { Check, FileCode2, FileDiff, GitCompareArrows } from "lucide-react"; + +interface ChangeReviewPreviewProps { + isChinese: boolean; +} + +export function ChangeReviewPreview({ isChinese }: ChangeReviewPreviewProps) { + return ( + + ); +} diff --git a/apps/desktop/src/components/preview/CodexPreviewScenarios.tsx b/apps/desktop/src/components/preview/CodexPreviewScenarios.tsx new file mode 100644 index 0000000..c828a99 --- /dev/null +++ b/apps/desktop/src/components/preview/CodexPreviewScenarios.tsx @@ -0,0 +1,199 @@ +import { + AlertTriangle, + Check, + CircleX, + FileDiff, + Search, + Settings, + SlidersHorizontal, + Sparkles, +} from "lucide-react"; +import type { PreviewScenario } from "../../lib/storage"; +import { ChangeReviewPreview } from "./ChangeReviewPreview"; +import { TaskActivityPreview } from "./TaskActivityPreview"; +import { TerminalActivityPreview } from "./TerminalActivityPreview"; + +export function CodexPreviewScenario({ + scenario, + isChinese, +}: { + scenario: PreviewScenario; + isChinese: boolean; +}) { + if (scenario === "home") { + return ( +
+ + + {isChinese ? "我们应该构建什么?" : "What should we build?"} + +
+ {[ + isChinese ? "探索代码" : "Explore code", + isChinese ? "构建功能" : "Build feature", + isChinese ? "审查更改" : "Review changes", + isChinese ? "修复问题" : "Fix issue", + ].map((label) => ( + {label} + ))} +
+
+ ); + } + + if (scenario === "settings") { + return ( +
+ +
+ {isChinese ? "应用设置" : "Application settings"} + + + {isChinese ? "外观" : "Appearance"} + {isChinese ? "跟随系统" : "Follow system"} + + {isChinese ? "系统" : "System"} + + + + {isChinese ? "减少动态" : "Reduce motion"} + {isChinese ? "关闭位移动画" : "Limit movement"} + + + + + + {isChinese ? "语言" : "Language"} + {isChinese ? "简体中文" : "English"} + + {isChinese ? "简中" : "EN"} + +
+
+ ); + } + + if (scenario === "components") { + return ; + } + if (scenario === "changes") + return ; + if (scenario === "terminal") + return ; + return ; +} + +function ComponentTestBench({ isChinese }: { isChinese: boolean }) { + return ( +
+
+ + + {isChinese ? "界面状态检查" : "Interface state check"} + + + {isChinese + ? "主题编辑器专用测试台,不代表 Codex 页面。" + : "An editor test bench, not a Codex page."} + + + {isChinese ? "测试台" : "TEST BENCH"} +
+
+
+ {isChinese ? "操作与输入" : "Actions & input"} +
+ + + +
+
+ + {isChinese ? "搜索更改…" : "Search changes…"} +