diff --git a/docs/images/pr-163-after.png b/docs/images/pr-163-after.png new file mode 100644 index 00000000..f9eafa4a Binary files /dev/null and b/docs/images/pr-163-after.png differ diff --git a/docs/images/pr-163-before.png b/docs/images/pr-163-before.png new file mode 100644 index 00000000..172f2812 Binary files /dev/null and b/docs/images/pr-163-before.png differ diff --git a/macos/CHANGELOG.md b/macos/CHANGELOG.md index 0e5d740a..bc2fabf2 100644 --- a/macos/CHANGELOG.md +++ b/macos/CHANGELOG.md @@ -5,6 +5,8 @@ ### 改进 - 菜单栏在 `paused` 状态显示「继续显示皮肤」(调用现有应用入口),与 Windows 托盘暂停/继续文案和语义对齐;运行中仍为「重新应用皮肤」+「暂停皮肤」。 +- 收紧 macOS 首页布局:项目选择器从中英文标题中独立显示,四个首页入口固定为紧凑的 2×2 网格(Codex 异步减少原生建议时自动补齐),并将撰写器最大宽度统一为 920px;其他语言继续保留原生完整标题。 +- 沉浸任务页右侧的输出、子智能体、浏览器和来源浮层改用半透明模糊材质,不再以原生实色下拉背景遮住主题图片;粘性分组标题直接继承浮层磨砂材质,不再叠加暗色底条。 ## 1.2.0 — 2026-07-17 diff --git a/macos/assets/dream-skin.css b/macos/assets/dream-skin.css index 07976b19..241d9b06 100644 --- a/macos/assets/dream-skin.css +++ b/macos/assets/dream-skin.css @@ -540,7 +540,7 @@ html.codex-dream-skin [role="main"] { } html.codex-dream-skin .dream-skin-home { - --thread-content-max-width: min(1180px, calc(100cqw - 44px)) !important; + --thread-content-max-width: min(920px, calc(100cqw - 48px)) !important; overflow-x: hidden !important; } @@ -550,8 +550,8 @@ html.codex-dream-skin .dream-skin-home { } .dream-skin-home > div:first-child > div:first-child { - flex: 0 0 440px !important; - min-height: 440px !important; + flex: 0 0 520px !important; + min-height: 520px !important; align-items: flex-start !important; padding-bottom: 0 !important; } @@ -561,13 +561,13 @@ html.codex-dream-skin .dream-skin-home { isolation: isolate; width: calc(100% - 44px) !important; max-width: none !important; - height: clamp(248px, 31cqw, 376px) !important; - min-height: 248px !important; + height: clamp(260px, 26cqw, 320px) !important; + min-height: 260px !important; flex: 0 1 auto !important; overflow: visible !important; padding: 0 !important; border: 1px solid rgb(var(--ds-accent-rgb) / .30) !important; - border-radius: 22px !important; + border-radius: 16px !important; background-color: var(--ds-panel) !important; background-image: var(--dream-skin-art) !important; background-repeat: no-repeat !important; @@ -582,7 +582,7 @@ html.codex-dream-skin .dream-skin-home { inset: 0 auto 0 0; z-index: 0; width: 62%; - border-radius: 21px 0 0 21px; + border-radius: 15px 0 0 15px; pointer-events: none; background: var(--ds-hero-scrim); } @@ -603,7 +603,7 @@ html.codex-dream-skin:is([data-dream-art-safe="none"], [data-dream-art-safe-area .dream-skin-home > div:first-child > div:first-child > div:first-child::before { inset: 0; width: auto; - border-radius: 21px; + border-radius: 15px; } .dream-skin-home > div:first-child > div:first-child > div:first-child > div:first-child { @@ -626,7 +626,7 @@ html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-ar } .dream-skin-home > div:first-child > div:first-child > div:first-child > div:first-child > div:first-child { - width: min(46%, 520px) !important; + width: min(640px, 100%) !important; align-items: flex-start !important; gap: 0 !important; } @@ -639,7 +639,6 @@ html.codex-dream-skin:is([data-dream-art-safe="right"], [data-dream-art-safe-are html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-area="center"]) .dream-skin-home > div:first-child > div:first-child > div:first-child > div:first-child > div:first-child { - width: min(58%, 640px) !important; align-items: center !important; text-align: center !important; } @@ -648,6 +647,7 @@ html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-ar .dream-skin-home [data-feature="game-source"] { display: block !important; + position: relative !important; max-width: 100% !important; color: var(--ds-text) !important; font-size: clamp(19px, 1.85vw, 27px) !important; @@ -659,14 +659,26 @@ html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-ar pointer-events: auto !important; } -.dream-skin-home [data-feature="game-source"]::before { - content: none; +.dream-skin-home [data-feature="game-source"][data-dream-skin-question] { + width: min(640px, 100%) !important; + padding-top: 58px !important; + font-size: 0 !important; +} + +.dream-skin-home [data-feature="game-source"][data-dream-skin-question]::before { + content: attr(data-dream-skin-question); + display: block; + color: var(--ds-text); + font-size: clamp(38px, 3.6cqw, 52px); + line-height: 1.08; + font-weight: 680; + letter-spacing: -.035em; } .dream-skin-home [data-feature="game-source"]::after { content: var(--dream-skin-tagline, "把喜欢的画面变成可交互的 Codex 工作台。"); display: block; - margin-top: 13px; + margin-top: 14px; color: rgb(var(--ds-text-rgb) / .76); font-size: 13px; line-height: 1.5; @@ -674,49 +686,143 @@ html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-ar letter-spacing: 0; } -.dream-skin-home [data-feature="game-source"] button { - margin: 0 5px; - padding: 2px 9px 3px; +.dream-skin-home [data-feature="game-source"][data-dream-skin-question] button { + position: absolute !important; + left: 0; + top: 0; + min-height: 44px; + margin: 0; + padding: 0 14px; border: 1px solid rgb(var(--ds-accent-rgb) / .36); - border-radius: 999px; - background: rgb(var(--ds-accent-rgb) / .10); - color: var(--ds-accent) !important; - text-decoration-color: rgb(var(--ds-accent-rgb) / .56) !important; - text-underline-offset: 5px; + border-radius: 12px; + background: rgb(var(--ds-panel-rgb) / .54); + color: var(--ds-text) !important; + font-size: 14px !important; + line-height: 1; + text-decoration: none !important; + box-shadow: inset 0 1px rgb(var(--ds-text-rgb) / .08); + backdrop-filter: blur(8px) saturate(104%); } -.dream-skin-home [data-feature="game-source"] button::before { +.dream-skin-home [data-feature="game-source"][data-dream-skin-question] button::before { content: var(--dream-skin-project-prefix, "选择项目 · "); - font-size: .46em; - font-weight: 700; + margin-right: 8px; + color: var(--ds-muted); + font-size: 11px; + font-weight: 650; vertical-align: middle; - opacity: .86; + opacity: 1; +} + +html.codex-dream-skin:is([data-dream-art-safe="right"], [data-dream-art-safe-area="right"]) + .dream-skin-home [data-feature="game-source"][data-dream-skin-question] button { + left: auto; + right: 0; +} + +html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-area="center"]) + .dream-skin-home [data-feature="game-source"][data-dream-skin-question] button { + left: 50%; + transform: translateX(-50%); } .dream-skin-home > div:first-child > div:first-child > div:first-child > div:nth-child(2) { - left: 14px !important; - right: 14px !important; + left: 38px !important; + right: auto !important; top: 100% !important; - margin-top: 13px !important; + width: min(640px, calc(100% - 76px)) !important; + margin-top: 14px !important; +} + +html.codex-dream-skin:is([data-dream-art-safe="right"], [data-dream-art-safe-area="right"]) + .dream-skin-home > div:first-child > div:first-child > div:first-child > div:nth-child(2) { + left: auto !important; + right: 38px !important; +} + +html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-area="center"]) + .dream-skin-home > div:first-child > div:first-child > div:first-child > div:nth-child(2) { + left: 50% !important; + right: auto !important; + transform: translateX(-50%); +} + +html.codex-dream-skin main.main-surface.dream-skin-home-shell [role="main"].dream-skin-home + [class~="absolute"][class~="top-full"]:has(> [data-dream-skin-generated-home-suggestions]) { + left: 50% !important; + right: auto !important; + top: 100% !important; + width: calc(100% - 52px) !important; + max-width: 888px; + margin-top: 76px !important; + transform: translateX(-50%) !important; +} + +.dream-skin-home .group\/home-suggestions { + display: block !important; + overflow: visible !important; +} + +.dream-skin-home div:has(> .group\/home-suggestions) { + margin-inline: 0 !important; +} + +html.codex-dream-skin[data-dream-art-wide="true"] + .dream-skin-home div:has(> .group\/home-suggestions) { + transform: translateY(-300px); +} + +.dream-skin-home .group\/home-suggestions > div { + width: 100% !important; + padding-inline: 0 !important; +} + +.dream-skin-home [data-dream-skin-home-grid] { + display: grid !important; + width: 100% !important; + padding-inline: 0 !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 12px !important; +} + +.dream-skin-home [data-dream-skin-home-grid] > * { + display: block !important; } -.dream-skin-home .group\/home-suggestions { overflow: visible !important; } +.dream-skin-home .group\/home-suggestions[data-dream-skin-home-suggestions] + [data-dream-skin-home-grid] > [data-dream-skin-native-home-card="hidden"] { + display: none !important; +} + +.dream-skin-home .group\/home-suggestions[data-dream-skin-home-suggestions] + > .group\/home-suggestions-header { + display: none !important; +} + +.dream-skin-home .group\/home-suggestions[data-dream-skin-empty-home-suggestions] { + display: none !important; +} .dream-skin-home .group\/home-suggestions button { position: relative !important; - min-height: 118px !important; - padding: 14px 13px 12px !important; - align-items: stretch !important; + display: flex !important; + flex-direction: row !important; + width: 100% !important; + height: 100% !important; + min-height: 72px !important; + padding: 12px 15px !important; + align-items: center !important; justify-content: flex-start !important; - text-align: center !important; + gap: 12px !important; + text-align: left !important; border: 1px solid rgb(var(--ds-muted-rgb) / .18) !important; - border-radius: 18px !important; - background: rgb(var(--ds-panel-rgb) / .90) !important; + border-radius: 14px !important; + background: rgb(var(--ds-panel-rgb) / .70) !important; color: var(--ds-text) !important; font-weight: 600 !important; - line-height: 1.45 !important; - box-shadow: 0 8px 22px rgb(var(--ds-bg-rgb) / .18) !important; - backdrop-filter: blur(10px) saturate(105%) !important; + line-height: 1.35 !important; + box-shadow: 0 6px 18px rgb(var(--ds-bg-rgb) / .14) !important; + backdrop-filter: blur(8px) saturate(104%) !important; transform: translateY(0); transition: transform .18s cubic-bezier(.22, 1, .36, 1), box-shadow .18s cubic-bezier(.22, 1, .36, 1), @@ -728,37 +834,85 @@ html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-ar } .dream-skin-home .group\/home-suggestions button:hover { - transform: translateY(-2px) !important; + transform: translateY(-1px) !important; border-color: rgb(var(--ds-accent-rgb) / .42) !important; - box-shadow: 0 12px 28px rgb(var(--ds-bg-rgb) / .24) !important; + background: rgb(var(--ds-panel-rgb) / .82) !important; + box-shadow: 0 9px 22px rgb(var(--ds-bg-rgb) / .20) !important; } .dream-skin-home .group\/home-suggestions button > span:first-child > span:first-child { - width: 38px; - height: 38px; + width: 34px; + height: 34px; display: grid !important; place-items: center; - margin: 0 auto; + flex: 0 0 34px; + margin: 0; border: 1px solid rgb(var(--ds-accent-rgb) / .24); - border-radius: 50%; + border-radius: 10px; color: var(--ds-accent) !important; - background: rgb(var(--ds-accent-rgb) / .12); - box-shadow: 0 0 0 6px rgb(var(--ds-accent-rgb) / .05); + background: rgb(var(--ds-accent-rgb) / .10); + box-shadow: inset 0 1px rgb(var(--ds-text-rgb) / .06); } .dream-skin-home .group\/home-suggestions button svg { - width: 21px !important; - height: 21px !important; + width: 18px !important; + height: 18px !important; color: var(--ds-accent) !important; } -.dream-skin-home .group\/home-suggestions button > span:first-child { justify-content: center !important; } -.dream-skin-home .group\/home-suggestions button > span:last-child { align-items: center !important; text-align: center !important; } +.dream-skin-home .group\/home-suggestions .dream-skin-home-action-icon { + width: 34px !important; + height: 34px !important; + display: grid !important; + place-items: center; + flex: 0 0 34px !important; + border: 1px solid rgb(var(--ds-accent-rgb) / .24); + border-radius: 10px; + color: var(--ds-accent) !important; + background: rgb(var(--ds-accent-rgb) / .10); + box-shadow: inset 0 1px rgb(var(--ds-text-rgb) / .06); +} + +.dream-skin-home .group\/home-suggestions .dream-skin-home-action-copy { + display: flex !important; + flex-direction: column !important; +} + +.dream-skin-home .group\/home-suggestions .dream-skin-home-action-label { + display: block !important; + min-width: 0; + overflow: hidden; + color: var(--ds-text) !important; + font-size: 13px; + font-weight: 600; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dream-skin-home .group\/home-suggestions button > span:first-child:not(.dream-skin-home-action-icon) { + width: auto !important; + flex: 0 0 auto !important; + justify-content: flex-start !important; +} +.dream-skin-home .group\/home-suggestions button > span:last-child { + min-width: 0 !important; + min-height: 0 !important; + width: auto !important; + flex: 1 1 auto !important; + margin: 0 !important; + justify-content: center !important; + align-items: flex-start !important; + text-align: left !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; +} html.codex-dream-skin .composer-surface-chrome { overflow: visible !important; border: 1px solid rgb(var(--ds-muted-rgb) / .18) !important; - border-radius: 22px !important; + border-radius: 16px !important; background: rgb(var(--ds-panel-rgb) / .94) !important; box-shadow: 0 10px 28px rgb(var(--ds-bg-rgb) / .24) !important; backdrop-filter: blur(16px) saturate(108%) !important; @@ -837,6 +991,25 @@ html.codex-dream-skin[data-dream-art-wide="true"] main.main-surface.dream-skin-h content: none !important; } +html.codex-dream-skin[data-dream-art-wide="true"] main.main-surface.dream-skin-home-shell + .dream-skin-home > div:first-child > div:first-child > div:first-child > div:first-child { + justify-content: center !important; +} + +html.codex-dream-skin[data-dream-art-wide="true"] main.main-surface.dream-skin-home-shell + .dream-skin-home > div:first-child > div:first-child > div:first-child + > div:first-child > div:first-child { + width: min(calc(var(--thread-content-max-width) - 32px), calc(100% - 76px)) !important; + align-items: flex-start !important; + text-align: left !important; + transform: translateY(90px); +} + +html.codex-dream-skin[data-dream-art-wide="true"] main.main-surface.dream-skin-home-shell + .dream-skin-home [data-feature="game-source"][data-dream-skin-question] { + width: 100% !important; +} + html.codex-dream-skin[data-dream-art-wide="true"] main.main-surface.dream-skin-home-shell .dream-skin-home .group\/home-suggestions button { background: rgb(var(--ds-panel-rgb) / .56) !important; @@ -878,8 +1051,9 @@ html.codex-dream-skin .dream-skin-home-utility { top: 0 !important; width: 100% !important; margin-inline: 0 !important; - padding-inline: 18px !important; - border-radius: 22px 22px 0 0 !important; + min-height: 48px !important; + padding-inline: 14px !important; + border-radius: 16px 16px 0 0 !important; background: rgb(var(--ds-panel-rgb) / .94) !important; box-shadow: inset 1px 0 rgb(var(--ds-muted-rgb) / .18), @@ -891,7 +1065,7 @@ html.codex-dream-skin .dream-skin-home-utility { html.codex-dream-skin .dream-skin-home:has(.dream-skin-home-utility) .composer-surface-chrome { border: 0 !important; - border-radius: 0 0 22px 22px !important; + border-radius: 0 0 16px 16px !important; box-shadow: 0 10px 28px rgb(var(--ds-bg-rgb) / .24), inset 1px 0 rgb(var(--ds-muted-rgb) / .18), @@ -982,6 +1156,29 @@ html.codex-dream-skin:is([data-dream-task-mode="ambient"], [data-dream-art-task- background: transparent !important; } +/* The native thread detail panel uses the opaque dropdown token for both its + shell and sticky headings. Give this right-edge surface the same translucent + treatment as the immersive composer without changing unrelated dropdowns. */ +html.codex-dream-skin:is([data-dream-task-mode="ambient"], [data-dream-art-task-mode="ambient"], [data-dream-task-mode="banner"], [data-dream-art-task-mode="banner"])[data-dream-art-wide="true"] + main.main-surface:not(.dream-skin-home-shell) + [class~="top-(--thread-floating-content-top-inset)"][class~="right-0"][class~="z-40"] + [class~="rounded-3xl"][class~="bg-token-dropdown-background"] { + background: var(--ds-immersive-composer) !important; + box-shadow: + 0 12px 32px rgb(var(--ds-bg-rgb) / .22), + inset 0 0 0 1px var(--ds-immersive-line) !important; + backdrop-filter: blur(16px) saturate(108%) !important; +} + +html.codex-dream-skin:is([data-dream-task-mode="ambient"], [data-dream-art-task-mode="ambient"], [data-dream-task-mode="banner"], [data-dream-art-task-mode="banner"])[data-dream-art-wide="true"] + main.main-surface:not(.dream-skin-home-shell) + [class~="top-(--thread-floating-content-top-inset)"][class~="right-0"][class~="z-40"] + [class~="rounded-3xl"][class~="bg-token-dropdown-background"] + header[class~="bg-token-dropdown-background"] { + background: transparent !important; + backdrop-filter: none !important; +} + html.codex-dream-skin[data-dream-art-wide="true"] main.main-surface .app-shell-main-content-frame { border-top: 0 !important; @@ -1053,21 +1250,50 @@ html.codex-dream-skin [data-message-author-role] { border-radius: 16px; } .dream-skin-brand { left: 15px; } .dream-skin-brand b { font-size: 12px; } .dream-skin-home > div:first-child > div:first-child { - flex-basis: 408px !important; - min-height: 408px !important; + flex-basis: 500px !important; + min-height: 500px !important; } .dream-skin-home > div:first-child > div:first-child > div:first-child { - height: 232px !important; - min-height: 232px !important; + height: 260px !important; + min-height: 260px !important; } .dream-skin-home > div:first-child > div:first-child > div:first-child > div:first-child { padding: 0 26px; } - .dream-skin-home > div:first-child > div:first-child > div:first-child > div:first-child > div:first-child { - width: min(58%, 460px) !important; - } .dream-skin-home [data-feature="game-source"] { font-size: 18px !important; } - .dream-skin-home [data-feature="game-source"]::before { margin-bottom: 7px; font-size: 10px; } + .dream-skin-home [data-feature="game-source"][data-dream-skin-question]::before { font-size: 36px; } .dream-skin-home [data-feature="game-source"]::after { margin-top: 9px; font-size: 11px; } - .dream-skin-home .group\/home-suggestions button { min-height: 112px !important; font-size: 12px !important; } + html.codex-dream-skin[data-dream-art-wide="true"] + .dream-skin-home div:has(> .group\/home-suggestions) { transform: translateY(-250px); } + .dream-skin-home .group\/home-suggestions button { min-height: 68px !important; font-size: 12px !important; } +} + +@media (max-width: 700px) { + .dream-skin-home { --thread-content-max-width: calc(100cqw - 28px) !important; } + .dream-skin-home > div:first-child > div:first-child { + flex-basis: 580px !important; + min-height: 580px !important; + } + .dream-skin-home > div:first-child > div:first-child > div:first-child > div:first-child { + padding: 0 20px; + } + .dream-skin-home > div:first-child > div:first-child > div:first-child > div:nth-child(2) { + left: 20px !important; + right: auto !important; + width: calc(100% - 40px) !important; + transform: none; + } + html.codex-dream-skin:is([data-dream-art-safe="right"], [data-dream-art-safe-area="right"]) + .dream-skin-home > div:first-child > div:first-child > div:first-child > div:nth-child(2), + html.codex-dream-skin:is([data-dream-art-safe="center"], [data-dream-art-safe-area="center"]) + .dream-skin-home > div:first-child > div:first-child > div:first-child > div:nth-child(2) { + left: 20px !important; + right: auto !important; + transform: none; + } + .dream-skin-home [data-dream-skin-home-grid] { + grid-template-columns: minmax(0, 1fr) !important; + } + html.codex-dream-skin[data-dream-art-wide="true"] + .dream-skin-home div:has(> .group\/home-suggestions) { transform: translateY(-180px); } } @media (prefers-reduced-motion: reduce) { diff --git a/macos/assets/renderer-inject.js b/macos/assets/renderer-inject.js index fe427679..91393d78 100644 --- a/macos/assets/renderer-inject.js +++ b/macos/assets/renderer-inject.js @@ -4,6 +4,33 @@ const STYLE_ID = "codex-dream-skin-style"; const CHROME_ID = "codex-dream-skin-chrome"; const SHELL_ATTR = "data-dream-shell"; + const HOME_QUESTION_ATTR = "data-dream-skin-question"; + const HOME_SUGGESTIONS_ATTR = "data-dream-skin-home-suggestions"; + const HOME_CARD_ATTR = "data-dream-skin-home-card"; + const HOME_NATIVE_CARD_ATTR = "data-dream-skin-native-home-card"; + const HOME_GENERATED_SUGGESTIONS_ATTR = "data-dream-skin-generated-home-suggestions"; + const HOME_EMPTY_SUGGESTIONS_ATTR = "data-dream-skin-empty-home-suggestions"; + const HOME_GRID_ATTR = "data-dream-skin-home-grid"; + const HOME_SUGGESTIONS = { + zh: [ + ["explore", "探索并理解代码", "探索并理解这个项目的代码结构、核心模块和关键执行链路。"], + ["build", "构建新功能、应用或工具", "在这个项目中构建一个新功能、应用或工具。"], + ["review", "审查代码并提出修改建议", "审查这个项目的代码,并提出具体、可执行的修改建议。"], + ["fix", "修复问题和失败", "定位并修复这个项目中的问题和失败。"], + ], + en: [ + ["explore", "Explore and understand the code", "Explore this project's code structure, core modules, and execution flow."], + ["build", "Build a new feature, app, or tool", "Build a new feature, app, or tool in this project."], + ["review", "Review code and suggest improvements", "Review this project's code and suggest specific, actionable improvements."], + ["fix", "Fix issues and failures", "Find and fix issues or failures in this project."], + ], + }; + const HOME_SUGGESTION_ICONS = { + explore: '', + build: '', + review: '', + fix: '', + }; const ART_ATTRS = [ "data-dream-art-wide", "data-dream-art-safe", "data-dream-task-mode", "data-dream-art-safe-area", "data-dream-art-task-mode", "data-dream-art-aspect", @@ -44,6 +71,7 @@ rootPasses: 0, routePasses: 0, layoutReads: 0, + rootClassWrites: 0, attributeWrites: 0, styleWrites: 0, textWrites: 0, @@ -78,6 +106,24 @@ try { previous.mediaQuery.removeEventListener("change", previous.mediaHandler); } catch {} } + const clearHomeEnhancements = () => { + document.querySelectorAll(`[${HOME_QUESTION_ATTR}]`).forEach((node) => + node.removeAttribute(HOME_QUESTION_ATTR)); + document.querySelectorAll(`[${HOME_CARD_ATTR}]`).forEach((node) => node.remove()); + document.querySelectorAll(`[${HOME_NATIVE_CARD_ATTR}]`).forEach((node) => + node.removeAttribute(HOME_NATIVE_CARD_ATTR)); + document.querySelectorAll(`[${HOME_SUGGESTIONS_ATTR}]`).forEach((node) => + node.removeAttribute(HOME_SUGGESTIONS_ATTR)); + document.querySelectorAll(`[${HOME_GRID_ATTR}]`).forEach((node) => + node.removeAttribute(HOME_GRID_ATTR)); + document.querySelectorAll(`[${HOME_EMPTY_SUGGESTIONS_ATTR}]`).forEach((node) => + node.removeAttribute(HOME_EMPTY_SUGGESTIONS_ATTR)); + document.querySelectorAll(`[${HOME_GENERATED_SUGGESTIONS_ATTR}]`).forEach((node) => + node.remove()); + }; + + clearHomeEnhancements(); + const cssString = (value) => JSON.stringify(String(value ?? "")); const setStyleProperty = (root, name, value) => { @@ -102,6 +148,162 @@ } }; + const fillComposer = (text) => { + const editor = document.querySelector('.ProseMirror[contenteditable="true"]'); + if (!editor) return; + editor.focus(); + const selection = window.getSelection?.(); + if (selection && typeof document.createRange === "function") { + const range = document.createRange(); + range.selectNodeContents(editor); + selection.removeAllRanges(); + selection.addRange(range); + } + let inserted = false; + try { + inserted = document.execCommand?.("insertText", false, text) === true; + } catch {} + if (!inserted) { + editor.textContent = text; + const InputEventConstructor = window.InputEvent || window.Event; + if (InputEventConstructor) { + editor.dispatchEvent(new InputEventConstructor("input", { + bubbles: true, + inputType: "insertText", + data: text, + })); + } + } + }; + + const suggestionGridFor = (group) => { + if (!group) return null; + for (const child of group.children ?? []) { + if (child.classList?.contains("group/home-suggestions-header")) continue; + if (child.classList?.contains("grid")) return child; + const nested = child.querySelector?.('[class~="grid"]'); + if (nested) return nested; + } + const legacyContainer = group.children?.[0] ?? null; + if (legacyContainer?.classList?.contains("group/home-suggestions-header")) return null; + return legacyContainer?.children?.[0] ?? null; + }; + + const findCurrentSuggestionPortal = (home, question) => { + for (let node = question; node && node !== home; node = node.parentElement) { + const sibling = node.nextElementSibling; + if (sibling?.classList?.contains("absolute") && + sibling.classList.contains("top-full")) return sibling; + } + return null; + }; + + const resolveHomeSuggestions = (home, question, locale) => { + const generated = [...document.querySelectorAll(`[${HOME_GENERATED_SUGGESTIONS_ATTR}]`)]; + const portal = locale ? findCurrentSuggestionPortal(home, question) : null; + const nativeGroups = home + ? [...home.querySelectorAll('[class~="group/home-suggestions"]')] + .filter((candidate) => candidate.getAttribute(HOME_GENERATED_SUGGESTIONS_ATTR) === null) + : []; + const native = nativeGroups.find((candidate) => suggestionGridFor(candidate)) ?? null; + for (const candidate of document.querySelectorAll(`[${HOME_EMPTY_SUGGESTIONS_ATTR}]`)) { + if (native || !portal || !nativeGroups.includes(candidate)) { + candidate.removeAttribute(HOME_EMPTY_SUGGESTIONS_ATTR); + } + } + if (native) { + for (const candidate of generated) candidate.remove(); + return native; + } + + if (!portal) { + for (const candidate of generated) candidate.remove(); + return null; + } + + for (const candidate of nativeGroups) setAttribute(candidate, HOME_EMPTY_SUGGESTIONS_ATTR, ""); + let group = generated.find((candidate) => portal.contains(candidate)) ?? null; + for (const candidate of generated) { + if (candidate !== group) candidate.remove(); + } + if (!group) { + group = document.createElement("section"); + group.classList.add("group/home-suggestions"); + group.setAttribute(HOME_GENERATED_SUGGESTIONS_ATTR, ""); + const container = document.createElement("div"); + container.appendChild(document.createElement("div")); + group.appendChild(container); + portal.appendChild(group); + } + return group; + }; + + // Codex can replace the four curated actions with fewer generated suggestions. + // Keep matching native buttons and their React handlers, then fill only the gaps. + const syncHomeSuggestions = (group, locale) => { + const suggestions = HOME_SUGGESTIONS[locale] ?? null; + for (const card of document.querySelectorAll(`[${HOME_CARD_ATTR}]`)) { + if (!group || !suggestions || !group.contains(card)) card.remove(); + } + for (const card of document.querySelectorAll(`[${HOME_NATIVE_CARD_ATTR}]`)) { + if (!group || !suggestions || !group.contains(card)) { + card.removeAttribute(HOME_NATIVE_CARD_ATTR); + } + } + for (const candidate of document.querySelectorAll(`[${HOME_SUGGESTIONS_ATTR}]`)) { + if (candidate !== group || !suggestions) candidate.removeAttribute(HOME_SUGGESTIONS_ATTR); + } + for (const candidate of document.querySelectorAll(`[${HOME_GRID_ATTR}]`)) { + if (!group?.contains(candidate) || !suggestions) candidate.removeAttribute(HOME_GRID_ATTR); + } + if (!group || !suggestions) return; + + setAttribute(group, HOME_SUGGESTIONS_ATTR, locale); + const suggestionByLabel = new Map(suggestions.map((suggestion) => [suggestion[1], suggestion])); + const nativeById = new Map(); + const grid = suggestionGridFor(group); + if (!grid) return; + setAttribute(grid, HOME_GRID_ATTR, ""); + for (const button of grid.querySelectorAll("button")) { + let wrapper = button; + while (wrapper.parentElement && wrapper.parentElement !== grid) { + wrapper = wrapper.parentElement; + } + if (wrapper.parentElement !== grid || wrapper.getAttribute(HOME_CARD_ATTR) !== null) continue; + const label = String(button.innerText || button.textContent || "").replace(/\s+/g, " ").trim(); + const suggestion = suggestionByLabel.get(label); + if (suggestion && !nativeById.has(suggestion[0])) { + setAttribute(wrapper, HOME_NATIVE_CARD_ATTR, suggestion[0]); + nativeById.set(suggestion[0], wrapper); + } else { + setAttribute(wrapper, HOME_NATIVE_CARD_ATTR, "hidden"); + } + } + + const syntheticById = new Map([...group.querySelectorAll(`[${HOME_CARD_ATTR}]`)] + .map((card) => [card.getAttribute(HOME_CARD_ATTR), card])); + for (const [id, label, prompt] of suggestions) { + const existing = syntheticById.get(id); + if (nativeById.has(id)) { + existing?.remove(); + } else if (!existing || existing.parentElement !== grid) { + existing?.remove(); + const card = document.createElement("div"); + card.setAttribute(HOME_CARD_ATTR, id); + const button = document.createElement("button"); + button.type = "button"; + button.setAttribute("aria-label", label); + button.innerHTML = `` + + `${label}`; + button.addEventListener("click", () => fillComposer(prompt)); + card.appendChild(button); + grid.appendChild(card); + } + syntheticById.delete(id); + } + for (const card of syntheticById.values()) card.remove(); + }; + const parseRgb = (value) => { if (!value || value === "transparent") return null; const hex = String(value).trim().match(/^#([0-9a-f]{6})$/i); @@ -552,7 +754,10 @@ setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`); applyTheme(root, shell); applyArtMetadata(root); - root.classList.add("codex-dream-skin"); + if (!root.classList.contains("codex-dream-skin")) { + root.classList.add("codex-dream-skin"); + metrics.rootClassWrites += 1; + } return shell; }; @@ -563,14 +768,43 @@ shell ||= root.getAttribute(SHELL_ATTR) || resolvedShell(); const shellMain = document.querySelector("main.main-surface") || document.querySelector("main"); const homeIndicator = document.querySelector('[data-testid="home-icon"]'); + const homeQuestionSignal = document.querySelector('[data-feature="game-source"]'); const home = homeIndicator?.closest('[role="main"]') || + homeQuestionSignal?.closest?.('[role="main"]') || [...document.querySelectorAll('[role="main"]')].find((candidate) => - candidate.querySelector('[data-feature="game-source"]') && - candidate.querySelector('.group\\\\/home-suggestions')) || null; + candidate.querySelector('[data-feature="game-source"]')) || null; for (const candidate of document.querySelectorAll('[role="main"].dream-skin-home')) { if (candidate !== home) candidate.classList.remove("dream-skin-home"); } if (home) home.classList.add("dream-skin-home"); + const homeQuestion = home?.querySelector('[data-feature="game-source"]') ?? null; + let homeLocale = null; + for (const candidate of document.querySelectorAll(`[${HOME_QUESTION_ATTR}]`)) { + if (candidate !== homeQuestion) candidate.removeAttribute(HOME_QUESTION_ATTR); + } + if (homeQuestion) { + const questionClone = homeQuestion.cloneNode(true); + questionClone.querySelectorAll("button").forEach((button) => button.remove()); + const nativeQuestion = questionClone.textContent.replace(/\s+/g, " ").trim(); + let compactQuestion = null; + if (/^我们应该(?:在\s*中)?构建什么[??]?$/.test(nativeQuestion)) { + compactQuestion = "我们应该构建什么?"; + homeLocale = "zh"; + } else if (/^我们应该(?:在\s*中)?做些什么[??]?$/.test(nativeQuestion)) { + compactQuestion = "我们应该做些什么?"; + homeLocale = "zh"; + } else if (/^what should we build(?: in)?\s*[??]?$/i.test(nativeQuestion)) { + compactQuestion = "What should we build?"; + homeLocale = "en"; + } else if (/^what should we work on(?: in)?\s*[??]?$/i.test(nativeQuestion)) { + compactQuestion = "What should we work on?"; + homeLocale = "en"; + } + if (compactQuestion) setAttribute(homeQuestion, HOME_QUESTION_ATTR, compactQuestion); + else homeQuestion.removeAttribute(HOME_QUESTION_ATTR); + } + const homeSuggestions = resolveHomeSuggestions(home, homeQuestion, homeLocale); + syncHomeSuggestions(homeSuggestions, homeLocale); const homeUtilityBars = new Set(home ? home.querySelectorAll('[class*="_homeUtilityBar_"]') : []); @@ -656,6 +890,7 @@ document.querySelectorAll(".dream-skin-home").forEach((node) => node.classList.remove("dream-skin-home")); document.querySelectorAll(".dream-skin-home-shell").forEach((node) => node.classList.remove("dream-skin-home-shell")); document.querySelectorAll(".dream-skin-home-utility").forEach((node) => node.classList.remove("dream-skin-home-utility")); + clearHomeEnhancements(); document.getElementById(STYLE_ID)?.remove(); document.getElementById(CHROME_ID)?.remove(); state?.observer?.disconnect(); diff --git a/macos/scripts/injector.mjs b/macos/scripts/injector.mjs index a37b67e2..de142ecf 100644 --- a/macos/scripts/injector.mjs +++ b/macos/scripts/injector.mjs @@ -203,13 +203,15 @@ function validatedDebuggerUrl(target, port) { return url.href; } -function isValidCdpPageTarget(item, port) { +export function isValidCdpPageTarget(item, port) { if ( item?.type !== "page" || !item.url?.startsWith("app://") || typeof item.id !== "string" || !CDP_ID_PATTERN.test(item.id) || !item.webSocketDebuggerUrl ) return false; try { + const targetUrl = new URL(item.url); + if (targetUrl.searchParams.get("initialRoute") === "/avatar-overlay") return false; const debuggerUrl = new URL(validatedDebuggerUrl(item, port)); return debuggerUrl.pathname === `/devtools/page/${item.id}`; } catch { @@ -217,7 +219,7 @@ function isValidCdpPageTarget(item, port) { } } -class CdpSession { +export class CdpSession { constructor(target, port) { this.target = target; this.ws = new WebSocket(validatedDebuggerUrl(target, port)); @@ -228,27 +230,32 @@ class CdpSession { } async open() { - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - try { this.ws.close(); } catch {} - reject(new Error("CDP WebSocket open timed out")); - }, 5000); - this.ws.addEventListener("open", () => { clearTimeout(timeout); resolve(); }, { once: true }); - this.ws.addEventListener("error", () => { clearTimeout(timeout); reject(new Error("CDP WebSocket open failed")); }, { once: true }); - }); - this.ws.addEventListener("message", (event) => this.onMessage(event)); - this.ws.addEventListener("error", () => this.close()); - this.ws.addEventListener("close", () => { - this.closed = true; - for (const waiter of this.pending.values()) { - clearTimeout(waiter.timeout); - waiter.reject(new Error("CDP socket closed")); - } - this.pending.clear(); - }); - await this.send("Runtime.enable"); - await this.send("Page.enable"); - return this; + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + try { this.ws.close(); } catch {} + reject(new Error("CDP WebSocket open timed out")); + }, 5000); + this.ws.addEventListener("open", () => { clearTimeout(timeout); resolve(); }, { once: true }); + this.ws.addEventListener("error", () => { clearTimeout(timeout); reject(new Error("CDP WebSocket open failed")); }, { once: true }); + }); + this.ws.addEventListener("message", (event) => this.onMessage(event)); + this.ws.addEventListener("error", () => this.close()); + this.ws.addEventListener("close", () => { + this.closed = true; + for (const waiter of this.pending.values()) { + clearTimeout(waiter.timeout); + waiter.reject(new Error("CDP socket closed")); + } + this.pending.clear(); + }); + await this.send("Runtime.enable"); + await this.send("Page.enable"); + return this; + } catch (error) { + this.close(); + throw error; + } } onMessage(event) { @@ -840,8 +847,10 @@ async function verifySession(session, expectedThemeId = null, expectedRevision = document.querySelector('.group\\\\/home-suggestions'); const homeRoute = homeSignal?.closest('[role="main"]') ?? null; const home = document.querySelector('[role="main"].dream-skin-home'); - const suggestions = home?.querySelector('.group\\\\/home-suggestions') ?? null; - const cardButtons = suggestions ? [...suggestions.querySelectorAll('button')] : []; + const homeQuestion = home?.querySelector('[data-feature="game-source"]') ?? null; + const suggestions = home?.querySelector('[data-dream-skin-home-suggestions]') ?? null; + const suggestionGrid = suggestions?.querySelector('[data-dream-skin-home-grid]') ?? null; + const cardButtons = suggestionGrid ? [...suggestionGrid.querySelectorAll('button')] : []; const cardBoxes = cardButtons.map(box); const visibleCards = cardBoxes.filter((item) => item?.visible); const suggestionLabels = cardButtons.flatMap((button) => { @@ -857,14 +866,15 @@ async function verifySession(session, expectedThemeId = null, expectedRevision = })); }); const visibleSuggestionLabels = suggestionLabels.filter((item) => item?.visible); - const suggestionLabelColorsMatch = visibleSuggestionLabels.every((item) => - item.color === item.expectedColor); const hero = box(home?.firstElementChild?.firstElementChild?.firstElementChild); - const projectButton = box(home?.querySelector('.group\\\\/project-selector > button')); + const projectButton = box(homeQuestion?.querySelector('button') ?? + home?.querySelector('.group\\\\/project-selector > button')); const shell = box(document.querySelector('main.main-surface')); const composer = box(document.querySelector('.composer-surface-chrome')); const sidebar = box(document.querySelector('aside.app-shell-left-panel')); const chrome = document.getElementById('codex-dream-skin-chrome'); + const suggestionLabelColorsMatch = visibleSuggestionLabels.every((item) => + item.color === item.expectedColor); const result = { installed: document.documentElement.classList.contains('codex-dream-skin'), version: window.__CODEX_DREAM_SKIN_STATE__?.version ?? null, @@ -875,7 +885,9 @@ async function verifySession(session, expectedThemeId = null, expectedRevision = chromePointerEvents: getComputedStyle(chrome || document.body).pointerEvents, homeRoute: Boolean(homeRoute), homePresent: Boolean(home), + homeEnhanced: Boolean(homeQuestion?.hasAttribute('data-dream-skin-question')), hero, + suggestions: box(suggestions), cards: cardBoxes, visibleCardCount: visibleCards.length, suggestionLabels, @@ -900,7 +912,11 @@ async function verifySession(session, expectedThemeId = null, expectedRevision = // Project selector markup varies across Codex builds — soft requirement. const homePass = !result.homeRoute || ( result.homePresent && result.hero?.visible && result.hero.width >= 280 && - result.hero.height >= 120 && (result.visibleCardCount === 0 || ( + result.hero.height >= 120 && (!result.homeEnhanced || ( + result.visibleCardCount === 4 && + result.suggestions?.visible && result.composer?.visible && + Math.abs(result.suggestions.x - result.composer.x) <= 2 && + Math.abs(result.suggestions.width - result.composer.width) <= 2 && visibleSuggestionLabels.length >= result.visibleCardCount && result.suggestionLabelColorsMatch )) @@ -911,7 +927,7 @@ async function verifySession(session, expectedThemeId = null, expectedRevision = result.softNotes = { projectButtonOptional: !result.projectButton?.visible, composerOptionalOnNonTaskRoutes: !result.composer?.visible, - suggestionCardsOptional: result.homeRoute && result.visibleCardCount === 0, + suggestionCardsOptional: result.homeRoute && !result.homeEnhanced, }; return result; })()`); @@ -1102,20 +1118,46 @@ async function runOneShot(options) { export function earlyPayloadFor(payload, revision) { return `(() => { + const isAuxiliaryRoute = () => + /(?:^|[?&])initialRoute=(?:%2f|\\/)avatar-overlay(?:&|$)/i.test( + window.location?.search || "", + ); + if (window.top !== window || window.location?.protocol !== "app:" || isAuxiliaryRoute()) return; const generationKey = "__CODEX_DREAM_SKIN_EARLY_GENERATION__"; const appliedKey = "__CODEX_DREAM_SKIN_EARLY_APPLIED__"; + const bootstrapKey = "__CODEX_DREAM_SKIN_EARLY_BOOTSTRAP__"; const generation = ${JSON.stringify(revision)}; + const previousBootstrap = window[bootstrapKey]; + if (typeof previousBootstrap?.stop === "function") previousBootstrap.stop(); window[generationKey] = generation; - let observer = null; + let interval = null; let timeout = null; + let controller = null; + const onReady = () => install(); const stop = () => { - observer?.disconnect(); - observer = null; + if (interval) clearInterval(interval); + interval = null; if (timeout) clearTimeout(timeout); timeout = null; + document.removeEventListener?.("DOMContentLoaded", onReady); + if (window[bootstrapKey] === controller) delete window[bootstrapKey]; }; + controller = { stop }; + window[bootstrapKey] = controller; const install = () => { if (window[generationKey] !== generation) { stop(); return true; } + if (isAuxiliaryRoute()) { stop(); return true; } + const state = window.__CODEX_DREAM_SKIN_STATE__; + if ( + window[appliedKey] === generation && + state?.revision === generation && + typeof state.ensure === "function" && + typeof state.cleanup === "function" && + !window.__CODEX_DREAM_SKIN_DISABLED__ + ) { + stop(); + return true; + } if (!document.documentElement) return false; const shell = document.querySelector('main.main-surface'); const sidebar = document.querySelector('aside.app-shell-left-panel'); @@ -1126,10 +1168,8 @@ export function earlyPayloadFor(payload, revision) { return true; }; if (install()) return; - if (typeof MutationObserver === "function" && document.documentElement) { - observer = new MutationObserver(install); - observer.observe(document.documentElement, { childList: true, subtree: true }); - } + document.addEventListener?.("DOMContentLoaded", onReady); + interval = setInterval(install, 250); timeout = setTimeout(stop, 10000); })()`; } diff --git a/macos/tests/injector-bootstrap.test.mjs b/macos/tests/injector-bootstrap.test.mjs index 21330596..59be9bff 100644 --- a/macos/tests/injector-bootstrap.test.mjs +++ b/macos/tests/injector-bootstrap.test.mjs @@ -1,23 +1,34 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import path from "node:path"; +import test from "node:test"; import vm from "node:vm"; import { fileURLToPath } from "node:url"; -import { earlyPayloadFor } from "../scripts/injector.mjs"; +import * as injector from "../scripts/injector.mjs"; + +const { earlyPayloadFor } = injector; const here = path.dirname(fileURLToPath(import.meta.url)); const injectorPath = path.resolve(here, "../scripts/injector.mjs"); const source = await fs.readFile(injectorPath, "utf8"); -function createFixture() { +function createFixture({ topFrame = true, protocol = "app:" } = {}) { const observers = []; const timers = new Map(); + const intervals = new Map(); + const listeners = new Map(); let nextTimer = 1; const markers = { shell: false, sidebar: false }; + const window = { installs: [], location: { protocol, search: "" } }; + window.top = topFrame ? window : {}; const context = { - window: { installs: [] }, + window, document: { documentElement: {}, + addEventListener(type, listener) { listeners.set(type, listener); }, + removeEventListener(type, listener) { + if (listeners.get(type) === listener) listeners.delete(type); + }, querySelector(selector) { if (selector === "main.main-surface") return markers.shell ? {} : null; if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null; @@ -39,29 +50,178 @@ function createFixture() { return id; }, clearTimeout(id) { timers.delete(id); }, + setInterval(callback) { + const id = nextTimer++; + intervals.set(id, callback); + return id; + }, + clearInterval(id) { intervals.delete(id); }, + }; + const tick = () => { + for (const observer of observers) { + if (observer.connected) observer.callback([]); + } + for (const callback of [...intervals.values()]) callback(); }; - return { context, markers, observers }; + return { context, intervals, listeners, markers, observers, tick, timers }; } -const guarded = createFixture(); -vm.runInNewContext(earlyPayloadFor('window.installs.push("guarded")', "guarded"), guarded.context); -assert.deepEqual(guarded.context.window.installs, [], "Auxiliary app targets must remain untouched."); -guarded.markers.shell = true; -guarded.observers[0].callback([]); -assert.deepEqual(guarded.context.window.installs, [], "A main surface without the Codex sidebar is not sufficient."); +test("early injection uses one bounded bootstrap and installs only in the top-level app shell", () => { + const guarded = createFixture(); + vm.runInNewContext(earlyPayloadFor('window.installs.push("guarded")', "guarded"), guarded.context); + assert.equal(guarded.observers.length, 0, "Bootstrap must not retain a broad subtree MutationObserver."); + assert.equal(guarded.intervals.size, 1, "A missing shell should have one bounded polling bootstrap."); + guarded.markers.shell = true; + guarded.tick(); + assert.deepEqual(guarded.context.window.installs, [], "A main surface without the Codex sidebar is not sufficient."); + guarded.markers.sidebar = true; + guarded.tick(); + assert.deepEqual(guarded.context.window.installs, ["guarded"]); + assert.equal(guarded.intervals.size, 0, "A successful install must clear its bootstrap interval."); -const generations = createFixture(); -vm.runInNewContext(earlyPayloadFor('window.installs.push("old")', "old"), generations.context); -vm.runInNewContext(earlyPayloadFor('window.installs.push("new")', "new"), generations.context); -generations.markers.shell = true; -generations.markers.sidebar = true; -for (const observer of generations.observers) observer.callback([]); -assert.deepEqual( - generations.context.window.installs, - ["new"], - "A stale early script must yield to the newest watcher generation.", -); -assert.equal(generations.context.window.__CODEX_DREAM_SKIN_EARLY_APPLIED__, "new"); + for (const fixture of [ + createFixture({ topFrame: false }), + createFixture({ protocol: "https:" }), + ]) { + vm.runInNewContext(earlyPayloadFor('window.installs.push("unexpected")', "ignored"), fixture.context); + assert.deepEqual(fixture.context.window.installs, []); + assert.equal(fixture.observers.length, 0); + assert.equal(fixture.intervals.size, 0); + assert.equal(fixture.timers.size, 0); + } +}); + +test("same-revision Runtime.evaluate replaces the previous bootstrap instead of stacking work", () => { + const generations = createFixture(); + for (let index = 0; index < 40; index += 1) { + vm.runInNewContext( + earlyPayloadFor('window.installs.push("same")', "same"), + generations.context, + ); + } + assert.equal(generations.observers.length, 0); + assert.equal(generations.intervals.size, 1, "Only the newest bootstrap interval may remain active."); + assert.equal(generations.timers.size, 1, "Only the newest bootstrap timeout may remain active."); + assert.equal(generations.listeners.size, 1, "Only the newest DOM-ready listener may remain active."); + generations.markers.shell = true; + generations.markers.sidebar = true; + generations.tick(); + assert.deepEqual(generations.context.window.installs, ["same"]); + assert.equal(generations.context.window.__CODEX_DREAM_SKIN_EARLY_APPLIED__, "same"); +}); + +test("same-revision reconnect skips a healthy renderer but restores a missing state", () => { + const ready = createFixture(); + ready.markers.shell = true; + ready.markers.sidebar = true; + const payload = ` + window.installs.push("same"); + window.__CODEX_DREAM_SKIN_STATE__ = { + revision: "same", + ensure() {}, + cleanup() {}, + }; + `; + vm.runInNewContext(earlyPayloadFor(payload, "same"), ready.context); + vm.runInNewContext(earlyPayloadFor(payload, "same"), ready.context); + assert.deepEqual( + ready.context.window.installs, + ["same"], + "A reconnect must not rebuild a healthy renderer at the same revision.", + ); + + delete ready.context.window.__CODEX_DREAM_SKIN_STATE__; + vm.runInNewContext(earlyPayloadFor(payload, "same"), ready.context); + assert.deepEqual( + ready.context.window.installs, + ["same", "same"], + "A stale applied marker must not block recovery after renderer state cleanup.", + ); +}); + +test("an early bootstrap aborts if its target navigates to the avatar overlay", () => { + const navigated = createFixture(); + vm.runInNewContext( + earlyPayloadFor('window.installs.push("unexpected")', "navigated"), + navigated.context, + ); + navigated.context.window.location.search = "?initialRoute=%2Favatar-overlay"; + navigated.markers.shell = true; + navigated.markers.sidebar = true; + navigated.tick(); + assert.deepEqual(navigated.context.window.installs, []); + assert.equal(navigated.intervals.size, 0); + assert.equal(navigated.timers.size, 0); +}); + +test("an unused early bootstrap releases its timer and listener at the deadline", () => { + const expired = createFixture(); + vm.runInNewContext( + earlyPayloadFor('window.installs.push("unexpected")', "expired"), + expired.context, + ); + assert.equal(expired.intervals.size, 1); + assert.equal(expired.listeners.size, 1); + const [timeout] = expired.timers.values(); + timeout(); + assert.equal(expired.intervals.size, 0); + assert.equal(expired.listeners.size, 0); + assert.equal(expired.context.window.__CODEX_DREAM_SKIN_EARLY_BOOTSTRAP__, undefined); +}); + +test("auxiliary app routes are rejected before a CDP session is opened", () => { + assert.equal(typeof injector.isValidCdpPageTarget, "function"); + const target = { + type: "page", + id: "target-1", + url: "app://-/index.html", + webSocketDebuggerUrl: "ws://127.0.0.1:9341/devtools/page/target-1", + }; + assert.equal(injector.isValidCdpPageTarget(target, 9341), true); + assert.equal(injector.isValidCdpPageTarget({ + ...target, + url: "app://-/index.html?initialRoute=%2Favatar-overlay", + }, 9341), false); +}); + +test("a failed CDP domain handshake closes the just-opened socket", async () => { + assert.equal(typeof injector.CdpSession, "function"); + const previousWebSocket = globalThis.WebSocket; + let socket = null; + class FakeWebSocket { + constructor() { + socket = this; + this.closed = false; + this.listeners = new Map(); + queueMicrotask(() => this.emit("open")); + } + addEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + emit(type, event = {}) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + close() { + this.closed = true; + this.emit("close"); + } + } + globalThis.WebSocket = FakeWebSocket; + try { + const session = new injector.CdpSession({ + id: "target-1", + webSocketDebuggerUrl: "ws://127.0.0.1:9341/devtools/page/target-1", + }, 9341); + session.send = async () => { throw new Error("Runtime.enable timed out"); }; + await assert.rejects(session.open(), /Runtime\.enable timed out/); + assert.equal(socket.closed, true); + assert.equal(session.closed, true); + } finally { + globalThis.WebSocket = previousWebSocket; + } +}); const discoveryStart = source.indexOf("record.earlyScriptId = await registerEarly"); const probeStart = source.indexOf("const probe = await waitForCodexProbe", discoveryStart); @@ -81,5 +241,3 @@ assert.match( /const suggestionLabelColorsMatch = visibleSuggestionLabels\.every\([\s\S]{0,2500}visibleSuggestionLabels\.length >= result\.visibleCardCount[\s\S]{0,160}result\.suggestionLabelColorsMatch/, "Live verification must reject visible home suggestion labels that diverge from the themed card color.", ); - -console.log("PASS: early injection is shell-guarded, generation-safe, and removed on shutdown."); diff --git a/macos/tests/renderer-inject.test.mjs b/macos/tests/renderer-inject.test.mjs index 7780a89f..ed1443b9 100644 --- a/macos/tests/renderer-inject.test.mjs +++ b/macos/tests/renderer-inject.test.mjs @@ -85,9 +85,49 @@ assert.match( /\[class\*="_homeUtilityBar_"\][\s\S]{0,500}dream-skin-home-utility/, "The renderer should give the current native home utility bar a stable theme class.", ); +assert.match( + template, + /\[class~="group\/home-suggestions"\]/, + "The renderer should select the slash-containing native class without fragile CSS escaping.", +); +assert.match( + css, + /data-dream-skin-question\]::before\s*\{[\s\S]{0,120}content:\s*attr\(data-dream-skin-question\);/, + "The compact question should render from the localized DOM attribute instead of hard-coded visible markup.", +); +assert.match( + css, + /data-dream-skin-home-grid\]\s*\{[\s\S]{0,160}grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\) !important;/, + "The marked suggestion grid should use two columns on desktop.", +); +assert.match( + css, + /\[class~="absolute"\]\[class~="top-full"\]:has\(> \[data-dream-skin-generated-home-suggestions\]\)[\s\S]{0,260}width:\s*calc\(100% - 52px\) !important;[\s\S]{0,160}transform:\s*translateX\(-50%\) !important;/, + "Current-renderer fallback suggestions should align with the composer without changing legacy layout.", +); +assert.match( + css, + /group\\\/home-suggestions button\s*\{[\s\S]{0,160}min-height:\s*72px !important;/, + "Desktop home suggestions should not expand into oversized feature cards.", +); +assert.match( + css, + /data-dream-skin-home-grid\]\s*>\s*\*\s*\{[\s\S]{0,80}display:\s*block !important;/, + "All four native suggestions must remain visible despite Codex container-query hiding rules.", +); assert.match( css, - /\.dream-skin-home:has\(\.dream-skin-home-utility\)[\s\S]{0,120}\.composer-surface-chrome\s*\{[\s\S]{0,180}border-radius:\s*0 0 22px 22px !important;/, + /data-dream-skin-native-home-card="hidden"\][\s\S]{0,80}display:\s*none !important;/, + "Native suggestions outside the fixed four-card set should not displace the requested actions.", +); +assert.match( + css, + /@media \(max-width:\s*700px\)[\s\S]{0,1400}data-dream-skin-home-grid\]\s*\{[\s\S]{0,100}grid-template-columns:\s*minmax\(0,\s*1fr\) !important;/, + "The four-action grid must collapse to one column on narrow windows.", +); +assert.match( + css, + /\.dream-skin-home:has\(\.dream-skin-home-utility\)[\s\S]{0,120}\.composer-surface-chrome\s*\{[\s\S]{0,180}border-radius:\s*0 0 16px 16px !important;/, "The home utility bar and composer should render as one continuous control.", ); assert.match( @@ -130,6 +170,16 @@ assert.match( /\[class~="bg-token-main-surface-primary"\]\[class~="h-full"\]\[class~="w-full"\][\s\S]{0,100}background:\s*transparent !important;/, "Full-size utility route wrappers should not hide the selected artwork.", ); +assert.match( + css, + /\[class~="top-\(--thread-floating-content-top-inset\)"\]\[class~="right-0"\]\[class~="z-40"\][\s\S]{0,220}\[class~="rounded-3xl"\]\[class~="bg-token-dropdown-background"\]\s*\{[\s\S]{0,220}background:\s*var\(--ds-immersive-composer\) !important;[\s\S]{0,220}backdrop-filter:\s*blur\(16px\) saturate\(108%\) !important;/, + "The native thread detail panel should reveal wide task artwork through a blurred theme surface.", +); +assert.match( + css, + /\[class~="rounded-3xl"\]\[class~="bg-token-dropdown-background"\][\s\S]{0,220}header\[class~="bg-token-dropdown-background"\]\s*\{[\s\S]{0,180}background:\s*transparent !important;[\s\S]{0,120}backdrop-filter:\s*none !important;/, + "Sticky thread detail headings should inherit the panel glass instead of adding dark overlay strips.", +); function createStyleDeclaration() { const values = new Map(); @@ -141,11 +191,14 @@ function createStyleDeclaration() { }; } -function createClassList(initial = []) { +function createClassList(initial = [], onAdd = null) { const values = new Set(initial); return { values, - add(...names) { for (const name of names) values.add(name); }, + add(...names) { + onAdd?.(names); + for (const name of names) values.add(name); + }, remove(...names) { for (const name of names) values.delete(name); }, contains(name) { return values.has(name); }, toggle(name, enabled) { @@ -159,6 +212,10 @@ function createFixture(theme, { nativeShell = "light", analysisFixture = null, analysisCache = null, + homeQuestionText = null, + nativeSuggestionLabels = [], + homeMarkup = "legacy", + currentSuggestionShell = "none", } = {}) { let fixtureShell = nativeShell; const nodes = new Map(); @@ -170,9 +227,10 @@ function createFixture(theme, { let nextTimer = 1; let nextBlob = 1; const rootStyle = createStyleDeclaration(); + const rootClassWrites = { addCalls: 0 }; const root = { className: nativeShell === "dark" ? "electron-dark" : "electron-light", - classList: createClassList(), + classList: createClassList([], () => { rootClassWrites.addCalls += 1; }), style: rootStyle, appendChild(node) { node.parentElement = root; @@ -198,38 +256,170 @@ function createFixture(theme, { return { ...shellBox }; }, }; - - const createElement = (tagName) => { - if (tagName === "canvas" && analysisFixture) { - return { - width: 0, - height: 0, - getContext() { - return { - drawImage() {}, - getImageData() { return { data: analysisFixture.pixels }; }, - }; - }, - }; - } + const createBasicElement = (tagName) => { const childNodes = new Map(); + const elementAttributes = new Map(); + const listeners = new Map(); const element = { + tagName: String(tagName).toUpperCase(), id: "", dataset: {}, style: createStyleDeclaration(), classList: createClassList(), parentElement: null, + children: [], textContent: "", + innerText: "", innerHTML: "", - setAttribute() {}, + get nextElementSibling() { + const siblings = element.parentElement?.children ?? []; + const index = siblings.indexOf(element); + return index >= 0 ? siblings[index + 1] ?? null : null; + }, + setAttribute(name, value) { elementAttributes.set(name, String(value)); }, + getAttribute(name) { return elementAttributes.get(name) ?? null; }, + removeAttribute(name) { elementAttributes.delete(name); }, + appendChild(node) { + node.parentElement = element; + element.children.push(node); + return node; + }, + contains(node) { + return node === element || element.children.some((child) => child.contains?.(node)); + }, + addEventListener(type, listener) { listeners.set(type, listener); }, + click() { listeners.get("click")?.({ preventDefault() {} }); }, querySelector(selector) { + if (/^\[[^\]]+\]$/.test(selector)) return element.querySelectorAll(selector)[0] ?? null; if (!childNodes.has(selector)) childNodes.set(selector, { textContent: "" }); return childNodes.get(selector); }, - remove() { if (element.id) nodes.delete(element.id); }, + querySelectorAll(selector) { + const attr = /^\[([^\]=]+)\]$/.exec(selector)?.[1] ?? null; + const classToken = /^\[class~="([^"]+)"\]$/.exec(selector)?.[1] ?? null; + const matches = (node) => selector === "button" + ? node.tagName === "BUTTON" + : attr !== null + ? node.getAttribute?.(attr) !== null + : classToken !== null && node.classList?.contains(classToken); + const result = []; + const visit = (node) => { + if (matches(node)) result.push(node); + for (const child of node.children ?? []) visit(child); + }; + for (const child of element.children) visit(child); + return result; + }, + remove() { + if (element.id) nodes.delete(element.id); + if (element.parentElement?.children) { + const index = element.parentElement.children.indexOf(element); + if (index >= 0) element.parentElement.children.splice(index, 1); + } + element.parentElement = null; + }, }; return element; }; + const homeQuestionAttributes = new Map(); + const homeQuestion = homeQuestionText === null ? null : createBasicElement("div"); + if (homeQuestion) { + homeQuestion.textContent = homeQuestionText; + homeQuestion.getAttribute = (name) => homeQuestionAttributes.get(name) ?? null; + homeQuestion.setAttribute = (name, value) => homeQuestionAttributes.set(name, String(value)); + homeQuestion.removeAttribute = (name) => homeQuestionAttributes.delete(name); + homeQuestion.cloneNode = () => ({ + textContent: homeQuestionText, + querySelectorAll(selector) { + return selector === "button" ? [{ remove() {} }] : []; + }, + }); + } + const suggestionGroup = createBasicElement("section"); + const suggestionContainer = createBasicElement("div"); + const suggestionGrid = createBasicElement("div"); + suggestionGroup.appendChild(suggestionContainer); + suggestionContainer.appendChild(suggestionGrid); + for (const label of nativeSuggestionLabels) { + const wrapper = createBasicElement("div"); + const button = createBasicElement("button"); + button.textContent = label; + button.innerText = label; + wrapper.appendChild(button); + suggestionGrid.appendChild(wrapper); + } + const currentHomeHero = createBasicElement("div"); + const currentSuggestionPortal = createBasicElement("div"); + currentSuggestionPortal.classList.add("absolute", "top-full"); + const currentNativeSuggestionGroup = createBasicElement("section"); + currentNativeSuggestionGroup.classList.add("group/home-suggestions"); + const currentNativeSuggestionHeader = createBasicElement("div"); + currentNativeSuggestionHeader.classList.add("group/home-suggestions-header"); + const currentNativeHeaderButton = createBasicElement("button"); + currentNativeHeaderButton.textContent = "Dismiss suggestions"; + currentNativeSuggestionHeader.appendChild(currentNativeHeaderButton); + currentNativeSuggestionGroup.appendChild(currentNativeSuggestionHeader); + const currentNativeSuggestionGrid = createBasicElement("div"); + currentNativeSuggestionGrid.classList.add("grid"); + if (currentSuggestionShell === "grid") { + const motion = createBasicElement("div"); + motion.appendChild(currentNativeSuggestionGrid); + currentNativeSuggestionGroup.appendChild(motion); + for (const label of nativeSuggestionLabels) { + const wrapper = createBasicElement("div"); + const button = createBasicElement("button"); + button.textContent = label; + button.innerText = label; + wrapper.appendChild(button); + currentNativeSuggestionGrid.appendChild(wrapper); + } + } + if (homeQuestion && homeMarkup === "current") { + currentHomeHero.appendChild(homeQuestion); + currentHomeHero.appendChild(currentSuggestionPortal); + if (currentSuggestionShell !== "none") { + currentSuggestionPortal.appendChild(currentNativeSuggestionGroup); + } + } + const home = homeQuestion ? { + classList: createClassList(), + querySelector(selector) { + if (selector === '[data-feature="game-source"]') return homeQuestion; + return null; + }, + querySelectorAll(selector) { + if (selector === '[class~="group/home-suggestions"]') { + return homeMarkup === "legacy" + ? [suggestionGroup] + : currentSuggestionPortal.querySelectorAll(selector); + } + return []; + }, + } : null; + const homeIndicator = home && homeMarkup === "legacy" ? { + closest(selector) { return selector === '[role="main"]' ? home : null; }, + } : null; + + const createElement = (tagName) => { + if (tagName === "canvas" && analysisFixture) { + return { + width: 0, + height: 0, + getContext() { + return { + drawImage() {}, + getImageData() { return { data: analysisFixture.pixels }; }, + }; + }, + }; + } + return createBasicElement(tagName); + }; + + const composer = createBasicElement("div"); + composer.focusCount = 0; + composer.focus = () => { composer.focusCount += 1; }; + const composerWrites = []; const document = { documentElement: root, @@ -239,9 +429,49 @@ function createFixture(theme, { getElementById(id) { return nodes.get(id) ?? null; }, querySelector(selector) { if (selector === "main.main-surface" || selector === "main") return shellMain; + if (selector === '[data-testid="home-icon"]') return homeIndicator; + if (selector === '.ProseMirror[contenteditable="true"]') return composer; return null; }, - querySelectorAll() { return []; }, + querySelectorAll(selector) { + if (selector === '[role="main"]') return home ? [home] : []; + if (selector === '[role="main"].dream-skin-home' || selector === ".dream-skin-home") { + return home?.classList.contains("dream-skin-home") ? [home] : []; + } + if (selector === "[data-dream-skin-question]") { + return homeQuestionAttributes.has("data-dream-skin-question") ? [homeQuestion] : []; + } + if (selector === "[data-dream-skin-home-card]" || + selector === "[data-dream-skin-native-home-card]") { + return homeMarkup === "legacy" + ? suggestionGroup.querySelectorAll(selector) + : currentSuggestionPortal.querySelectorAll(selector); + } + if (selector === "[data-dream-skin-home-suggestions]") { + if (homeMarkup === "legacy") { + return suggestionGroup.getAttribute("data-dream-skin-home-suggestions") !== null + ? [suggestionGroup] : []; + } + return currentSuggestionPortal.querySelectorAll(selector); + } + if (selector === "[data-dream-skin-generated-home-suggestions]") { + return currentSuggestionPortal.querySelectorAll(selector); + } + if (selector === "[data-dream-skin-empty-home-suggestions]") { + return currentSuggestionPortal.querySelectorAll(selector); + } + if (selector === ".dream-skin-home-shell") { + return shellMain.classList.contains("dream-skin-home-shell") ? [shellMain] : []; + } + return []; + }, + execCommand(command, _showUi, value) { + composerWrites.push({ command, value }); + if (command !== "insertText") return false; + composer.textContent = value; + composer.innerText = value; + return true; + }, }; const mediaQuery = { matches: false, @@ -331,9 +561,12 @@ function createFixture(theme, { return { attributes, body, - bodyAttributes, + composer, + composerWrites, context, flushTimers, + home, + homeQuestionAttributes, nodes, observers, payload: payloadFor(theme), @@ -341,8 +574,15 @@ function createFixture(theme, { revokedUrls, resizeObservers, root, + rootClassWrites, rootStyle, shellBox, + suggestionGrid, + suggestionGroup, + currentSuggestionPortal, + currentNativeHeaderButton, + currentNativeSuggestionGrid, + currentNativeSuggestionGroup, timers, window, setNativeShell(value) { fixtureShell = value; }, @@ -365,10 +605,22 @@ const defaultMetrics = defaults.window.__CODEX_DREAM_SKIN_STATE__.metrics; assert.equal(defaultMetrics.rootPasses, 1); assert.equal(defaultMetrics.routePasses, 1); assert.equal(defaultMetrics.layoutReads, 1); +assert.equal(defaults.rootClassWrites.addCalls, 1); +defaults.window.__CODEX_DREAM_SKIN_STATE__.ensure({ root: true, route: false, layout: false }); +assert.equal( + defaults.rootClassWrites.addCalls, + 1, + "A repeated root pass must not rewrite the observed skin class and retrigger itself.", +); +const rootPassesBeforeSubtreeMutations = defaultMetrics.rootPasses; for (let index = 0; index < 50; index += 1) defaults.observers[0].callback([]); assert.equal(defaults.timers.size, 1, "Mutation bursts should coalesce into one scheduled ensure."); defaults.flushTimers(64); -assert.equal(defaultMetrics.rootPasses, 1, "Subtree mutations must not recompute root theme tokens."); +assert.equal( + defaultMetrics.rootPasses, + rootPassesBeforeSubtreeMutations, + "Subtree mutations must not recompute root theme tokens.", +); assert.equal(defaultMetrics.routePasses, 2); assert.equal(defaultMetrics.layoutReads, 1, "Subtree mutations must not force shell layout reads."); assert.equal(defaults.resizeObservers.length, 1); @@ -382,6 +634,226 @@ const defaultChrome = defaults.nodes.get("codex-dream-skin-chrome"); assert.equal(defaultChrome.style.values.get("left"), "196px"); assert.equal(defaultChrome.style.values.get("width"), "1084px"); +const homeTheme = { + id: "home-question-contract", + appearance: "auto", + art: { safeArea: "auto", taskMode: "auto" }, +}; +const chineseHome = createFixture(homeTheme, { + homeQuestionText: "我们应该在 中构建什么?", + nativeSuggestionLabels: ["探索并理解代码", "构建新功能、应用或工具", "审查代码并提出修改建议"], +}); +vm.runInNewContext(chineseHome.payload, chineseHome.context); +assert.equal( + chineseHome.homeQuestionAttributes.get("data-dream-skin-question"), + "我们应该构建什么?", + "The Chinese home question should be compacted after excluding the native project button.", +); +assert.equal(chineseHome.home.classList.contains("dream-skin-home"), true); +assert.equal(chineseHome.suggestionGroup.getAttribute("data-dream-skin-home-suggestions"), "zh"); +assert.deepEqual( + chineseHome.suggestionGrid.children.slice(0, 3) + .map((wrapper) => wrapper.getAttribute("data-dream-skin-native-home-card")), + ["explore", "build", "review"], +); +let syntheticCards = chineseHome.suggestionGroup.querySelectorAll("[data-dream-skin-home-card]"); +assert.equal(syntheticCards.length, 1); +assert.equal(syntheticCards[0].getAttribute("data-dream-skin-home-card"), "fix"); +chineseHome.window.__CODEX_DREAM_SKIN_STATE__.ensure({ root: false, route: true }); +assert.equal( + chineseHome.suggestionGroup.querySelectorAll("[data-dream-skin-home-card]").length, + 1, + "Repeated synchronization must not duplicate the fallback fourth card.", +); +syntheticCards[0].remove(); +chineseHome.window.__CODEX_DREAM_SKIN_STATE__.ensure({ root: false, route: true }); +syntheticCards = chineseHome.suggestionGroup.querySelectorAll("[data-dream-skin-home-card]"); +assert.equal(syntheticCards.length, 1, "A React rerender must not permanently remove the fourth card."); +syntheticCards[0].querySelectorAll("button")[0].click(); +assert.equal(chineseHome.composer.focusCount, 1); +assert.deepEqual(chineseHome.composerWrites, [{ + command: "insertText", + value: "定位并修复这个项目中的问题和失败。", +}]); + +const currentChineseHome = createFixture(homeTheme, { + homeMarkup: "current", + homeQuestionText: "我们应该在 中做些什么?", +}); +vm.runInNewContext(currentChineseHome.payload, currentChineseHome.context); +assert.equal( + currentChineseHome.home.classList.contains("dream-skin-home"), + true, + "The current Codex home route must be recognized without the retired native suggestion group.", +); +assert.equal( + currentChineseHome.homeQuestionAttributes.get("data-dream-skin-question"), + "我们应该做些什么?", + "The current Chinese home question should keep its native wording while moving the project selector.", +); +const currentChineseSuggestions = currentChineseHome.currentSuggestionPortal + .querySelector("[data-dream-skin-home-suggestions]"); +assert.ok( + currentChineseSuggestions, + "An empty current Codex suggestion portal should receive the Dream Skin fallback group.", +); +assert.equal( + currentChineseSuggestions.querySelectorAll("[data-dream-skin-home-card]").length, + 4, + "The current Codex home route should always receive the complete four-card set.", +); +currentChineseHome.window.__CODEX_DREAM_SKIN_STATE__.ensure({ root: false, route: true }); +assert.equal( + currentChineseHome.currentSuggestionPortal + .querySelectorAll("[data-dream-skin-home-card]").length, + 4, + "Repeated synchronization must not duplicate current-renderer fallback cards.", +); + +const currentHeaderOnlyHome = createFixture(homeTheme, { + homeMarkup: "current", + homeQuestionText: "我们应该在 中做些什么?", + currentSuggestionShell: "header-only", +}); +vm.runInNewContext(currentHeaderOnlyHome.payload, currentHeaderOnlyHome.context); +const headerOnlyGeneratedGroup = currentHeaderOnlyHome.currentSuggestionPortal + .querySelector("[data-dream-skin-generated-home-suggestions]"); +assert.ok( + headerOnlyGeneratedGroup, + "A native header without a card grid must not replace the generated four-card group.", +); +assert.equal( + currentHeaderOnlyHome.currentNativeSuggestionGroup + .getAttribute("data-dream-skin-empty-home-suggestions"), + "", + "An unusable native shell must stay hidden while the fallback grid is active.", +); +assert.equal( + headerOnlyGeneratedGroup.querySelectorAll("[data-dream-skin-home-card]").length, + 4, +); +assert.equal( + currentHeaderOnlyHome.currentNativeHeaderButton + .getAttribute("data-dream-skin-native-home-card"), + null, + "Native suggestion controls must never be classified as home cards.", +); + +const currentNativeHome = createFixture(homeTheme, { + homeMarkup: "current", + homeQuestionText: "我们应该在 中做些什么?", + currentSuggestionShell: "grid", + nativeSuggestionLabels: ["探索并理解代码"], +}); +vm.runInNewContext(currentNativeHome.payload, currentNativeHome.context); +assert.equal( + currentNativeHome.currentNativeHeaderButton.getAttribute("data-dream-skin-native-home-card"), + null, +); +assert.equal( + currentNativeHome.currentNativeSuggestionGrid + .querySelectorAll("[data-dream-skin-home-card]").length, + 3, + "A current native grid should preserve matching React cards and fill only the missing actions.", +); +assert.equal( + currentNativeHome.currentNativeSuggestionGrid.children[0] + .getAttribute("data-dream-skin-native-home-card"), + "explore", +); +assert.equal( + currentNativeHome.currentSuggestionPortal + .querySelector("[data-dream-skin-generated-home-suggestions]"), + null, + "A usable current native grid should supersede the generated group.", +); +assert.equal( + currentNativeHome.currentNativeSuggestionGroup + .getAttribute("data-dream-skin-empty-home-suggestions"), + null, +); + +const currentEnglishHome = createFixture(homeTheme, { + homeMarkup: "current", + homeQuestionText: "What should we work on in ", +}); +vm.runInNewContext(currentEnglishHome.payload, currentEnglishHome.context); +assert.equal( + currentEnglishHome.homeQuestionAttributes.get("data-dream-skin-question"), + "What should we work on?", + "The current English home question should retain the updated work-on wording.", +); +assert.equal( + currentEnglishHome.currentSuggestionPortal + .querySelectorAll("[data-dream-skin-home-card]").length, + 4, +); + +const currentUnsupportedHome = createFixture(homeTheme, { + homeMarkup: "current", + homeQuestionText: "¿En qué deberíamos trabajar?", +}); +vm.runInNewContext(currentUnsupportedHome.payload, currentUnsupportedHome.context); +assert.equal(currentUnsupportedHome.home.classList.contains("dream-skin-home"), true); +assert.equal(currentUnsupportedHome.homeQuestionAttributes.has("data-dream-skin-question"), false); +assert.equal( + currentUnsupportedHome.currentSuggestionPortal + .querySelector("[data-dream-skin-generated-home-suggestions]"), + null, + "Unknown current locales must preserve the native empty state without English fallback cards.", +); +assert.equal(currentChineseHome.window.__CODEX_DREAM_SKIN_STATE__.cleanup(), true); +assert.equal( + currentChineseHome.currentSuggestionPortal + .querySelector("[data-dream-skin-generated-home-suggestions]"), + null, + "Cleanup must remove the generated current-renderer suggestion group.", +); + +const englishHome = createFixture(homeTheme, { + homeQuestionText: "What should we build in ?", + nativeSuggestionLabels: [ + "Explore and understand the code", + "Build a new feature, app, or tool", + "Review code and suggest improvements", + "Fix issues and failures", + ], +}); +vm.runInNewContext(englishHome.payload, englishHome.context); +assert.equal( + englishHome.homeQuestionAttributes.get("data-dream-skin-question"), + "What should we build?", + "The English home question should be compacted after excluding the native project button.", +); +assert.equal( + englishHome.suggestionGroup.querySelectorAll("[data-dream-skin-home-card]").length, + 0, + "Four matching native English suggestions should keep their original React click handlers.", +); + +const unsupportedHome = createFixture(homeTheme, { + homeQuestionText: "¿Qué deberíamos construir en ?", + nativeSuggestionLabels: ["Explorar el código"], +}); +vm.runInNewContext(unsupportedHome.payload, unsupportedHome.context); +assert.equal( + unsupportedHome.homeQuestionAttributes.has("data-dream-skin-question"), + false, + "Unknown locales must keep the complete native question instead of rendering text with the project name removed.", +); +assert.equal(unsupportedHome.suggestionGroup.getAttribute("data-dream-skin-home-suggestions"), null); +assert.equal( + unsupportedHome.suggestionGrid.children[0].getAttribute("data-dream-skin-native-home-card"), + null, + "Unknown locales must preserve native suggestions without filtering them.", +); +assert.equal(chineseHome.window.__CODEX_DREAM_SKIN_STATE__.cleanup(), true); +assert.equal(chineseHome.suggestionGroup.querySelectorAll("[data-dream-skin-home-card]").length, 0); +assert.equal( + chineseHome.suggestionGrid.children[0].getAttribute("data-dream-skin-native-home-card"), + null, +); + // Auto appearance must continue following the native shell after the skin is // already installed. The fixture makes the injected root color-scheme win // whenever our class remains on , so a temporary native probe is needed