diff --git a/macos/CHANGELOG.md b/macos/CHANGELOG.md index 7b5315d7..c0a3b18d 100644 --- a/macos/CHANGELOG.md +++ b/macos/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### 改进 + +- 路由可读性层不再等待 180ms 后才更新。统一 runtime 在 Navigation API 事件同一轮写入 `data-dream-route-phase`,下一动画帧按选择器契约复测 scope,并以 380ms 曲线驱动单一继承进度;侧栏与主区使用同一数值叠加任务页 veil,避免两列透明度不同步或梯度瞬间跳变。实现不监听 DOM subtree、不写业务 class、不读布局,并遵守 reduced-motion。 + ## 1.3.3 — 2026-07-23 ### 修复 diff --git a/macos/assets/dream-skin.css b/macos/assets/dream-skin.css index d020d73a..a6843dbf 100644 --- a/macos/assets/dream-skin.css +++ b/macos/assets/dream-skin.css @@ -1,3 +1,9 @@ +@property --ds-route-progress { + syntax: ""; + inherits: true; + initial-value: 1; +} + /* Canonical cross-platform skin. Run tools/sync-runtime-assets.mjs after editing. */ :root[data-dream-skin="active"] { color-scheme: dark !important; @@ -56,6 +62,14 @@ --ds-task-immersive-edge: rgb(var(--ds-bg-rgb) / .82); --ds-task-immersive-mid: rgb(var(--ds-bg-rgb) / .74); --ds-task-immersive-far: rgb(var(--ds-bg-rgb) / .60); + --ds-task-sidebar-overlay: rgb(var(--ds-panel-rgb) / .44); + --ds-task-sidebar-edge-overlay: rgb(var(--ds-bg-rgb) / .70); + --ds-task-overlay-edge: rgb(var(--ds-bg-rgb) / .70); + --ds-task-overlay-mid: rgb(var(--ds-bg-rgb) / .65); + --ds-task-overlay-far: rgb(var(--ds-bg-rgb) / .52); + --ds-route-progress: 1; + --ds-route-transition: 380ms cubic-bezier(.25, .32, .25, 1); + transition: --ds-route-progress var(--ds-route-transition); } html[data-dream-skin="active"][data-dream-shell="light"] { @@ -101,6 +115,23 @@ html[data-dream-skin="active"][data-dream-shell="light"] { --ds-task-immersive-edge: rgb(var(--ds-panel-rgb) / .86); --ds-task-immersive-mid: rgb(var(--ds-panel-rgb) / .78); --ds-task-immersive-far: rgb(var(--ds-panel-rgb) / .66); + --ds-task-sidebar-overlay: rgb(var(--ds-panel-rgb) / .46); + --ds-task-sidebar-edge-overlay: rgb(var(--ds-panel-rgb) / .75); + --ds-task-overlay-edge: rgb(var(--ds-panel-rgb) / .75); + --ds-task-overlay-mid: rgb(var(--ds-panel-rgb) / .69); + --ds-task-overlay-far: rgb(var(--ds-panel-rgb) / .60); +} + +/* Navigation begins at the click/navigation event, not after route DOM settles. + The renderer advances to "settling" on the next animation frame so Chromium + can interpolate one inherited value without layout reads or subtree watches. */ +html[data-dream-skin="active"][data-dream-route-phase="prepare"] { + --ds-route-progress: 0; + transition: none !important; +} + +html[data-dream-skin="active"][data-dream-route-phase="settling"] { + --ds-route-progress: 1; } html[data-dream-skin="active"]:is([data-dream-art-safe="left"], [data-dream-art-safe-area="left"]) { @@ -316,12 +347,35 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) aside.app-shell-left-panel { background: linear-gradient(90deg, - var(--ds-task-immersive-sidebar), - var(--ds-task-immersive-edge) 100%) !important; + var(--ds-immersive-sidebar), + var(--ds-immersive-edge) 100%) !important; border: 0 !important; border-radius: 0 !important; box-shadow: none !important; backdrop-filter: none !important; + isolation: isolate; +} + +/* The sidebar and main surface share this inherited progress value. Keeping the + stronger task wash in separate overlays avoids a discrete gradient swap. */ +html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) + aside.app-shell-left-panel::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: linear-gradient(90deg, + var(--ds-task-sidebar-overlay), + var(--ds-task-sidebar-edge-overlay)); + opacity: var(--ds-route-progress); + will-change: opacity; +} + +html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) + aside.app-shell-left-panel > :first-child { + position: relative; + z-index: 1; } /* Native sidebar resize chrome inherits the sidebar background and extends @@ -334,9 +388,9 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) main.main-surface:not(:has([role="main"])) { background: linear-gradient(90deg, - var(--ds-task-immersive-edge), - var(--ds-task-immersive-mid) 64%, - var(--ds-task-immersive-far)) !important; + var(--ds-immersive-edge), + var(--ds-immersive-mid) 64%, + var(--ds-immersive-far)) !important; border: 0 !important; border-radius: 0 !important; box-shadow: none !important; @@ -344,7 +398,17 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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(:has([role="main"]))::before { - content: none; + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: linear-gradient(90deg, + var(--ds-task-overlay-edge), + var(--ds-task-overlay-mid) 64%, + var(--ds-task-overlay-far)); + opacity: var(--ds-route-progress); + will-change: opacity; } html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) @@ -1084,6 +1148,10 @@ html[data-dream-skin="active"] [data-message-author-role] { border-radius: 16px; } @media (prefers-reduced-motion: reduce) { + :root[data-dream-skin="active"] { + --ds-route-transition: 0ms linear; + } + html[data-dream-skin="active"] *, html[data-dream-skin="active"] *::before, html[data-dream-skin="active"] *::after { diff --git a/macos/assets/renderer-inject.js b/macos/assets/renderer-inject.js index bf2bb1cb..99a76df2 100644 --- a/macos/assets/renderer-inject.js +++ b/macos/assets/renderer-inject.js @@ -10,8 +10,9 @@ "data-dream-skin", SHELL_ATTR, "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", - "data-dream-art-ready", + "data-dream-art-ready", "data-dream-route-phase", ]; + const ROUTE_TRANSITION_MS = 380; const VERSION = __DREAM_SKIN_VERSION_JSON__; const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__; const PAYLOAD_REVISION = __DREAM_SKIN_PAYLOAD_REVISION_JSON__; @@ -58,6 +59,7 @@ styleWrites: 0, styleRepairs: 0, navigationEvents: 0, + routeTransitions: 0, safetyPasses: 0, analysisRuns: 0, analysisCacheHits: artAnalysis ? 1 : 0, @@ -586,6 +588,16 @@ } if (state?.timer) clearInterval(state.timer); if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout); + if (state?.routeTransition?.frame !== null) { + if (typeof window.cancelAnimationFrame === "function") { + window.cancelAnimationFrame(state.routeTransition.frame); + } else { + clearTimeout(state.routeTransition.frame); + } + } + if (state?.routeTransition?.timeout !== null) { + clearTimeout(state.routeTransition.timeout); + } if (analysisTimer) clearTimeout(analysisTimer); if (state?.mediaHandler && state?.mediaQuery) { try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {} @@ -623,6 +635,37 @@ if (scheduler.timeout) return; scheduler.timeout = setTimeout(flushScheduledEnsure, delay); }; + const routeTransition = { frame: null, timeout: null }; + const requestRouteFrame = (callback) => typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame(callback) + : setTimeout(callback, 0); + const cancelRouteFrame = (frame) => { + if (frame === null) return; + if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(frame); + else clearTimeout(frame); + }; + const beginRouteTransition = () => { + const root = document.documentElement; + if (!root) return; + cancelRouteFrame(routeTransition.frame); + routeTransition.frame = null; + if (routeTransition.timeout !== null) clearTimeout(routeTransition.timeout); + routeTransition.timeout = null; + setAttribute(root, "data-dream-route-phase", "prepare"); + metrics.routeTransitions += 1; + routeTransition.frame = requestRouteFrame(() => { + routeTransition.frame = null; + if (window[DISABLED_KEY] || window[STATE_KEY]?.installToken !== installToken) return; + setAttribute(root, "data-dream-route-phase", "settling"); + ensure({ scope: true }); + routeTransition.timeout = setTimeout(() => { + routeTransition.timeout = null; + if (window[STATE_KEY]?.installToken === installToken) { + root.removeAttribute("data-dream-route-phase"); + } + }, ROUTE_TRANSITION_MS); + }); + }; if (typeof MutationObserver === "function") { rootObserver = new MutationObserver(() => scheduleEnsure({ root: true })); } @@ -638,7 +681,7 @@ ? window.navigation : null; const navigationHandler = navigationApi ? () => { metrics.navigationEvents += 1; - scheduleEnsure({ scope: true }, 180); + beginRouteTransition(); } : null; window[STATE_KEY] = { @@ -647,6 +690,7 @@ rootObserver, timer: null, scheduler, + routeTransition, mediaQuery, mediaHandler, navigation: navigationApi, @@ -666,6 +710,7 @@ themeId: THEME.id || "custom", revision: PAYLOAD_REVISION, detectShellAppearance, + beginRouteTransition, }; const firstEnsureStartedAt = now(); ensure({ root: true }); diff --git a/runtime/dream-skin.css b/runtime/dream-skin.css index 77e6d5d5..fabac57c 100644 --- a/runtime/dream-skin.css +++ b/runtime/dream-skin.css @@ -1,3 +1,9 @@ +@property --ds-route-progress { + syntax: ""; + inherits: true; + initial-value: 1; +} + /* Canonical cross-platform skin. Run tools/sync-runtime-assets.mjs after editing. */ :root[data-dream-skin="active"] { color-scheme: dark !important; @@ -56,6 +62,14 @@ --ds-task-immersive-edge: rgb(var(--ds-bg-rgb) / .82); --ds-task-immersive-mid: rgb(var(--ds-bg-rgb) / .74); --ds-task-immersive-far: rgb(var(--ds-bg-rgb) / .60); + --ds-task-sidebar-overlay: rgb(var(--ds-panel-rgb) / .44); + --ds-task-sidebar-edge-overlay: rgb(var(--ds-bg-rgb) / .70); + --ds-task-overlay-edge: rgb(var(--ds-bg-rgb) / .70); + --ds-task-overlay-mid: rgb(var(--ds-bg-rgb) / .65); + --ds-task-overlay-far: rgb(var(--ds-bg-rgb) / .52); + --ds-route-progress: 1; + --ds-route-transition: 380ms cubic-bezier(.25, .32, .25, 1); + transition: --ds-route-progress var(--ds-route-transition); } html[data-dream-skin="active"][data-dream-shell="light"] { @@ -101,6 +115,23 @@ html[data-dream-skin="active"][data-dream-shell="light"] { --ds-task-immersive-edge: rgb(var(--ds-panel-rgb) / .86); --ds-task-immersive-mid: rgb(var(--ds-panel-rgb) / .78); --ds-task-immersive-far: rgb(var(--ds-panel-rgb) / .66); + --ds-task-sidebar-overlay: rgb(var(--ds-panel-rgb) / .46); + --ds-task-sidebar-edge-overlay: rgb(var(--ds-panel-rgb) / .75); + --ds-task-overlay-edge: rgb(var(--ds-panel-rgb) / .75); + --ds-task-overlay-mid: rgb(var(--ds-panel-rgb) / .69); + --ds-task-overlay-far: rgb(var(--ds-panel-rgb) / .60); +} + +/* Navigation begins at the click/navigation event, not after route DOM settles. + The renderer advances to "settling" on the next animation frame so Chromium + can interpolate one inherited value without layout reads or subtree watches. */ +html[data-dream-skin="active"][data-dream-route-phase="prepare"] { + --ds-route-progress: 0; + transition: none !important; +} + +html[data-dream-skin="active"][data-dream-route-phase="settling"] { + --ds-route-progress: 1; } html[data-dream-skin="active"]:is([data-dream-art-safe="left"], [data-dream-art-safe-area="left"]) { @@ -316,12 +347,35 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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"]:has(__DREAM_SELECTOR_SHELL_MAIN__):not(:has(__DREAM_SELECTOR_SHELL_MAIN__ __DREAM_SELECTOR_HOME_ROUTE_CSS__)) __DREAM_SELECTOR_LEFT_PANEL__ { background: linear-gradient(90deg, - var(--ds-task-immersive-sidebar), - var(--ds-task-immersive-edge) 100%) !important; + var(--ds-immersive-sidebar), + var(--ds-immersive-edge) 100%) !important; border: 0 !important; border-radius: 0 !important; box-shadow: none !important; backdrop-filter: none !important; + isolation: isolate; +} + +/* The sidebar and main surface share this inherited progress value. Keeping the + stronger task wash in separate overlays avoids a discrete gradient swap. */ +html[data-dream-skin="active"]: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"]:has(__DREAM_SELECTOR_SHELL_MAIN__):not(:has(__DREAM_SELECTOR_SHELL_MAIN__ __DREAM_SELECTOR_HOME_ROUTE_CSS__)) + __DREAM_SELECTOR_LEFT_PANEL__::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: linear-gradient(90deg, + var(--ds-task-sidebar-overlay), + var(--ds-task-sidebar-edge-overlay)); + opacity: var(--ds-route-progress); + will-change: opacity; +} + +html[data-dream-skin="active"]: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"]:has(__DREAM_SELECTOR_SHELL_MAIN__):not(:has(__DREAM_SELECTOR_SHELL_MAIN__ __DREAM_SELECTOR_HOME_ROUTE_CSS__)) + __DREAM_SELECTOR_LEFT_PANEL__ > :first-child { + position: relative; + z-index: 1; } /* Native sidebar resize chrome inherits the sidebar background and extends @@ -334,9 +388,9 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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"]:has(__DREAM_SELECTOR_SHELL_MAIN__):not(:has(__DREAM_SELECTOR_SHELL_MAIN__ __DREAM_SELECTOR_HOME_ROUTE_CSS__)) __DREAM_SELECTOR_SHELL_MAIN__:not(:has(__DREAM_SELECTOR_HOME_ROUTE_CSS__)) { background: linear-gradient(90deg, - var(--ds-task-immersive-edge), - var(--ds-task-immersive-mid) 64%, - var(--ds-task-immersive-far)) !important; + var(--ds-immersive-edge), + var(--ds-immersive-mid) 64%, + var(--ds-immersive-far)) !important; border: 0 !important; border-radius: 0 !important; box-shadow: none !important; @@ -344,7 +398,17 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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"] __DREAM_SELECTOR_SHELL_MAIN__:not(:has(__DREAM_SELECTOR_HOME_ROUTE_CSS__))::before { - content: none; + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: linear-gradient(90deg, + var(--ds-task-overlay-edge), + var(--ds-task-overlay-mid) 64%, + var(--ds-task-overlay-far)); + opacity: var(--ds-route-progress); + will-change: opacity; } html[data-dream-skin="active"]: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"]:has(__DREAM_SELECTOR_SHELL_MAIN__):not(:has(__DREAM_SELECTOR_SHELL_MAIN__ __DREAM_SELECTOR_HOME_ROUTE_CSS__)) @@ -1084,6 +1148,10 @@ html[data-dream-skin="active"] [data-message-author-role] { border-radius: 16px; } @media (prefers-reduced-motion: reduce) { + :root[data-dream-skin="active"] { + --ds-route-transition: 0ms linear; + } + html[data-dream-skin="active"] *, html[data-dream-skin="active"] *::before, html[data-dream-skin="active"] *::after { diff --git a/runtime/renderer-inject.js b/runtime/renderer-inject.js index b0b868f4..8af964f0 100644 --- a/runtime/renderer-inject.js +++ b/runtime/renderer-inject.js @@ -10,8 +10,9 @@ "data-dream-skin", SHELL_ATTR, "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", - "data-dream-art-ready", + "data-dream-art-ready", "data-dream-route-phase", ]; + const ROUTE_TRANSITION_MS = 380; const VERSION = __DREAM_SKIN_VERSION_JSON__; const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__; const PAYLOAD_REVISION = __DREAM_SKIN_PAYLOAD_REVISION_JSON__; @@ -58,6 +59,7 @@ styleWrites: 0, styleRepairs: 0, navigationEvents: 0, + routeTransitions: 0, safetyPasses: 0, analysisRuns: 0, analysisCacheHits: artAnalysis ? 1 : 0, @@ -586,6 +588,16 @@ } if (state?.timer) clearInterval(state.timer); if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout); + if (state?.routeTransition?.frame !== null) { + if (typeof window.cancelAnimationFrame === "function") { + window.cancelAnimationFrame(state.routeTransition.frame); + } else { + clearTimeout(state.routeTransition.frame); + } + } + if (state?.routeTransition?.timeout !== null) { + clearTimeout(state.routeTransition.timeout); + } if (analysisTimer) clearTimeout(analysisTimer); if (state?.mediaHandler && state?.mediaQuery) { try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {} @@ -623,6 +635,37 @@ if (scheduler.timeout) return; scheduler.timeout = setTimeout(flushScheduledEnsure, delay); }; + const routeTransition = { frame: null, timeout: null }; + const requestRouteFrame = (callback) => typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame(callback) + : setTimeout(callback, 0); + const cancelRouteFrame = (frame) => { + if (frame === null) return; + if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(frame); + else clearTimeout(frame); + }; + const beginRouteTransition = () => { + const root = document.documentElement; + if (!root) return; + cancelRouteFrame(routeTransition.frame); + routeTransition.frame = null; + if (routeTransition.timeout !== null) clearTimeout(routeTransition.timeout); + routeTransition.timeout = null; + setAttribute(root, "data-dream-route-phase", "prepare"); + metrics.routeTransitions += 1; + routeTransition.frame = requestRouteFrame(() => { + routeTransition.frame = null; + if (window[DISABLED_KEY] || window[STATE_KEY]?.installToken !== installToken) return; + setAttribute(root, "data-dream-route-phase", "settling"); + ensure({ scope: true }); + routeTransition.timeout = setTimeout(() => { + routeTransition.timeout = null; + if (window[STATE_KEY]?.installToken === installToken) { + root.removeAttribute("data-dream-route-phase"); + } + }, ROUTE_TRANSITION_MS); + }); + }; if (typeof MutationObserver === "function") { rootObserver = new MutationObserver(() => scheduleEnsure({ root: true })); } @@ -638,7 +681,7 @@ ? window.navigation : null; const navigationHandler = navigationApi ? () => { metrics.navigationEvents += 1; - scheduleEnsure({ scope: true }, 180); + beginRouteTransition(); } : null; window[STATE_KEY] = { @@ -647,6 +690,7 @@ rootObserver, timer: null, scheduler, + routeTransition, mediaQuery, mediaHandler, navigation: navigationApi, @@ -666,6 +710,7 @@ themeId: THEME.id || "custom", revision: PAYLOAD_REVISION, detectShellAppearance, + beginRouteTransition, }; const firstEnsureStartedAt = now(); ensure({ root: true }); diff --git a/tools/renderer-runtime.test.mjs b/tools/renderer-runtime.test.mjs index d464b7bb..f02eb745 100644 --- a/tools/renderer-runtime.test.mjs +++ b/tools/renderer-runtime.test.mjs @@ -35,6 +35,7 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr const observers = []; const timers = new Map(); const intervals = new Map(); + const frames = new Map(); const listeners = new Map(); const revoked = []; let nextId = 0; @@ -91,6 +92,12 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr } const window = { navigation, + requestAnimationFrame(callback) { + const id = ++nextId; + frames.set(id, callback); + return id; + }, + cancelAnimationFrame(id) { frames.delete(id); }, matchMedia() { return { matches: nativeAppearance === "dark", @@ -135,8 +142,14 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr if (timer.delay <= maximumDelay) { timers.delete(id); timer.callback(); } } }; + const flushAnimationFrames = () => { + for (const [id, callback] of [...frames]) { + frames.delete(id); + callback(16); + } + }; return { - attrs, context, document, flushTimers, intervals, listeners, nodes, observers, + attrs, context, document, flushAnimationFrames, flushTimers, frames, intervals, listeners, nodes, observers, payloadFor, revoked, root, rootClasses, rootStyle, timers, window, }; } @@ -193,6 +206,13 @@ export async function runRendererRuntimeTest(assetRoot) { // and the measured fossil selector must be absent from the canonical CSS. assert.doesNotMatch(css, /(?:^|[.#\s])(?:codex-dream-skin|dream-skin-home|dream-home|dream-task)(?:[\s.#:{>]|$)|home-suggestion-list-item/); assert.match(css, /html\[data-dream-skin="active"\]/); + assert.match(css, /@property --ds-route-progress/); + assert.match(css, /\[data-dream-route-phase="prepare"\]/); + assert.match(css, /--ds-route-transition:\s*380ms cubic-bezier/); + assert.ok((css.match(/opacity:\s*var\(--ds-route-progress\)/g) || []).length >= 2, + "Sidebar and main route overlays must consume the same progress value."); + assert.doesNotMatch(template, /scheduleEnsure\(\{\s*scope:\s*true\s*\},\s*180\)/, + "Navigation must not retain the retired 180ms route debounce."); // Home gating must stay single-level: CSS forbids :has() inside :has(), // and Chromium drops any rule that nests it (the v1.3.1 regression). The // canonical CSS therefore gates on the :has()-free home-route-css alias. @@ -232,9 +252,17 @@ export async function runRendererRuntimeTest(assetRoot) { const navigationHandler = home.listeners.get("navigation:navigate"); assert.equal(typeof navigationHandler, "function"); navigationHandler(); - home.flushTimers(180); + assert.equal(home.attrs.get("data-dream-route-phase"), "prepare", + "Navigation must reset route progress in the same event turn."); + assert.equal(state.metrics.routePasses, 1, + "Scope measurement waits for the next animation frame, not an arbitrary debounce."); + home.flushAnimationFrames(); + assert.equal(home.attrs.get("data-dream-route-phase"), "settling"); assert.equal(state.metrics.navigationEvents, 1); + assert.equal(state.metrics.routeTransitions, 1); assert.equal(state.metrics.routePasses, 2); + home.flushTimers(380); + assert.equal(home.attrs.has("data-dream-route-phase"), false); const settings = makeFixture({ nativeAppearance: "light", settings: true }); vm.runInNewContext(settings.payloadFor(), settings.context); diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 5ec35427..992d77f9 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -1,5 +1,13 @@ # Windows Changelog +## Unreleased + +### 改进 + +- 托盘菜单改为更接近 Windows 11 / Codex 的轻量原生面板:主菜单和主题子菜单使用真正的圆角裁切、双层品牌/状态标题(运行绿、暂停棕、异常红)、紧凑 Segoe UI 字号、内容自适应宽度、统一 16px 文本栅格、柔和中性悬停、浅蓝灰分隔线和独立退出区,避免高 DPI 下字体、行距与右侧留白过度放大;源码运行缺少安装器 ICO 时会在内存中绘制同款品牌标记,不再退回无辨识度的系统应用图标。新增「停止 Dream Skin」与 DreamSkin Studio 入口,并由真实 WinForms 自测覆盖图标、渲染器和空菜单首项。 +- 启动脚本只在皮肤验证成功后补启一次托盘,并通过隐藏的 STA PowerShell 子进程托管通知区图标;启动脚本会立即正常结束,托盘互斥锁继续阻止重复后台实例。 +- 路由白色/深色可读性层不再等待 180ms 后才更新。统一 runtime 在 Navigation API 事件同一轮写入 `data-dream-route-phase`,下一动画帧按选择器契约复测 scope,并以 380ms 曲线驱动单一继承进度;左栏与主区使用同一数值叠加任务页 veil,避免两列透明度不同步或梯度瞬间跳变。实现仍不监听 DOM subtree、不写业务 class、不读布局,并遵守 reduced-motion。 + ## 1.3.3 — 2026-07-23 ### 修复 diff --git a/windows/README.en.md b/windows/README.en.md index 58df845b..1f9232bc 100644 --- a/windows/README.en.md +++ b/windows/README.en.md @@ -59,6 +59,7 @@ Exit the Dream Skin tray and close Codex, update the checkout (`git pull`, or do ## Launch and verify The `Codex Dream Skin` shortcut is the recommended launcher. It asks for confirmation before restarting an open Codex window. +After the skin verifies successfully, the launcher hands the tray controller to a hidden background process and exits; a mutex prevents duplicate tray instances. Command-line launch: diff --git a/windows/README.md b/windows/README.md index 8a63f69c..e5d8ffdb 100644 --- a/windows/README.md +++ b/windows/README.md @@ -52,6 +52,7 @@ powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\install- ## 启动与验证 推荐从 `Codex Dream Skin` 快捷方式启动。它发现 Codex 已经运行时会先询问是否重启。 +皮肤验证成功后,启动脚本会按需把托盘控制器交给隐藏的后台进程并立即结束;互斥锁会避免重复托盘。 命令行启动: diff --git a/windows/assets/dream-skin.css b/windows/assets/dream-skin.css index d020d73a..a6843dbf 100644 --- a/windows/assets/dream-skin.css +++ b/windows/assets/dream-skin.css @@ -1,3 +1,9 @@ +@property --ds-route-progress { + syntax: ""; + inherits: true; + initial-value: 1; +} + /* Canonical cross-platform skin. Run tools/sync-runtime-assets.mjs after editing. */ :root[data-dream-skin="active"] { color-scheme: dark !important; @@ -56,6 +62,14 @@ --ds-task-immersive-edge: rgb(var(--ds-bg-rgb) / .82); --ds-task-immersive-mid: rgb(var(--ds-bg-rgb) / .74); --ds-task-immersive-far: rgb(var(--ds-bg-rgb) / .60); + --ds-task-sidebar-overlay: rgb(var(--ds-panel-rgb) / .44); + --ds-task-sidebar-edge-overlay: rgb(var(--ds-bg-rgb) / .70); + --ds-task-overlay-edge: rgb(var(--ds-bg-rgb) / .70); + --ds-task-overlay-mid: rgb(var(--ds-bg-rgb) / .65); + --ds-task-overlay-far: rgb(var(--ds-bg-rgb) / .52); + --ds-route-progress: 1; + --ds-route-transition: 380ms cubic-bezier(.25, .32, .25, 1); + transition: --ds-route-progress var(--ds-route-transition); } html[data-dream-skin="active"][data-dream-shell="light"] { @@ -101,6 +115,23 @@ html[data-dream-skin="active"][data-dream-shell="light"] { --ds-task-immersive-edge: rgb(var(--ds-panel-rgb) / .86); --ds-task-immersive-mid: rgb(var(--ds-panel-rgb) / .78); --ds-task-immersive-far: rgb(var(--ds-panel-rgb) / .66); + --ds-task-sidebar-overlay: rgb(var(--ds-panel-rgb) / .46); + --ds-task-sidebar-edge-overlay: rgb(var(--ds-panel-rgb) / .75); + --ds-task-overlay-edge: rgb(var(--ds-panel-rgb) / .75); + --ds-task-overlay-mid: rgb(var(--ds-panel-rgb) / .69); + --ds-task-overlay-far: rgb(var(--ds-panel-rgb) / .60); +} + +/* Navigation begins at the click/navigation event, not after route DOM settles. + The renderer advances to "settling" on the next animation frame so Chromium + can interpolate one inherited value without layout reads or subtree watches. */ +html[data-dream-skin="active"][data-dream-route-phase="prepare"] { + --ds-route-progress: 0; + transition: none !important; +} + +html[data-dream-skin="active"][data-dream-route-phase="settling"] { + --ds-route-progress: 1; } html[data-dream-skin="active"]:is([data-dream-art-safe="left"], [data-dream-art-safe-area="left"]) { @@ -316,12 +347,35 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) aside.app-shell-left-panel { background: linear-gradient(90deg, - var(--ds-task-immersive-sidebar), - var(--ds-task-immersive-edge) 100%) !important; + var(--ds-immersive-sidebar), + var(--ds-immersive-edge) 100%) !important; border: 0 !important; border-radius: 0 !important; box-shadow: none !important; backdrop-filter: none !important; + isolation: isolate; +} + +/* The sidebar and main surface share this inherited progress value. Keeping the + stronger task wash in separate overlays avoids a discrete gradient swap. */ +html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) + aside.app-shell-left-panel::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: linear-gradient(90deg, + var(--ds-task-sidebar-overlay), + var(--ds-task-sidebar-edge-overlay)); + opacity: var(--ds-route-progress); + will-change: opacity; +} + +html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) + aside.app-shell-left-panel > :first-child { + position: relative; + z-index: 1; } /* Native sidebar resize chrome inherits the sidebar background and extends @@ -334,9 +388,9 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) main.main-surface:not(:has([role="main"])) { background: linear-gradient(90deg, - var(--ds-task-immersive-edge), - var(--ds-task-immersive-mid) 64%, - var(--ds-task-immersive-far)) !important; + var(--ds-immersive-edge), + var(--ds-immersive-mid) 64%, + var(--ds-immersive-far)) !important; border: 0 !important; border-radius: 0 !important; box-shadow: none !important; @@ -344,7 +398,17 @@ html[data-dream-skin="active"]:is([data-dream-task-mode="ambient"], [data-dream- html[data-dream-skin="active"]: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(:has([role="main"]))::before { - content: none; + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: linear-gradient(90deg, + var(--ds-task-overlay-edge), + var(--ds-task-overlay-mid) 64%, + var(--ds-task-overlay-far)); + opacity: var(--ds-route-progress); + will-change: opacity; } html[data-dream-skin="active"]: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"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) @@ -1084,6 +1148,10 @@ html[data-dream-skin="active"] [data-message-author-role] { border-radius: 16px; } @media (prefers-reduced-motion: reduce) { + :root[data-dream-skin="active"] { + --ds-route-transition: 0ms linear; + } + html[data-dream-skin="active"] *, html[data-dream-skin="active"] *::before, html[data-dream-skin="active"] *::after { diff --git a/windows/assets/renderer-inject.js b/windows/assets/renderer-inject.js index bf2bb1cb..99a76df2 100644 --- a/windows/assets/renderer-inject.js +++ b/windows/assets/renderer-inject.js @@ -10,8 +10,9 @@ "data-dream-skin", SHELL_ATTR, "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", - "data-dream-art-ready", + "data-dream-art-ready", "data-dream-route-phase", ]; + const ROUTE_TRANSITION_MS = 380; const VERSION = __DREAM_SKIN_VERSION_JSON__; const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__; const PAYLOAD_REVISION = __DREAM_SKIN_PAYLOAD_REVISION_JSON__; @@ -58,6 +59,7 @@ styleWrites: 0, styleRepairs: 0, navigationEvents: 0, + routeTransitions: 0, safetyPasses: 0, analysisRuns: 0, analysisCacheHits: artAnalysis ? 1 : 0, @@ -586,6 +588,16 @@ } if (state?.timer) clearInterval(state.timer); if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout); + if (state?.routeTransition?.frame !== null) { + if (typeof window.cancelAnimationFrame === "function") { + window.cancelAnimationFrame(state.routeTransition.frame); + } else { + clearTimeout(state.routeTransition.frame); + } + } + if (state?.routeTransition?.timeout !== null) { + clearTimeout(state.routeTransition.timeout); + } if (analysisTimer) clearTimeout(analysisTimer); if (state?.mediaHandler && state?.mediaQuery) { try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {} @@ -623,6 +635,37 @@ if (scheduler.timeout) return; scheduler.timeout = setTimeout(flushScheduledEnsure, delay); }; + const routeTransition = { frame: null, timeout: null }; + const requestRouteFrame = (callback) => typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame(callback) + : setTimeout(callback, 0); + const cancelRouteFrame = (frame) => { + if (frame === null) return; + if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(frame); + else clearTimeout(frame); + }; + const beginRouteTransition = () => { + const root = document.documentElement; + if (!root) return; + cancelRouteFrame(routeTransition.frame); + routeTransition.frame = null; + if (routeTransition.timeout !== null) clearTimeout(routeTransition.timeout); + routeTransition.timeout = null; + setAttribute(root, "data-dream-route-phase", "prepare"); + metrics.routeTransitions += 1; + routeTransition.frame = requestRouteFrame(() => { + routeTransition.frame = null; + if (window[DISABLED_KEY] || window[STATE_KEY]?.installToken !== installToken) return; + setAttribute(root, "data-dream-route-phase", "settling"); + ensure({ scope: true }); + routeTransition.timeout = setTimeout(() => { + routeTransition.timeout = null; + if (window[STATE_KEY]?.installToken === installToken) { + root.removeAttribute("data-dream-route-phase"); + } + }, ROUTE_TRANSITION_MS); + }); + }; if (typeof MutationObserver === "function") { rootObserver = new MutationObserver(() => scheduleEnsure({ root: true })); } @@ -638,7 +681,7 @@ ? window.navigation : null; const navigationHandler = navigationApi ? () => { metrics.navigationEvents += 1; - scheduleEnsure({ scope: true }, 180); + beginRouteTransition(); } : null; window[STATE_KEY] = { @@ -647,6 +690,7 @@ rootObserver, timer: null, scheduler, + routeTransition, mediaQuery, mediaHandler, navigation: navigationApi, @@ -666,6 +710,7 @@ themeId: THEME.id || "custom", revision: PAYLOAD_REVISION, detectShellAppearance, + beginRouteTransition, }; const firstEnsureStartedAt = now(); ensure({ root: true }); diff --git a/windows/references/tray-menu-preview.png b/windows/references/tray-menu-preview.png new file mode 100644 index 00000000..82603494 Binary files /dev/null and b/windows/references/tray-menu-preview.png differ diff --git a/windows/scripts/start-dream-skin.ps1 b/windows/scripts/start-dream-skin.ps1 index 8925d552..61e59898 100644 --- a/windows/scripts/start-dream-skin.ps1 +++ b/windows/scripts/start-dream-skin.ps1 @@ -13,6 +13,18 @@ $Injector = Join-Path $PSScriptRoot 'injector.mjs' . (Join-Path $PSScriptRoot 'common-windows.ps1') . (Join-Path $PSScriptRoot 'theme-windows.ps1') +function Start-DreamSkinTrayInBackground { + param([Parameter(Mandatory = $true)][int]$TrayPort) + if (Test-DreamSkinTrayActive) { return } + + $powershell = (Get-Command powershell.exe -ErrorAction Stop).Source + $trayScript = Join-Path $PSScriptRoot 'tray-dream-skin.ps1' + $trayToken = ConvertTo-DreamSkinProcessArgument -Value $trayScript + $trayArguments = '-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File ' + + $trayToken + " -Port $TrayPort" + Start-Process -FilePath $powershell -ArgumentList $trayArguments -WindowStyle Hidden | Out-Null +} + $operationLock = Enter-DreamSkinOperationLock try { Assert-DreamSkinPort -Port $Port @@ -306,6 +318,7 @@ try { } Write-Host "Codex Dream Skin is active on verified loopback port $Port." + Start-DreamSkinTrayInBackground -TrayPort $Port } finally { if ($null -ne $operationLock) { Exit-DreamSkinOperationLock -Mutex $operationLock } } diff --git a/windows/scripts/tray-dream-skin.ps1 b/windows/scripts/tray-dream-skin.ps1 index 5d2e62eb..8c42a082 100644 --- a/windows/scripts/tray-dream-skin.ps1 +++ b/windows/scripts/tray-dream-skin.ps1 @@ -1,5 +1,8 @@ [CmdletBinding()] -param([int]$Port = 9335) +param( + [int]$Port = 9335, + [switch]$SelfTest +) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Windows.Forms @@ -8,6 +11,402 @@ Add-Type -AssemblyName Microsoft.VisualBasic . (Join-Path $PSScriptRoot 'common-windows.ps1') . (Join-Path $PSScriptRoot 'theme-windows.ps1') +if (-not ('DreamSkin.TrayRenderer' -as [type])) { + Add-Type -ReferencedAssemblies @( + [System.Windows.Forms.NotifyIcon].Assembly.Location, + [System.Drawing.Bitmap].Assembly.Location + ) -TypeDefinition @' +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace DreamSkin { + public static class NativeMethods { + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool DestroyIcon(IntPtr handle); + } + + public sealed class TrayColorTable : ProfessionalColorTable { + private static readonly Color Surface = Color.FromArgb(255, 255, 255, 255); + private static readonly Color Selected = Color.FromArgb(255, 243, 244, 246); + private static readonly Color Accent = Color.FromArgb(255, 37, 99, 235); + private static readonly Color Border = Color.FromArgb(255, 218, 222, 229); + private static readonly Color Divider = Color.FromArgb(255, 218, 228, 242); + + public override Color ToolStripDropDownBackground { get { return Surface; } } + public override Color ImageMarginGradientBegin { get { return Surface; } } + public override Color ImageMarginGradientMiddle { get { return Surface; } } + public override Color ImageMarginGradientEnd { get { return Surface; } } + public override Color MenuItemSelected { get { return Selected; } } + public override Color MenuItemBorder { get { return Accent; } } + public override Color MenuBorder { get { return Border; } } + public override Color SeparatorDark { get { return Divider; } } + public override Color SeparatorLight { get { return Color.Transparent; } } + public override Color CheckBackground { get { return Selected; } } + public override Color CheckSelectedBackground { get { return Selected; } } + public override Color CheckPressedBackground { get { return Selected; } } + } + + public sealed class TrayMenu : ContextMenuStrip { + private static GraphicsPath RoundedRectangle(Rectangle bounds, int radius) { + GraphicsPath path = new GraphicsPath(); + int diameter = radius * 2; + Rectangle arc = new Rectangle(bounds.Location, new Size(diameter, diameter)); + path.AddArc(arc, 180, 90); + arc.X = bounds.Right - diameter; + path.AddArc(arc, 270, 90); + arc.Y = bounds.Bottom - diameter; + path.AddArc(arc, 0, 90); + arc.X = bounds.Left; + path.AddArc(arc, 90, 90); + path.CloseFigure(); + return path; + } + + private void ApplyRoundedRegion() { + if (Width < 2 || Height < 2) return; + int radius = Math.Max(10, (int)Math.Round(14.0 * DeviceDpi / 96.0)); + using (GraphicsPath path = RoundedRectangle(new Rectangle(0, 0, Width, Height), radius)) { + Region previous = Region; + Region = new Region(path); + if (previous != null) previous.Dispose(); + } + } + + public TrayMenu() { + SetStyle( + ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | + ControlStyles.ResizeRedraw, + true + ); + } + + protected override void OnHandleCreated(EventArgs e) { + base.OnHandleCreated(e); + ApplyRoundedRegion(); + } + + protected override void OnSizeChanged(EventArgs e) { + base.OnSizeChanged(e); + ApplyRoundedRegion(); + } + + protected override void Dispose(bool disposing) { + if (disposing && Region != null) { + Region.Dispose(); + Region = null; + } + base.Dispose(disposing); + } + } + + public sealed class TrayRenderer : ToolStripProfessionalRenderer { + private static GraphicsPath RoundedRectangle(Rectangle bounds, int radius) { + GraphicsPath path = new GraphicsPath(); + int diameter = radius * 2; + Rectangle arc = new Rectangle(bounds.Location, new Size(diameter, diameter)); + path.AddArc(arc, 180, 90); + arc.X = bounds.Right - diameter; + path.AddArc(arc, 270, 90); + arc.Y = bounds.Bottom - diameter; + path.AddArc(arc, 0, 90); + arc.X = bounds.Left; + path.AddArc(arc, 90, 90); + path.CloseFigure(); + return path; + } + + public TrayRenderer() : base(new TrayColorTable()) { + RoundedEdges = true; + } + + protected override void OnRenderToolStripBackground(ToolStripRenderEventArgs e) { + e.Graphics.Clear(Color.White); + } + + protected override void OnRenderImageMargin(ToolStripRenderEventArgs e) { + // Keep the menu visually flat. Checks are rendered independently below. + } + + protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e) { + if (!e.Item.Selected || !e.Item.Enabled) return; + Rectangle bounds = new Rectangle(4, 1, Math.Max(1, e.Item.Width - 8), Math.Max(1, e.Item.Height - 2)); + SmoothingMode previous = e.Graphics.SmoothingMode; + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using (GraphicsPath path = RoundedRectangle(bounds, 8)) + using (SolidBrush brush = new SolidBrush(Color.FromArgb(255, 243, 244, 246))) { + e.Graphics.FillPath(brush, path); + } + e.Graphics.SmoothingMode = previous; + } + + protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e) { + ToolStripMenuItem menuItem = e.Item as ToolStripMenuItem; + int rightInset = menuItem != null && menuItem.HasDropDownItems ? 36 : 12; + Rectangle bounds = new Rectangle( + 16, + 0, + Math.Max(1, e.Item.Width - 16 - rightInset), + e.Item.Height + ); + Color color = e.Item.Enabled + ? e.Item.ForeColor + : Color.FromArgb(255, 146, 151, 159); + TextRenderer.DrawText( + e.Graphics, + e.Text, + e.TextFont, + bounds, + color, + TextFormatFlags.Left | + TextFormatFlags.VerticalCenter | + TextFormatFlags.SingleLine | + TextFormatFlags.NoPrefix + ); + } + + protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e) { + int y = e.Item.Height / 2; + using (Pen pen = new Pen(Color.FromArgb(255, 218, 228, 242), 1.0f)) { + e.Graphics.DrawLine(pen, 0, y, Math.Max(0, e.Item.Width), y); + } + } + + protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e) { + int radius = Math.Max(10, (int)Math.Round(14.0 * e.ToolStrip.DeviceDpi / 96.0)); + Rectangle bounds = new Rectangle( + 0, + 0, + Math.Max(1, e.ToolStrip.Width - 1), + Math.Max(1, e.ToolStrip.Height - 1) + ); + SmoothingMode previous = e.Graphics.SmoothingMode; + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using (GraphicsPath path = RoundedRectangle(bounds, radius)) + using (Pen pen = new Pen(Color.FromArgb(255, 218, 222, 229), 1.0f)) { + e.Graphics.DrawPath(pen, path); + } + e.Graphics.SmoothingMode = previous; + } + + protected override void OnRenderArrow(ToolStripArrowRenderEventArgs e) { + int centerX = e.ArrowRectangle.Left + (e.ArrowRectangle.Width / 2); + int centerY = e.ArrowRectangle.Top + (e.ArrowRectangle.Height / 2); + Color color = e.Item.Enabled + ? Color.FromArgb(255, 91, 97, 105) + : Color.FromArgb(255, 183, 188, 196); + SmoothingMode previous = e.Graphics.SmoothingMode; + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using (Pen pen = new Pen(color, 1.6f)) { + pen.StartCap = LineCap.Round; + pen.EndCap = LineCap.Round; + e.Graphics.DrawLines(pen, new Point[] { + new Point(centerX - 2, centerY - 4), + new Point(centerX + 2, centerY), + new Point(centerX - 2, centerY + 4) + }); + } + e.Graphics.SmoothingMode = previous; + } + + protected override void OnRenderItemCheck(ToolStripItemImageRenderEventArgs e) { + Rectangle box = new Rectangle(e.ImageRectangle.Left + 3, e.ImageRectangle.Top + 3, 16, 16); + SmoothingMode previous = e.Graphics.SmoothingMode; + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using (SolidBrush brush = new SolidBrush(Color.FromArgb(255, 37, 99, 235))) + using (GraphicsPath path = RoundedRectangle(box, 5)) + using (Pen pen = new Pen(Color.White, 1.8f)) { + pen.StartCap = LineCap.Round; + pen.EndCap = LineCap.Round; + e.Graphics.FillPath(brush, path); + e.Graphics.DrawLines(pen, new Point[] { + new Point(box.Left + 4, box.Top + 8), + new Point(box.Left + 7, box.Top + 11), + new Point(box.Left + 12, box.Top + 5) + }); + } + e.Graphics.SmoothingMode = previous; + } + } +} +'@ +} + +function New-DreamSkinTrayIcon { + $bitmap = [System.Drawing.Bitmap]::new( + 32, + 32, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb + ) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $mark = [System.Drawing.Drawing2D.GraphicsPath]::new() + $paper = $null + $ink = $null + $outline = $null + $dot = $null + try { + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $graphics.Clear([System.Drawing.Color]::Transparent) + $mark.AddArc(2, 2, 9, 9, 180, 90) + $mark.AddArc(21, 2, 9, 9, 270, 90) + $mark.AddArc(21, 21, 9, 9, 0, 90) + $mark.AddArc(2, 21, 9, 9, 90, 90) + $mark.CloseFigure() + + $paper = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(255, 253, 253, 252)) + $graphics.FillPath($paper, $mark) + $graphics.SetClip($mark) + $ink = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(255, 23, 24, 28)) + $graphics.FillPolygon($ink, [System.Drawing.Point[]]@( + [System.Drawing.Point]::new(30, 2), + [System.Drawing.Point]::new(30, 30), + [System.Drawing.Point]::new(2, 30) + )) + $graphics.ResetClip() + $outline = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(118, 23, 24, 28), 1.1) + $graphics.DrawPath($outline, $mark) + $dot = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(255, 45, 225, 194)) + $graphics.FillEllipse($dot, 20, 6, 6, 6) + + $handle = $bitmap.GetHicon() + try { + $borrowed = [System.Drawing.Icon]::FromHandle($handle) + try { return [System.Drawing.Icon]$borrowed.Clone() } finally { $borrowed.Dispose() } + } finally { + if ($handle -ne [IntPtr]::Zero) { + [void][DreamSkin.NativeMethods]::DestroyIcon($handle) + } + } + } finally { + if ($null -ne $dot) { $dot.Dispose() } + if ($null -ne $outline) { $outline.Dispose() } + if ($null -ne $ink) { $ink.Dispose() } + if ($null -ne $paper) { $paper.Dispose() } + $mark.Dispose() + $graphics.Dispose() + $bitmap.Dispose() + } +} + +function Initialize-DreamSkinTrayMenu { + param([Parameter(Mandatory = $true)][System.Windows.Forms.ContextMenuStrip]$Menu) + $Menu.Renderer = [DreamSkin.TrayRenderer]::new() + $Menu.BackColor = [System.Drawing.Color]::White + $Menu.ForeColor = [System.Drawing.Color]::FromArgb(255, 31, 35, 40) + $Menu.Font = [System.Drawing.Font]::new('Segoe UI', 9.0) + $Menu.Padding = [System.Windows.Forms.Padding]::new(5, 5, 5, 5) + $Menu.AutoSize = $true + $Menu.DropShadowEnabled = $true + $Menu.ShowImageMargin = $false + $Menu.ShowCheckMargin = $false +} + +function Show-DreamSkinTrayError { + param([string]$Message) + [void][System.Windows.Forms.MessageBox]::Show( + $Message, + 'Codex Dream Skin', + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Error + ) +} + +function Add-DreamSkinTrayItem { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [System.Windows.Forms.ToolStripItemCollection]$Items, + [Parameter(Mandatory = $true)][string]$Text, + [AllowNull()][scriptblock]$Action, + [bool]$Enabled = $true, + [bool]$Checked = $false + ) + $displayText = if ($Checked) { "✓ $Text" } else { $Text } + $item = [System.Windows.Forms.ToolStripMenuItem]::new($displayText) + $item.Enabled = $Enabled + $item.Checked = $false + $item.ForeColor = if ($Enabled) { + [System.Drawing.Color]::FromArgb(255, 31, 35, 40) + } else { + [System.Drawing.Color]::FromArgb(255, 146, 151, 159) + } + $item.Padding = [System.Windows.Forms.Padding]::new(8, 4, 8, 4) + $item.Margin = [System.Windows.Forms.Padding]::new(1, 0, 1, 0) + if ($null -ne $Action) { + $item.add_Click({ + try { & $Action } catch { Show-DreamSkinTrayError -Message $_.Exception.Message } + }.GetNewClosure()) + } + [void]$Items.Add($item) + return $item +} + +function Add-DreamSkinTraySection { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [System.Windows.Forms.ToolStripItemCollection]$Items, + [Parameter(Mandatory = $true)][string]$Text + ) + $label = [System.Windows.Forms.ToolStripLabel]::new($Text) + $label.ForeColor = [System.Drawing.Color]::FromArgb(255, 98, 105, 114) + $label.Padding = [System.Windows.Forms.Padding]::new(11, 6, 11, 2) + $label.Font = [System.Drawing.Font]::new('Segoe UI', 8.25, [System.Drawing.FontStyle]::Regular) + [void]$Items.Add($label) + return $label +} + +function Clear-DreamSkinTrayItems { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [System.Windows.Forms.ToolStripItemCollection]$Items + ) + $previousItems = @($Items) + $Items.Clear() + foreach ($item in $previousItems) { $item.Dispose() } +} + +if ($SelfTest) { + $testMenu = [DreamSkin.TrayMenu]::new() + $testIcon = $null + try { + Initialize-DreamSkinTrayMenu -Menu $testMenu + $null = Add-DreamSkinTraySection -Items $testMenu.Items -Text 'Dream Skin' + $null = Add-DreamSkinTrayItem -Items $testMenu.Items -Text 'Self test' -Action $null + $testSubmenu = [System.Windows.Forms.ToolStripMenuItem]::new('Themes') + $testDropDown = [DreamSkin.TrayMenu]::new() + Initialize-DreamSkinTrayMenu -Menu $testDropDown + $testSubmenu.DropDown = $testDropDown + $null = Add-DreamSkinTrayItem -Items $testSubmenu.DropDownItems -Text 'Example' -Action $null + [void]$testMenu.Items.Add($testSubmenu) + $testMenu.PerformLayout() + $null = $testMenu.Handle + if ( + $testMenu.Items.Count -ne 3 -or + $testMenu.GetType().FullName -cne 'DreamSkin.TrayMenu' -or + $testMenu.Renderer.GetType().FullName -cne 'DreamSkin.TrayRenderer' -or + $testSubmenu.DropDown.GetType().FullName -cne 'DreamSkin.TrayMenu' -or + $null -eq $testMenu.Region + ) { + throw 'The styled tray menu did not initialize correctly.' + } + $testIcon = New-DreamSkinTrayIcon + if ($null -eq $testIcon -or $testIcon.Width -ne 32 -or $testIcon.Height -ne 32) { + throw 'The Dream Skin tray fallback icon was not created at 32 x 32.' + } + Write-Host 'PASS: branded tray icon and styled empty context menu.' + } finally { + if ($null -ne $testIcon) { $testIcon.Dispose() } + $testMenu.Dispose() + } + exit 0 +} + Assert-DreamSkinPort -Port $Port $SkillRoot = Split-Path -Parent $PSScriptRoot $StateRoot = Join-Path $env:LOCALAPPDATA 'CodexDreamSkin' @@ -33,23 +432,15 @@ try { $trayIcon = [System.Drawing.Icon]::new($iconPath) $notify.Icon = $trayIcon } else { - $notify.Icon = [System.Drawing.SystemIcons]::Application + $trayIcon = New-DreamSkinTrayIcon + $notify.Icon = $trayIcon } $notify.Text = 'Codex Dream Skin' $notify.Visible = $true - $menu = [System.Windows.Forms.ContextMenuStrip]::new() + $menu = [DreamSkin.TrayMenu]::new() + Initialize-DreamSkinTrayMenu -Menu $menu $notify.ContextMenuStrip = $menu - function Show-DreamSkinTrayError { - param([string]$Message) - [void][System.Windows.Forms.MessageBox]::Show( - $Message, - 'Codex Dream Skin', - [System.Windows.Forms.MessageBoxButtons]::OK, - [System.Windows.Forms.MessageBoxIcon]::Error - ) - } - function Start-DreamSkinPowerShell { param([Parameter(Mandatory = $true)][string]$Script, [string[]]$Arguments = @()) $scriptToken = ConvertTo-DreamSkinProcessArgument -Value $Script @@ -58,28 +449,6 @@ try { Start-Process -FilePath $powershell -ArgumentList $argumentLine -WindowStyle Hidden | Out-Null } - function Add-DreamSkinTrayItem { - param( - [Parameter(Mandatory = $true)] - [AllowEmptyCollection()] - [System.Windows.Forms.ToolStripItemCollection]$Items, - [Parameter(Mandatory = $true)][string]$Text, - [AllowNull()][scriptblock]$Action, - [bool]$Enabled = $true, - [bool]$Checked = $false - ) - $item = [System.Windows.Forms.ToolStripMenuItem]::new($Text) - $item.Enabled = $Enabled - $item.Checked = $Checked - if ($null -ne $Action) { - $item.add_Click({ - try { & $Action } catch { Show-DreamSkinTrayError -Message $_.Exception.Message } - }.GetNewClosure()) - } - [void]$Items.Add($item) - return $item - } - function Set-DreamSkinAutoStart { param([Parameter(Mandatory = $true)][bool]$Enabled) if (-not $Enabled) { @@ -96,17 +465,33 @@ try { } function Rebuild-DreamSkinTrayMenu { - $menu.Items.Clear() + Clear-DreamSkinTrayItems -Items $menu.Items $paused = Test-DreamSkinPaused -StateRoot $StateRoot $state = $null try { $state = Read-DreamSkinState -Path $paths.State } catch {} $active = $null try { $active = Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata } catch {} - $status = if ($paused) { '状态:已暂停' } elseif ($state) { '状态:运行中' } else { '状态:未运行' } + $status = if ($paused) { '已暂停' } elseif ($state) { '运行中' } else { '未运行' } if ($null -ne $active -and $null -ne $active.Theme -and $active.Theme.name) { $status += " · $($active.Theme.name)" } - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text $status -Action $null -Enabled $false + $brandLabel = [System.Windows.Forms.ToolStripLabel]::new('Codex Dream Skin') + $brandLabel.ForeColor = [System.Drawing.Color]::FromArgb(255, 31, 35, 40) + $brandLabel.Padding = [System.Windows.Forms.Padding]::new(11, 8, 11, 1) + $brandLabel.Font = [System.Drawing.Font]::new('Segoe UI', 9.25, [System.Drawing.FontStyle]::Regular) + [void]$menu.Items.Add($brandLabel) + + $statusLabel = [System.Windows.Forms.ToolStripLabel]::new($status) + $statusLabel.ForeColor = if ($paused) { + [System.Drawing.Color]::FromArgb(255, 145, 98, 32) + } elseif ($state) { + [System.Drawing.Color]::FromArgb(255, 22, 130, 92) + } else { + [System.Drawing.Color]::FromArgb(255, 190, 48, 48) + } + $statusLabel.Padding = [System.Windows.Forms.Padding]::new(11, 1, 11, 7) + $statusLabel.Font = [System.Drawing.Font]::new('Segoe UI', 8.25, [System.Drawing.FontStyle]::Regular) + [void]$menu.Items.Add($statusLabel) [void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '应用或重新应用' -Action { @@ -186,6 +571,12 @@ try { } $savedMenu = [System.Windows.Forms.ToolStripMenuItem]::new('已保存主题') + $savedMenu.ForeColor = [System.Drawing.Color]::FromArgb(255, 31, 35, 40) + $savedMenu.Padding = [System.Windows.Forms.Padding]::new(8, 4, 8, 4) + $savedMenu.Margin = [System.Windows.Forms.Padding]::new(1, 0, 1, 0) + $savedDropDown = [DreamSkin.TrayMenu]::new() + Initialize-DreamSkinTrayMenu -Menu $savedDropDown + $savedMenu.DropDown = $savedDropDown $savedThemes = @(Get-DreamSkinSavedThemes -StateRoot $StateRoot -SkipImageMetadata) if ($savedThemes.Count -eq 0) { $empty = [System.Windows.Forms.ToolStripMenuItem]::new('暂无已保存主题') @@ -205,16 +596,16 @@ try { } [void]$menu.Items.Add($savedMenu) + [void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '打开图片文件夹' -Action { $imageDirectoryToken = ConvertTo-DreamSkinProcessArgument -Value $paths.Images Start-Process -FilePath explorer.exe -ArgumentList $imageDirectoryToken | Out-Null } - [void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '检查更新…' -Action { Start-DreamSkinPowerShell -Script $checkUpdateScript -Arguments @('-Interactive') } - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '打开 DreamSkin.cc' -Action { - Start-Process -FilePath 'https://dreamskin.cc' | Out-Null + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '打开 DreamSkin Studio' -Action { + Start-Process -FilePath 'https://www.dreamskin.cc/studio' | Out-Null } $autoStartEnabled = Test-Path -LiteralPath $startupShortcut -PathType Leaf $autoStartAction = { @@ -223,13 +614,17 @@ try { $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '登录时启动' ` -Action $autoStartAction -Checked $autoStartEnabled [void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '停止 Dream Skin' -Action { + Start-DreamSkinPowerShell -Script $restoreScript -Arguments @( + '-Port', "$Port", '-PromptRestart' + ) + } $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '完全恢复 Codex' -Action { Start-DreamSkinPowerShell -Script $restoreScript -Arguments @( '-Port', "$Port", '-RestoreBaseTheme', '-PromptRestart' ) - $notify.Visible = $false - [System.Windows.Forms.Application]::Exit() } + [void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '退出托盘' -Action { $notify.Visible = $false [System.Windows.Forms.Application]::Exit() @@ -247,7 +642,10 @@ try { }) [System.Windows.Forms.Application]::Run() } finally { - if ($null -ne $notify) { $notify.Dispose() } + if ($null -ne $notify) { + $notify.Visible = $false + $notify.Dispose() + } if ($null -ne $trayIcon) { $trayIcon.Dispose() } if ($acquired) { try { $mutex.ReleaseMutex() } catch {} } $mutex.Dispose() diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index 1667165d..cea4dd79 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -945,6 +945,9 @@ try { '--ds-immersive-composer', 'background-position: var(--ds-art-position)', 'html[data-dream-skin="active"]', + '@property --ds-route-progress', + '[data-dream-route-phase="prepare"]', + 'opacity: var(--ds-route-progress)', 'main.main-surface:has([role="main"])', 'main.main-surface:not(:has([role="main"]))' )) { @@ -968,7 +971,17 @@ try { throw 'macOS and Windows selector contract assets are not byte-identical.' } $traySource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\tray-dream-skin.ps1') - foreach ($requiredTrayAction in @('System.Windows.Forms.NotifyIcon', '暂停皮肤', '继续显示皮肤', '更换背景图', '已保存主题', '完全恢复 Codex')) { + foreach ($requiredTrayAction in @( + 'System.Windows.Forms.NotifyIcon', + 'DreamSkin.TrayRenderer', + 'New-DreamSkinTrayIcon', + '暂停皮肤', + '继续显示皮肤', + '更换背景图', + '已保存主题', + '停止 Dream Skin', + '完全恢复 Codex' + )) { if (-not $traySource.Contains($requiredTrayAction)) { throw "Tray action is missing: $requiredTrayAction" } } if (-not $traySource.Contains('Invoke-DreamSkinLiveRemove') -or @@ -1015,7 +1028,7 @@ try { -not $traySource.Contains('Get-DreamSkinSavedThemes -StateRoot $StateRoot -SkipImageMetadata')) { throw 'Tray menu metadata enumeration still performs full image parsing on every open.' } - foreach ($requiredReleaseAction in @('check-update.ps1', '检查更新', '打开 DreamSkin.cc', '登录时启动')) { + foreach ($requiredReleaseAction in @('check-update.ps1', '检查更新', '打开 DreamSkin Studio', '登录时启动')) { if (-not $traySource.Contains($requiredReleaseAction)) { throw "Tray release action is missing: $requiredReleaseAction" } @@ -1036,6 +1049,7 @@ try { if ($null -eq $addTrayItemAst) { throw 'Tray item helper could not be loaded for an empty-menu behavior check.' } Invoke-Expression $addTrayItemAst.Extent.Text Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing $emptyMenu = [System.Windows.Forms.ContextMenuStrip]::new() try { $probeItem = Add-DreamSkinTrayItem -Items $emptyMenu.Items -Text 'first-item-probe' -Action $null -Enabled $false @@ -1045,6 +1059,21 @@ try { } finally { $emptyMenu.Dispose() } + $traySelfTest = Invoke-DreamSkinNative -FilePath ( + (Get-Command powershell.exe -ErrorAction Stop).Source + ) -ArgumentList @( + '-NoProfile', + '-STA', + '-ExecutionPolicy', + 'RemoteSigned', + '-File', + (Join-Path $Root 'scripts\tray-dream-skin.ps1'), + '-SelfTest' + ) + if ($traySelfTest.ExitCode -ne 0 -or + ($traySelfTest.Output -join "`n") -notmatch 'PASS: branded tray icon and styled empty context menu') { + throw "Tray icon/menu self-test failed: $($traySelfTest.Output -join '')" + } $restoreSource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\restore-dream-skin.ps1') if (-not $restoreSource.Contains('Stop-DreamSkinTrayProcess')) { throw 'Complete restore does not stop a separately launched tray process.' @@ -1058,6 +1087,28 @@ try { -not $startSource.Contains('Start-DreamSkinCodex -Codex $codex')) { throw 'Start still executes the WindowsApps path instead of activating the registered package.' } + $activeMessageIndex = $startSource.IndexOf( + 'Write-Host "Codex Dream Skin is active on verified loopback port $Port."', + [System.StringComparison]::Ordinal + ) + $trayBackgroundIndex = $startSource.IndexOf( + 'Start-DreamSkinTrayInBackground -TrayPort $Port', + [System.StringComparison]::Ordinal + ) + foreach ($requiredTrayLaunchToken in @( + 'function Start-DreamSkinTrayInBackground', + 'Test-DreamSkinTrayActive', + "Join-Path `$PSScriptRoot 'tray-dream-skin.ps1'", + '-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File', + 'Start-Process -FilePath $powershell -ArgumentList $trayArguments -WindowStyle Hidden' + )) { + if (-not $startSource.Contains($requiredTrayLaunchToken)) { + throw "Start no longer detaches the tray safely after launch: $requiredTrayLaunchToken" + } + } + if ($activeMessageIndex -lt 0 -or $trayBackgroundIndex -le $activeMessageIndex) { + throw 'Start launches the tray before the skin has completed verification.' + } $stateReadIndex = $startSource.IndexOf('$previousState = Read-DreamSkinState', [System.StringComparison]::Ordinal) $restartPromptIndex = $startSource.IndexOf('$restartAuthorized = Confirm-DreamSkinRestart', [System.StringComparison]::Ordinal) $recordedStopIndex = $startSource.IndexOf('$recordedInjectorStopped = Stop-DreamSkinRecordedInjector', [System.StringComparison]::Ordinal) @@ -1105,7 +1156,8 @@ try { $rendererSource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'assets\renderer-inject.js') foreach ($requiredRendererBehavior in @( 'adoptedStyleSheets', 'CSSStyleSheet', 'artMetadata', 'detectShellAppearance', - 'data-dream-skin', 'window.navigation', 'selectorsSchema', 'codex-dream-skin-selectors/1' + 'data-dream-skin', 'window.navigation', 'selectorsSchema', 'codex-dream-skin-selectors/1', + 'beginRouteTransition', 'data-dream-route-phase', 'requestAnimationFrame' )) { if (-not $rendererSource.Contains($requiredRendererBehavior)) { throw "Renderer adaptive behavior is missing: $requiredRendererBehavior"