diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2bc76a..dca67f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ on: required: true type: string runtime_manifest_sha256: - description: SHA-256 of the transition runtime artifact-manifest.json embedded in the DMG + description: SHA-256 of the release-bundled-latest runtime artifact-manifest.json embedded in the DMG required: true type: string @@ -89,6 +89,8 @@ jobs: npm run build:manager npm --prefix app run adapter:catalog:check npm --prefix app run prepare:runtime + npm --prefix app run prepare:release-resources + npm --prefix app run verify:release-resources cargo test --manifest-path app/src-tauri/Cargo.toml cargo fmt --manifest-path app/src-tauri/Cargo.toml --check @@ -140,7 +142,7 @@ jobs: RESOURCES="$APP/Contents/Resources" [[ "$(shasum -a 256 "$RESOURCES/artifact-manifest.json" | awk '{print $1}')" == "$RUNTIME_MANIFEST_SHA256" ]] node app/scripts/verify-runtime-resources.mjs \ - "$RESOURCES" app/config/transition-baseline.json "$VERSION" + "$RESOURCES" app/config/release-baseline.json "$VERSION" hdiutil detach "$MOUNT_POINT" trap - EXIT @@ -161,8 +163,8 @@ jobs: echo "- Commit: \`$(git rev-parse HEAD)\`" echo "- Apple notarization: \`$NOTARIZATION_ID\`" echo "- DMG SHA-256: \`$DMG_SHA256\`" - echo "- Transition runtime manifest SHA-256: \`$RUNTIME_MANIFEST_SHA256\`" - echo "- Bundled profile: transition-baseline (CodeX 26.715.31925-r1; Doubao 2.19.9-r1; WorkBuddy 5.2.6-r1)" + echo "- Release runtime manifest SHA-256: \`$RUNTIME_MANIFEST_SHA256\`" + echo "- Bundled profile: \`release-bundled-latest\` (verified against the exact signed Adapter Release assets)" echo "- Product assets: one notarized and stapled DMG only" echo echo "The Release remains a draft for a maintainer to publish after reviewing this run." diff --git a/adapters/mac-codex/CHANGELOG.md b/adapters/mac-codex/CHANGELOG.md index 700124c..6766a21 100644 --- a/adapters/mac-codex/CHANGELOG.md +++ b/adapters/mac-codex/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Mac-CodeX 26.715.71837 r2 — Unreleased +## Mac-CodeX 26.715.71837 r3 — Unreleased - Added the adapter-owned `immersive-scene-v1` presentation recipe. diff --git a/adapters/mac-codex/PROJECT_MANIFEST.json b/adapters/mac-codex/PROJECT_MANIFEST.json index 6ed4107..d259d24 100644 --- a/adapters/mac-codex/PROJECT_MANIFEST.json +++ b/adapters/mac-codex/PROJECT_MANIFEST.json @@ -3,15 +3,15 @@ "schemaVersion": "1.0.0", "adapterId": "mac-codex", "adapterVersion": "26.715.71837", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 3, "productVersion": "26.715.71837", "platform": "macOS", "releaseTarget": { "os": "macos", "arch": "arm64", - "assetIdentity": "mac-codex-26.715.71837-r2-macos-arm64", - "sourceArtifact": "mac-codex-26.715.71837-r2-macos-arm64.zip", - "clientArtifact": "cc-theme-mac-codex-26.715.71837-r2-macos-arm64.zip" + "assetIdentity": "mac-codex-26.715.71837-r3-macos-arm64", + "sourceArtifact": "mac-codex-26.715.71837-r3-macos-arm64.zip", + "clientArtifact": "cc-theme-mac-codex-26.715.71837-r3-macos-arm64.zip" }, "client": { "bundleId": "com.openai.codex", diff --git a/adapters/mac-codex/README.md b/adapters/mac-codex/README.md index 563872e..c300bd2 100644 --- a/adapters/mac-codex/README.md +++ b/adapters/mac-codex/README.md @@ -86,10 +86,10 @@ Adapter 验证外部包内的本地图片、H.264 MP4、静态 WebP 方向图集 ``` 当前 arm64 用户包为 -`cc-theme-mac-codex-26.715.71837-r2-macos-arm64.zip`,源码包为 -`mac-codex-26.715.71837-r2-macos-arm64.zip`,校验文件分别为 -`cc-theme-mac-codex-26.715.71837-r2-macos-arm64.zip.sha256` 和 -`mac-codex-26.715.71837-r2-macos-arm64.zip.sha256`。 +`cc-theme-mac-codex-26.715.71837-r3-macos-arm64.zip`,源码包为 +`mac-codex-26.715.71837-r3-macos-arm64.zip`,校验文件分别为 +`cc-theme-mac-codex-26.715.71837-r3-macos-arm64.zip.sha256` 和 +`mac-codex-26.715.71837-r3-macos-arm64.zip.sha256`。 `adapterVersion` 严格跟随 Codex `CFBundleShortVersionString`;同版本再发布时递增 `adapterReleaseRevision`,构建器拒绝覆盖已有同修订制品。精确 build 只存在 compatibility evidence 和 Manager compile context,不进入资产名。 diff --git a/adapters/mac-codex/assets/renderer-inject.js b/adapters/mac-codex/assets/renderer-inject.js index 21a43a6..98edea5 100644 --- a/adapters/mac-codex/assets/renderer-inject.js +++ b/adapters/mac-codex/assets/renderer-inject.js @@ -268,6 +268,9 @@ "--skin-name", "--skin-tagline", "--skin-project-prefix", "--skin-project-label", "--skin-active-art", "--skin-native-canvas", "--skin-native-sidebar", + "--scene-gold", "--scene-line", "--scene-panel", "--scene-raised", "--scene-texture-tint", + "--scene-surface-opacity", "--scene-density-inset", "--scene-border-width", + "--scene-navigation-frame-width", "--scene-composer-frame-width", "--scene-card-frame-width", ...NATIVE_THEME_VARIABLES, ]; window[DISABLED_KEY] = false; @@ -903,9 +906,14 @@ }); const applyTheme = (root, shell) => { - const colors = THEME.colors || {}; - const explicit = new Set(Array.isArray(THEME.explicitColorKeys) ? THEME.explicitColorKeys : []); - const explicitSemantic = new Set(Array.isArray(THEME.explicitSemanticColorKeys) ? THEME.explicitSemanticColorKeys : []); + const appearanceVariant = THEME.appearanceVariants?.[shell] || null; + const colors = appearanceVariant?.colors || THEME.colors || {}; + const explicit = new Set(appearanceVariant + ? Object.keys(appearanceVariant.colors || {}) + : (Array.isArray(THEME.explicitColorKeys) ? THEME.explicitColorKeys : [])); + const explicitSemantic = new Set(appearanceVariant + ? Object.keys(appearanceVariant.semanticColors || {}) + : (Array.isArray(THEME.explicitSemanticColorKeys) ? THEME.explicitSemanticColorKeys : [])); const adaptive = makeAdaptivePalette(artAnalysis?.accentRgb, shell); const paletteMode = ART.paletteMode === "media" || (ART.paletteMode === undefined && ART.adaptivePalette === true) ? "media" : "system"; @@ -951,7 +959,7 @@ }; if (BRIDGE_ENABLED) { - const semantic = THEME.semanticColors || {}; + const semantic = appearanceVariant?.semanticColors || THEME.semanticColors || {}; const systemSemantic = resolvedPalette.semanticColors || {}; const adaptiveSemantic = adaptiveEnabled ? { surfaceBase: variables["--skin-panel"], @@ -1222,6 +1230,30 @@ "--vscode-simpleFindWidget-sashBorder": "var(--skin-border-default)", "--oai-wb-surface-primary": "var(--skin-surface-raised)", }); + + // The immersive recipe is Adapter-owned, while these values come only + // from the closed Shared Core presentation envelope. Keep the values in + // CSS custom properties so an owned role can consume every declared + // semantic parameter without exposing host selectors to a Theme Package. + if (PRESENTATION_PROFILE === "immersive-scene-v1") { + const scene = THEME.presentation?.parameters || {}; + const texture = Math.round(Math.min(1, Math.max(0, Number(scene.textureIntensity) || 0)) * 30); + const requestedOpacity = Number(scene.surfaceOpacity); + const opacity = Math.round(Math.min(1, Math.max(0, Number.isFinite(requestedOpacity) ? requestedOpacity : .72)) * 100); + Object.assign(variables, { + "--scene-gold": "var(--skin-action)", + "--scene-line": "color-mix(in srgb, var(--scene-gold) 34%, transparent)", + "--scene-panel": "color-mix(in srgb, var(--skin-panel) var(--scene-surface-opacity), transparent)", + "--scene-raised": "color-mix(in srgb, var(--skin-panel-2) var(--scene-surface-opacity), transparent)", + "--scene-texture-tint": `color-mix(in srgb, var(--scene-gold) ${texture}%, transparent)`, + "--scene-surface-opacity": `${opacity}%`, + "--scene-density-inset": scene.density === "comfortable" ? "12px" : "0px", + "--scene-border-width": scene.borderTreatment === "etched" ? "1px" : "0px", + "--scene-navigation-frame-width": scene.navigationTreatment === "framed" ? "1px" : "0px", + "--scene-composer-frame-width": scene.composerTreatment === "anchored" ? "1px" : "0px", + "--scene-card-frame-width": scene.cardTreatment === "elevated" ? "1px" : "0px", + }); + } } for (const [name, value] of Object.entries(variables)) { diff --git a/adapters/mac-codex/assets/skin.css b/adapters/mac-codex/assets/skin.css index f390e4d..0e1eca3 100644 --- a/adapters/mac-codex/assets/skin.css +++ b/adapters/mac-codex/assets/skin.css @@ -1694,40 +1694,74 @@ html.cc-theme[data-skin-theme-bridge] .skin-home > div:first-child > div:first-c /* Adapter-owned immersive scene recipe. It consumes stable Skin Surface Roles only; native controls remain live nodes with their host interaction geometry. */ html.cc-theme[data-skin-presentation="immersive-scene-v1"] { - --scene-gold: #c8a55a; + --scene-gold: var(--skin-action, #c8a55a); --scene-line: color-mix(in srgb, var(--scene-gold) 34%, transparent); - --scene-panel: color-mix(in srgb, var(--skin-panel, #171513) 78%, transparent); - --scene-raised: color-mix(in srgb, var(--skin-panel-2, #211d18) 84%, transparent); + --scene-panel: color-mix(in srgb, var(--skin-panel, #171513) var(--scene-surface-opacity, 78%), transparent); + --scene-raised: color-mix(in srgb, var(--skin-panel-2, #211d18) var(--scene-surface-opacity, 84%), transparent); + --scene-texture-tint: color-mix(in srgb, var(--scene-gold) 11%, transparent); + --scene-density-inset: 12px; + --scene-border-width: 1px; + --scene-navigation-frame-width: 1px; + --scene-composer-frame-width: 1px; + --scene-card-frame-width: 1px; } html.cc-theme[data-skin-presentation="immersive-scene-v1"] body { - background: linear-gradient(90deg, rgba(8, 8, 10, .94) 0%, rgba(11, 10, 11, .74) 49%, rgba(14, 11, 9, .26) 100%) !important; + background: + linear-gradient(90deg, + color-mix(in srgb, var(--skin-main-scrim-start) var(--scene-surface-opacity), transparent) 0%, + color-mix(in srgb, var(--skin-main-scrim-mid) var(--scene-surface-opacity), transparent) 49%, + color-mix(in srgb, var(--skin-main-scrim-end) var(--scene-surface-opacity), transparent) 100%), + repeating-linear-gradient(135deg, var(--scene-texture-tint) 0 1px, transparent 1px 12px) !important; } html.cc-theme[data-skin-presentation="immersive-scene-v1"] .app-shell-left-panel { background: linear-gradient(180deg, color-mix(in srgb, var(--scene-panel) 94%, #070606), color-mix(in srgb, var(--scene-panel) 78%, #0d0907)) !important; - border-right: 1px solid var(--scene-line) !important; + border-right: var(--scene-navigation-frame-width) solid var(--scene-line) !important; box-shadow: inset -1px 0 rgba(255, 240, 202, .05), 12px 0 32px rgba(0, 0, 0, .34) !important; } html.cc-theme[data-skin-presentation="immersive-scene-v1"] :is(main.main-surface, [data-app-shell-main-content-layout]) { - background: linear-gradient(90deg, rgba(9, 8, 9, .80), rgba(14, 11, 10, .44) 56%, rgba(12, 10, 9, .16)) !important; - box-shadow: inset 1px 0 var(--scene-line) !important; + background: linear-gradient(90deg, + color-mix(in srgb, var(--skin-main-scrim-start) var(--scene-surface-opacity), transparent), + color-mix(in srgb, var(--skin-main-scrim-mid) var(--scene-surface-opacity), transparent) 56%, + color-mix(in srgb, var(--skin-main-scrim-end) var(--scene-surface-opacity), transparent)) !important; + box-shadow: inset var(--scene-border-width) 0 var(--scene-line) !important; +} + +/* These two route-specific rules normally choose a reading-strength scrim. + The immersive profile must take precedence so its single bounded opacity + control cannot leave a second dark veil behind at 0%. */ +html.cc-theme[data-skin-theme-bridge][data-skin-presentation="immersive-scene-v1"][data-skin-surface-context="home"] main.main-surface, +html.cc-theme[data-skin-theme-bridge][data-skin-presentation="immersive-scene-v1"][data-skin-surface-context="home"] main.main-surface:not(.skin-home-shell), +html.cc-theme[data-skin-theme-bridge][data-skin-presentation="immersive-scene-v1"]:is([data-skin-surface-context="task"], [data-skin-surface-context="library"]) main.main-surface, +html.cc-theme[data-skin-theme-bridge][data-skin-presentation="immersive-scene-v1"]:is([data-skin-surface-context="task"], [data-skin-surface-context="library"]) main.main-surface:not(.skin-home-shell) { + background: linear-gradient(90deg, + color-mix(in srgb, var(--skin-main-scrim-start) var(--scene-surface-opacity), transparent) 0%, + color-mix(in srgb, var(--skin-main-scrim-mid) var(--scene-surface-opacity), transparent) 58%, + color-mix(in srgb, var(--skin-main-scrim-end) var(--scene-surface-opacity), transparent) 100%) !important; } html.cc-theme[data-skin-presentation="immersive-scene-v1"] [data-skin-role="home-hero"] { - border: 1px solid var(--scene-line) !important; + border: var(--scene-border-width) solid var(--scene-line) !important; border-radius: 18px !important; box-shadow: 0 22px 54px rgba(0, 0, 0, .42), inset 0 1px rgba(255, 243, 212, .08) !important; } -html.cc-theme[data-skin-presentation="immersive-scene-v1"] :is([data-skin-role="home-suggestion-card"], [data-skin-role="composer-root"], .composer-surface-chrome, article) { +html.cc-theme[data-skin-presentation="immersive-scene-v1"] [data-skin-role="home-suggestion-card"] { + background: var(--scene-raised) !important; + border: var(--scene-card-frame-width) solid var(--scene-line) !important; + padding: var(--scene-density-inset) !important; + box-shadow: 0 12px 30px rgba(0, 0, 0, .28), inset 0 1px rgba(255, 243, 212, .05) !important; +} + +html.cc-theme[data-skin-presentation="immersive-scene-v1"] :is([data-skin-role="composer-root"], .composer-surface-chrome) { background: var(--scene-raised) !important; - border: 1px solid var(--scene-line) !important; + border: var(--scene-composer-frame-width) solid var(--scene-line) !important; box-shadow: 0 12px 30px rgba(0, 0, 0, .28), inset 0 1px rgba(255, 243, 212, .05) !important; } -html.cc-theme[data-skin-presentation="immersive-scene-v1"] :is([role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]) { +html.cc-theme[data-skin-presentation="immersive-scene-v1"] [data-skin-role="overlay-surface"] { background: color-mix(in srgb, var(--skin-panel-2, #211d18) 96%, #080707) !important; border-color: var(--scene-line) !important; } diff --git a/adapters/mac-codex/contracts/adapter-capability.json b/adapters/mac-codex/contracts/adapter-capability.json index fe65b05..e4cb049 100644 --- a/adapters/mac-codex/contracts/adapter-capability.json +++ b/adapters/mac-codex/contracts/adapter-capability.json @@ -3,11 +3,11 @@ "revision": 2, "adapterId": "mac-codex", "adapterVersion": "26.715.71837", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 3, "releaseTarget": { "os": "macos", "arch": "arm64", - "assetIdentity": "mac-codex-26.715.71837-r2-macos-arm64" + "assetIdentity": "mac-codex-26.715.71837-r3-macos-arm64" }, "displayName": "Mac Codex", "availability": "available", @@ -69,17 +69,38 @@ "immersive-scene-v1": { "profileVersion": 1, "geometryPolicy": "scene-bounded", - "surfaces": { - "shell": "exact", - "navigation": "exact", - "home": "exact", - "conversation": "exact", - "composer": "exact", - "cards": "exact", - "overlays": "exact" + "sceneSemantics": { + "scope": "presentation-scene", + "surfaces": { + "shell": { "decision": "exact", "consumerId": "codex.scene.shell.root", "diagnostic": "scene-surface-shell-exact" }, + "navigation": { "decision": "exact", "consumerId": "codex.scene.navigation.sidebar", "diagnostic": "scene-surface-navigation-exact" }, + "home": { "decision": "exact", "consumerId": "codex.scene.home.role-frame", "diagnostic": "scene-surface-home-exact" }, + "conversation": { "decision": "exact", "consumerId": "codex.scene.conversation.main-frame", "diagnostic": "scene-surface-conversation-exact" }, + "composer": { "decision": "exact", "consumerId": "codex.scene.composer.role-frame", "diagnostic": "scene-surface-composer-exact" }, + "cards": { "decision": "exact", "consumerId": "codex.scene.cards.suggestion-frame", "diagnostic": "scene-surface-cards-exact" }, + "overlays": { "decision": "exact", "consumerId": "codex.scene.overlays.role-frame", "diagnostic": "scene-surface-overlays-exact" } + }, + "parameters": { + "density": { "decision": "exact", "consumerId": "codex.scene.density.inset", "diagnostic": "scene-parameter-density-exact" }, + "borderTreatment": { "decision": "exact", "consumerId": "codex.scene.border.etched-frame", "diagnostic": "scene-parameter-border-treatment-exact" }, + "textureIntensity": { "decision": "exact", "consumerId": "codex.scene.texture.main-canvas", "diagnostic": "scene-parameter-texture-intensity-exact" }, + "surfaceOpacity": { "decision": "exact", "consumerId": "codex.scene.opacity.panel-layer", "diagnostic": "scene-parameter-surface-opacity-exact" }, + "navigationTreatment": { "decision": "exact", "consumerId": "codex.scene.navigation.framed-sidebar", "diagnostic": "scene-parameter-navigation-treatment-exact" }, + "composerTreatment": { "decision": "exact", "consumerId": "codex.scene.composer.anchored-frame", "diagnostic": "scene-parameter-composer-treatment-exact" }, + "cardTreatment": { "decision": "exact", "consumerId": "codex.scene.cards.elevated-frame", "diagnostic": "scene-parameter-card-treatment-exact" } + }, + "assetSlots": { + "scene.backdrop": { "decision": "exact", "consumerId": "codex.scene.backdrop.active-art", "diagnostic": "scene-asset-backdrop-exact" } + } } } }, + "presentationBoundaries": { + "nativeControls": { "decision": "unsupported", "consumerId": null, "diagnostic": "scene-native-controls-host-owned" }, + "layout": { "decision": "unsupported", "consumerId": null, "diagnostic": "scene-layout-host-owned" }, + "uncataloguedPortals": { "decision": "unsupported", "consumerId": null, "diagnostic": "scene-uncatalogued-portals-unsupported" }, + "fonts": { "decision": "unsupported", "consumerId": null, "diagnostic": "scene-fonts-host-owned" } + }, "security": { "forbiddenPayloads": ["css", "javascript", "html", "shader", "selector", "command", "url", "absolute-path", "path-traversal", "host-version-fact"], "officialApplicationReadOnly": true, diff --git a/adapters/mac-codex/contracts/adapter-release-manifest.json b/adapters/mac-codex/contracts/adapter-release-manifest.json index 3d173af..7428f51 100644 --- a/adapters/mac-codex/contracts/adapter-release-manifest.json +++ b/adapters/mac-codex/contracts/adapter-release-manifest.json @@ -3,13 +3,13 @@ "revision": 1, "adapterId": "mac-codex", "adapterVersion": "26.715.71837", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 3, "os": "macos", "arch": "arm64", - "assetIdentity": "mac-codex-26.715.71837-r2-macos-arm64", + "assetIdentity": "mac-codex-26.715.71837-r3-macos-arm64", "artifacts": { - "source": "mac-codex-26.715.71837-r2-macos-arm64.zip", - "client": "cc-theme-mac-codex-26.715.71837-r2-macos-arm64.zip" + "source": "mac-codex-26.715.71837-r3-macos-arm64.zip", + "client": "cc-theme-mac-codex-26.715.71837-r3-macos-arm64.zip" }, "entries": [ "LICENSE", diff --git a/adapters/mac-codex/contracts/skin-theme.schema.json b/adapters/mac-codex/contracts/skin-theme.schema.json index aa760f7..7fe0ad5 100644 --- a/adapters/mac-codex/contracts/skin-theme.schema.json +++ b/adapters/mac-codex/contracts/skin-theme.schema.json @@ -200,7 +200,7 @@ "properties": { "density": { "const": "comfortable" }, "borderTreatment": { "const": "etched" }, "textureIntensity": { "type": "number", "minimum": 0, "maximum": 1 }, - "surfaceOpacity": { "type": "number", "minimum": 0.4, "maximum": 0.88 }, + "surfaceOpacity": { "type": "number", "minimum": 0, "maximum": 1 }, "navigationTreatment": { "const": "framed" }, "composerTreatment": { "const": "anchored" }, "cardTreatment": { "const": "elevated" } } }, diff --git a/adapters/mac-codex/package.json b/adapters/mac-codex/package.json index 83fcb5d..83877ea 100644 --- a/adapters/mac-codex/package.json +++ b/adapters/mac-codex/package.json @@ -4,10 +4,10 @@ "ccThemeAdapter": { "adapterId": "mac-codex", "adapterVersion": "26.715.71837", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 3, "os": "macos", "arch": "arm64", - "assetIdentity": "mac-codex-26.715.71837-r2-macos-arm64" + "assetIdentity": "mac-codex-26.715.71837-r3-macos-arm64" }, "description": "A reversible always-latest macOS Theme Adapter for Codex Desktop backgrounds and local style authoring.", "keywords": [ diff --git a/adapters/mac-codex/scripts/adapter-capability.mjs b/adapters/mac-codex/scripts/adapter-capability.mjs index eefb94e..c931b03 100644 --- a/adapters/mac-codex/scripts/adapter-capability.mjs +++ b/adapters/mac-codex/scripts/adapter-capability.mjs @@ -66,6 +66,15 @@ const LOCAL_RUNTIME_OVERRIDE_KEYS = new Set(["baseThemeHash", "entries"]); const LOCAL_RUNTIME_OVERRIDE_ENTRY_KEYS = new Set(["tokenId", "baseHash", "value"]); const SAFE_CONTEXT_TEXT = /^[A-Za-z0-9._:+-]{1,160}$/; const SHA256 = /^[0-9a-f]{64}$/; +const PRESENTATION_CONSUMER_ID = /^[A-Za-z][A-Za-z0-9.-]{2,119}$/; +const PRESENTATION_DIAGNOSTIC = /^[a-z][a-z0-9-]{2,159}$/; +const SCENE_SURFACES = ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"]; +const SCENE_PARAMETERS = [ + "density", "borderTreatment", "textureIntensity", "surfaceOpacity", + "navigationTreatment", "composerTreatment", "cardTreatment", +]; +const SCENE_ASSET_SLOTS = ["scene.backdrop"]; +const PRESENTATION_BOUNDARIES = ["nativeControls", "layout", "uncataloguedPortals", "fonts"]; function plainObject(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); @@ -80,6 +89,47 @@ function allowedObject(value, keys, label, optional = false) { return object; } +function presentationDecision(value, label, expectedDecision) { + const decision = allowedObject(value, new Set(["decision", "consumerId", "diagnostic"]), label); + if (decision.decision !== expectedDecision || typeof decision.diagnostic !== "string" || + !PRESENTATION_DIAGNOSTIC.test(decision.diagnostic)) { + throw new Error(`${label} has an invalid mapping decision`); + } + if (expectedDecision === "unsupported") { + if (decision.consumerId !== null) throw new Error(`${label}.consumerId must be null for an unsupported boundary`); + } else if (typeof decision.consumerId !== "string" || !PRESENTATION_CONSUMER_ID.test(decision.consumerId)) { + throw new Error(`${label}.consumerId must be a bounded opaque consumer id`); + } + return decision; +} + +function exactPresentationScope(value, keys, label) { + const scope = allowedObject(value, new Set(keys), label); + for (const key of keys) presentationDecision(scope[key], `${label}.${key}`, "exact"); + return scope; +} + +function validatePresentationCapability(capability) { + const profiles = allowedObject(capability.presentationProfiles, new Set(["immersive-scene-v1"]), + "Mac Codex presentationProfiles"); + const profile = allowedObject(profiles["immersive-scene-v1"], + new Set(["profileVersion", "geometryPolicy", "sceneSemantics"]), "Mac Codex immersive-scene-v1 capability"); + const semantics = allowedObject(profile.sceneSemantics, + new Set(["scope", "surfaces", "parameters", "assetSlots"]), "Mac Codex immersive-scene-v1 sceneSemantics"); + if (profile.profileVersion !== 1 || profile.geometryPolicy !== "scene-bounded" || + semantics.scope !== "presentation-scene") { + throw new Error("Mac Codex immersive-scene-v1 capability has an invalid identity"); + } + exactPresentationScope(semantics.surfaces, SCENE_SURFACES, "Mac Codex immersive-scene-v1 surfaces"); + exactPresentationScope(semantics.parameters, SCENE_PARAMETERS, "Mac Codex immersive-scene-v1 parameters"); + exactPresentationScope(semantics.assetSlots, SCENE_ASSET_SLOTS, "Mac Codex immersive-scene-v1 assetSlots"); + const boundaries = allowedObject(capability.presentationBoundaries, new Set(PRESENTATION_BOUNDARIES), + "Mac Codex presentationBoundaries"); + for (const boundary of PRESENTATION_BOUNDARIES) { + presentationDecision(boundaries[boundary], `Mac Codex presentationBoundaries.${boundary}`, "unsupported"); + } +} + function managerCompileContext(value) { const context = allowedObject(value, MANAGER_COMPILE_CONTEXT_KEYS, "Adapter projector compile context"); const missing = [...MANAGER_COMPILE_CONTEXT_KEYS].filter((key) => !Object.hasOwn(context, key)); @@ -247,6 +297,7 @@ export async function loadAdapterCapability(file = ADAPTER_CAPABILITY_PATH) { capability.compatibility?.currentEvidence?.clientVersion !== adapterVersion) { throw new Error("Mac Codex capability does not match the frozen Adapter release identity"); } + validatePresentationCapability(capability); return capability; } @@ -492,7 +543,7 @@ function neutralInvocation(value) { throw new Error("Adapter projector invocation has an invalid Mac Codex identity"); } const identity = allowedObject(invocation.identity, new Set(["id", "name", "version"]), "Adapter projector identity"); - const core = allowedObject(invocation.sharedCore, new Set(["tokens", "background", "accessibility"]), "Adapter projector Shared Core"); + const core = allowedObject(invocation.sharedCore, new Set(["tokens", "background", "accessibility", "appearanceVariants"]), "Adapter projector Shared Core"); const profiles = allowedObject(invocation.targetProfiles, new Set([ADAPTER_ID]), "Adapter projector Target Profiles"); const assets = allowedObject(invocation.assetBindings, new Set(["background", "homeHero", "video", "atlas"]), "Adapter projector asset bindings"); const compileContext = managerCompileContext(invocation.compileContext); @@ -520,6 +571,30 @@ function visibleUnsupportedAccessibility(accessibility, projection) { }); } +function projectAppearanceVariants(value) { + if (value === undefined) return null; + const variants = allowedObject(value, new Set(["light", "dark"]), "Adapter projector Shared Core appearanceVariants"); + const result = {}; + for (const mode of ["light", "dark"]) { + const variant = allowedObject(variants[mode], new Set(["colors"]), `Adapter projector Shared Core appearanceVariants.${mode}`); + const colors = allowedObject(variant.colors, SEMANTIC_COLOR_KEYS, + `Adapter projector Shared Core appearanceVariants.${mode}.colors`); + if (Object.keys(colors).length !== SEMANTIC_COLOR_KEYS.size || + [...SEMANTIC_COLOR_KEYS].some((key) => typeof colors[key] !== "string" || !COLOR.test(colors[key].trim()))) { + throw new Error(`Adapter projector Shared Core appearanceVariants.${mode}.colors must contain every valid semantic color`); + } + result[mode] = { + colors: { text: colors.text, muted: colors.textMuted }, + semanticColors: Object.fromEntries( + Object.entries(colors) + .filter(([key]) => key !== "text" && key !== "textMuted") + .map(([key, color]) => [key, color.trim()]), + ), + }; + } + return result; +} + function compileAdmission(capability, context) { const expected = capability.compatibility?.currentEvidence ?? {}; const diagnostics = []; @@ -554,6 +629,7 @@ export async function projectThemeFamilyAdapter(value) { throw new Error(`Mac Codex capability version ${String(invocation.capabilityVersion)} is not supported`); } const tokens = allowedObject(core.tokens, new Set(["colors", "fonts", "appearance"]), "Adapter projector Shared Core tokens"); + const appearanceVariants = projectAppearanceVariants(core.appearanceVariants); const background = allowedObject(core.background, V2_BACKGROUND_KEYS, "Adapter projector Shared Core background"); const accessibility = allowedObject(core.accessibility, new Set([ "reducedMotion", "minimumTextContrast", "minimumLargeTextContrast", "preserveSystemFocusRing", "transparencyFallback", @@ -617,6 +693,10 @@ export async function projectThemeFamilyAdapter(value) { }; const result = await executeCompileRequest(request); if (result.status === "success") { + if (appearanceVariants) { + result.targetTheme.appearanceVariants = appearanceVariants; + result.targetTheme = normalizeSkinTheme(result.targetTheme, "Compiled Mac Codex theme"); + } const admission = compileAdmission(capability, compileContext); result.applyAllowed = admission.applyAllowed; result.diagnostics = [ diff --git a/adapters/mac-codex/scripts/skin-theme.mjs b/adapters/mac-codex/scripts/skin-theme.mjs index 963c296..efdee33 100644 --- a/adapters/mac-codex/scripts/skin-theme.mjs +++ b/adapters/mac-codex/scripts/skin-theme.mjs @@ -13,7 +13,7 @@ export const MAC_CODEX_TARGET_PATH = "targets/macos/theme.json"; const TOP_LEVEL_KEYS = new Set([ "kind", "id", "name", "brandSubtitle", "tagline", "projectPrefix", "projectLabel", "statusText", "quote", "image", "homeHeroImage", "backgroundVideo", "interactiveBackground", "pet", "colors", - "semanticColors", "fonts", "art", "appearance", "presentation", + "semanticColors", "appearanceVariants", "fonts", "art", "appearance", "presentation", ]); const COLOR_KEYS = new Set(["background", "panel", "panelAlt", "accent", "accentAlt", "secondary", "highlight", "text", "muted", "line"]); const SEMANTIC_COLOR_KEYS = new Set([ @@ -36,6 +36,13 @@ const DIRECTIONAL_BACKGROUND_KEYS = new Set([ "idleFrame", "origin", "scrimOpacity", ]); const IMMERSIVE_SCENE_SURFACES = new Set(["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"]); +const IMMERSIVE_SCENE_PARAMETERS = new Set([ + "density", "borderTreatment", "textureIntensity", "surfaceOpacity", + "navigationTreatment", "composerTreatment", "cardTreatment", +]); +const IMMERSIVE_SCENE_ASSET_SLOTS = new Set(["scene.backdrop"]); +const IMMERSIVE_SCENE_FALLBACK_POLICY = new Set(["unsupportedSurface", "reducedMotion"]); +const SAFE_LOCAL_SCENE_IMAGE = /^(?!.*[:/\\])[^\u0000-\u001f]+\.(?:png|jpe?g|webp)$/i; // Target packages are standalone Adapter releases. The Shared Core validates the // full declaration before projection; this local gate only accepts that fixed, @@ -52,6 +59,23 @@ function normalizePresentation(value, label) { !plainObject(presentation.parameters) || !plainObject(presentation.assetSlots) || !plainObject(presentation.fallbackPolicy)) { throw new Error(`${label} must use the validated immersive-scene-v1 envelope`); } + const parameters = presentation.parameters; + const assetSlots = presentation.assetSlots; + const fallbackPolicy = presentation.fallbackPolicy; + if (Object.keys(parameters).some((key) => !IMMERSIVE_SCENE_PARAMETERS.has(key)) || + [...IMMERSIVE_SCENE_PARAMETERS].some((key) => !Object.hasOwn(parameters, key)) || + parameters.density !== "comfortable" || parameters.borderTreatment !== "etched" || + parameters.navigationTreatment !== "framed" || parameters.composerTreatment !== "anchored" || + parameters.cardTreatment !== "elevated" || !Number.isFinite(parameters.textureIntensity) || + parameters.textureIntensity < 0 || parameters.textureIntensity > 1 || + !Number.isFinite(parameters.surfaceOpacity) || parameters.surfaceOpacity < 0 || parameters.surfaceOpacity > 1 || + Object.keys(assetSlots).some((key) => !IMMERSIVE_SCENE_ASSET_SLOTS.has(key)) || + !Object.hasOwn(assetSlots, "scene.backdrop") || typeof assetSlots["scene.backdrop"] !== "string" || + !SAFE_LOCAL_SCENE_IMAGE.test(assetSlots["scene.backdrop"]) || + Object.keys(fallbackPolicy).some((key) => !IMMERSIVE_SCENE_FALLBACK_POLICY.has(key)) || + fallbackPolicy.unsupportedSurface !== "block" || fallbackPolicy.reducedMotion !== "static") { + throw new Error(`${label} must use only bounded immersive-scene-v1 values`); + } return structuredClone(presentation); } @@ -383,6 +407,9 @@ export function normalizeSkinTheme(value, label = "Theme config") { const backgroundVideo = optionalMediaName(raw.backgroundVideo, "backgroundVideo", label); const interactiveBackground = normalizeInteractiveBackground(raw.interactiveBackground, backgroundVideo, label); const presentation = raw.presentation === undefined ? null : normalizePresentation(raw.presentation, `${label} presentation`); + if (presentation && presentation.assetSlots["scene.backdrop"] !== raw.image) { + throw new Error(`${label} presentation.scene.backdrop must bind the active theme image`); + } const configuredVideoPosterMode = rawAppearance.backgroundVideoPosterMode; if (configuredVideoPosterMode !== undefined && !["none", "image"].includes(configuredVideoPosterMode)) { throw new Error(`${label} appearance.backgroundVideoPosterMode must be "none" or "image"`); @@ -406,6 +433,13 @@ export function normalizeSkinTheme(value, label = "Theme config") { const newTaskLayout = configuredNewTaskLayout ?? (homeHeroImage ? "banner" : "cards"); const rawColors = plainObject(raw.colors) ?? {}; const rawSemanticColors = plainObject(raw.semanticColors) ?? {}; + const rawAppearanceVariants = assertAllowedObject( + raw.appearanceVariants, + new Set(["light", "dark"]), + "appearanceVariants", + label, + true, + ); const rawFonts = plainObject(raw.fonts) ?? {}; const explicitColorKeys = Object.keys(rawColors).filter((key) => [ "background", "panel", "panelAlt", "accent", "accentAlt", "secondary", @@ -425,6 +459,24 @@ export function normalizeSkinTheme(value, label = "Theme config") { light: resolvedPalette(rawColors, rawSemanticColors, "light"), dark: resolvedPalette(rawColors, rawSemanticColors, "dark"), }; + const appearanceVariants = rawAppearanceVariants === null ? null : Object.fromEntries( + ["light", "dark"].map((mode) => { + const variant = assertAllowedObject(rawAppearanceVariants[mode], new Set(["colors", "semanticColors"]), + `appearanceVariants.${mode}`, label, false); + const variantColors = assertAllowedObject(variant.colors, COLOR_KEYS, `appearanceVariants.${mode}.colors`, label, false); + const variantSemantic = assertAllowedObject(variant.semanticColors, SEMANTIC_COLOR_KEYS, + `appearanceVariants.${mode}.semanticColors`, label, false); + if (variantColors.text === undefined || variantColors.muted === undefined) { + throw new Error(`${label} appearanceVariants.${mode}.colors requires text and muted`); + } + for (const [key, value] of Object.entries(variantColors)) variantColors[key] = color(value, ""); + for (const [key, value] of Object.entries(variantSemantic)) variantSemantic[key] = color(value, ""); + if (Object.values(variantColors).some((value) => !value) || Object.values(variantSemantic).some((value) => !value)) { + throw new Error(`${label} appearanceVariants.${mode} contains an invalid color`); + } + return [mode, { colors: variantColors, semanticColors: variantSemantic }]; + }), + ); return { kind: SKIN_THEME_KIND, @@ -452,6 +504,7 @@ export function normalizeSkinTheme(value, label = "Theme config") { shellMode, explicitColorKeys, explicitSemanticColorKeys, + ...(appearanceVariants ? { appearanceVariants } : {}), resolvedPalettes, colors: { ...resolvedPalettes.dark.colors, diff --git a/adapters/mac-codex/tests/adapter-capability.test.mjs b/adapters/mac-codex/tests/adapter-capability.test.mjs index b5a949d..d1a5f5a 100644 --- a/adapters/mac-codex/tests/adapter-capability.test.mjs +++ b/adapters/mac-codex/tests/adapter-capability.test.mjs @@ -24,11 +24,11 @@ assert.equal(capability.revision, 2); assert.equal(capability.capabilityVersion, "2.0.0"); assert.equal(capability.adapterId, "mac-codex"); assert.equal(capability.adapterVersion, adapterVersion); -assert.equal(capability.adapterReleaseRevision, 2); +assert.equal(capability.adapterReleaseRevision, 3); assert.deepEqual(capability.releaseTarget, { os: "macos", arch: "arm64", - assetIdentity: `mac-codex-${adapterVersion}-r2-macos-arm64`, + assetIdentity: `mac-codex-${adapterVersion}-r3-macos-arm64`, }); assert.equal(capability.availability, "available"); assert.equal(capability.runtimeApplyAvailable, true); @@ -39,6 +39,27 @@ assert.equal(capability.compatibility.currentEvidence.clientBuild, "5702"); assert.equal(capability.compatibility.currentEvidence.surfaceCatalogId, "chatgpt-macos-26.715.71837"); assert.equal(capability.compatibility.currentEvidence.surfaceCatalogVersion, 1); assert.equal(capability.semanticContractStatus, "blocked-pending-manager-cross-validation"); +const immersiveScene = capability.presentationProfiles["immersive-scene-v1"]?.sceneSemantics; +assert.equal(immersiveScene?.scope, "presentation-scene"); +assert.deepEqual(Object.keys(immersiveScene?.surfaces ?? {}).sort(), [ + "cards", "composer", "conversation", "home", "navigation", "overlays", "shell", +]); +assert.deepEqual(Object.keys(immersiveScene?.parameters ?? {}).sort(), [ + "borderTreatment", "cardTreatment", "composerTreatment", "density", "navigationTreatment", "surfaceOpacity", "textureIntensity", +]); +assert.deepEqual(Object.keys(immersiveScene?.assetSlots ?? {}), ["scene.backdrop"]); +for (const scope of [immersiveScene?.surfaces, immersiveScene?.parameters, immersiveScene?.assetSlots]) { + for (const mapping of Object.values(scope ?? {})) { + assert.equal(mapping.decision, "exact"); + assert.match(mapping.consumerId, /^codex\.scene\./); + assert.match(mapping.diagnostic, /^scene-/); + } +} +for (const boundary of Object.values(capability.presentationBoundaries ?? {})) { + assert.equal(boundary.decision, "unsupported"); + assert.equal(boundary.consumerId, null); + assert.match(boundary.diagnostic, /^scene-/); +} assert.equal(capability.sharedCoreFieldDecisions.source, "adapter-projection.json"); assert.equal(capability.sharedCoreFieldDecisions.singleSourceOfTruth, true); assert.equal(capability.localRuntimeOverrides.editableTokens.source, "theme-style-catalog.json"); diff --git a/adapters/mac-codex/tests/adapter-release.test.mjs b/adapters/mac-codex/tests/adapter-release.test.mjs index 477a0ed..adb41cb 100644 --- a/adapters/mac-codex/tests/adapter-release.test.mjs +++ b/adapters/mac-codex/tests/adapter-release.test.mjs @@ -18,12 +18,12 @@ try { const { manifest, identity } = await loadAdapterReleaseManifest(releaseSource); const adapterVersion = (await fs.readFile(path.join(releaseSource, "VERSION"), "utf8")).trim(); - const expectedIdentity = `mac-codex-${adapterVersion}-r2-macos-arm64`; + const expectedIdentity = `mac-codex-${adapterVersion}-r3-macos-arm64`; assert.equal(manifest.kind, "mac-codex-adapter.release-manifest"); assert.equal(manifest.revision, 1, "release-manifest schema revision remains independent"); assert.equal(manifest.adapterId, "mac-codex"); assert.equal(manifest.adapterVersion, adapterVersion); - assert.equal(manifest.adapterReleaseRevision, 2); + assert.equal(manifest.adapterReleaseRevision, 3); assert(Number.isSafeInteger(manifest.adapterReleaseRevision) && manifest.adapterReleaseRevision > 0); assert.equal(identity.assetIdentity, expectedIdentity); assert.deepEqual(identity.artifacts, { @@ -45,7 +45,7 @@ try { assert.equal(result.kind, "mac-codex-adapter.release"); assert.equal(result.adapterId, "mac-codex"); assert.equal(result.adapterVersion, adapterVersion); - assert.equal(result.adapterReleaseRevision, 2); + assert.equal(result.adapterReleaseRevision, 3); assert.equal(result.os, "macos"); assert.equal(result.arch, "arm64"); assert.equal(result.assetIdentity, expectedIdentity); diff --git a/adapters/mac-codex/tests/immersive-presentation-contract.test.mjs b/adapters/mac-codex/tests/immersive-presentation-contract.test.mjs new file mode 100644 index 0000000..67511a2 --- /dev/null +++ b/adapters/mac-codex/tests/immersive-presentation-contract.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { normalizeSkinTheme } from "../scripts/skin-theme.mjs"; + +const presentation = { + profileId: "immersive-scene-v1", + profileVersion: 1, + strictness: "exact-required", + geometryPolicy: "scene-bounded", + surfaces: ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], + parameters: { + density: "comfortable", + borderTreatment: "etched", + textureIntensity: 0.36, + surfaceOpacity: 0.72, + navigationTreatment: "framed", + composerTreatment: "anchored", + cardTreatment: "elevated", + }, + assetSlots: { "scene.backdrop": "background.png" }, + fallbackPolicy: { unsupportedSurface: "block", reducedMotion: "static" }, +}; + +function theme(nextPresentation = presentation) { + return { + kind: "skin.theme", + id: "immersive-contract", + name: "Immersive Contract", + image: "background.png", + colors: {}, + semanticColors: {}, + fonts: {}, + art: {}, + appearance: {}, + presentation: nextPresentation, + }; +} + +const normalized = normalizeSkinTheme(theme()); +assert.deepEqual(normalized.presentation, presentation, "the closed Shared Core scene envelope reaches the CodeX runtime intact"); + +const unknownParameter = structuredClone(presentation); +unknownParameter.parameters.hostSelector = "main > div"; +assert.throws(() => normalizeSkinTheme(theme(unknownParameter)), /bounded immersive-scene-v1 values/); + +const wrongBackdrop = structuredClone(presentation); +wrongBackdrop.assetSlots["scene.backdrop"] = "another.png"; +assert.throws(() => normalizeSkinTheme(theme(wrongBackdrop)), /must bind the active theme image/); + +const invalidFallback = structuredClone(presentation); +invalidFallback.fallbackPolicy.reducedMotion = "play"; +assert.throws(() => normalizeSkinTheme(theme(invalidFallback)), /bounded immersive-scene-v1 values/); + +const legacy = theme(); +delete legacy.presentation; +assert.equal(normalizeSkinTheme(legacy).presentation, undefined, "themes without a presentation extension remain compatible"); + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const css = await fs.readFile(path.join(root, "assets", "skin.css"), "utf8"); +assert.match(css, /var\(--skin-main-scrim-start\) var\(--scene-surface-opacity\)/, + "the exact surfaceOpacity parameter must scale the CodeX main-content veil"); +assert.match(css, /main\.main-surface[\s\S]*?var\(--skin-main-scrim-end\) var\(--scene-surface-opacity\)/, + "the main content cannot retain an unparameterized dark overlay at 0% opacity"); +assert.match(css, /data-skin-surface-context="home"\][\s\S]*?var\(--skin-main-scrim-start\) var\(--scene-surface-opacity\)/, + "the higher-specificity home route veil must also respect the shared opacity control"); + +console.log("PASS: Mac CodeX accepts only the bounded immersive-scene-v1 envelope and maps its backdrop to the active asset."); diff --git a/adapters/mac-codex/tests/surface-admission.test.mjs b/adapters/mac-codex/tests/surface-admission.test.mjs index f145653..f03390b 100644 --- a/adapters/mac-codex/tests/surface-admission.test.mjs +++ b/adapters/mac-codex/tests/surface-admission.test.mjs @@ -61,7 +61,7 @@ oldHostFacts.version = "26.715.61943"; oldHostFacts.build = "5628"; oldHostFacts.asarSha256 = "7501dd25c22e090bb131fe3fe6423e5c3b21b7f275c7e45b86ebe00a68052c80"; const oldHost = evaluateSurfaceAdmissionFacts(oldHostFacts, verifiedCatalog); -assert.equal(oldHost.allowed, false, "26.715.71837-r2 must reject the stale 26.715.61943 host context"); +assert.equal(oldHost.allowed, false, "26.715.71837-r3 must reject the stale 26.715.61943 host context"); assert(oldHost.diagnostics.some((item) => item.code === "surface-evidence-client-version-mismatch")); const previousHostFacts = structuredClone(facts); diff --git a/adapters/mac-doubao/GOTHIC_VOID_CRUSADE_QA.md b/adapters/mac-doubao/GOTHIC_VOID_CRUSADE_QA.md new file mode 100644 index 0000000..1e9b36d --- /dev/null +++ b/adapters/mac-doubao/GOTHIC_VOID_CRUSADE_QA.md @@ -0,0 +1,55 @@ +# Gothic Void Crusade — mac-doubao QA record + +## Scope and source + +- Theme identity: `gothic-void-crusade` / `1.0.0`. +- QA package checked on 2026-07-22: + `themes/dist/gothic-void-crusade-1.0.0.cctheme`, SHA-256 + `31ada36fb16215ed29a01a8e0a004acd2fe394d73e3f2cc45bce36e8980e06b0`. +- Host: Doubao for macOS `2.19.9`, using the Manager bundle's verified Node + runtime. This record does not assert a result for other hosts or versions. + +## Adapter-owned result + +`immersive-scene-v1` now accepts only the closed, fixed profile envelope and +uses only engine-owned role attributes. It does not accept selectors, CSS, +scripts, host paths, or arbitrary layout values. + +The bounded recipe consumes: + +- `text` and `textMuted` for semantic scene text only; +- `surfaceOpacity` and `textureIntensity` as bounded scene variables; +- fixed `etched`, `framed`, `anchored`, and `elevated` recipe choices through + role-frame borders and panels; and +- `scene.backdrop` as the validated local static image. + +The renderer does **not** take ownership of native font families or metrics, +icon glyphs, control interaction, focus rings, host geometry, or animation. +Those remain explicitly host-owned/unsupported rather than being implied by +the profile's role-frame paint fidelity. + +## Live verification + +In the light-host QA session, the imported package applied and verified with +one owned style, one owned background, 92 reconciled roles, and no video or +object URL. A chat conversation, the composer input, sidebar history rows, +and the account popover were visually checked. Text and input values rendered +in the theme's cream foreground against the dark scene frame; the host popup +remained interactive and readable. The static backdrop means reduced-motion +does not initiate media playback, and the existing reduced-motion CSS path +continues to suppress optional transitions. + +## Accessibility and remaining limits + +- The exact package checked here uses action `#8F6431` with foreground + `#FFF8E9` (4.92:1). The Adapter intentionally does not repurpose these as + a generic native button fill/foreground pair. +- No host-wide dark-mode switch was changed during QA. The role recipe uses a + self-contained dark scene frame; a separately controlled dark-host visual + pass remains advisable when a non-disruptive test account/session is + available. +- Unrecognised dialogs, portals, custom controls, or DOM nodes without a + verified `data-cc-theme-doubao-role` remain native. They are not silently + claimed as themed. Any future stable role must receive its own bounded + renderer mapping, CSS consumer, and regression before its capability is + promoted. diff --git a/adapters/mac-doubao/PROJECT_MANIFEST.json b/adapters/mac-doubao/PROJECT_MANIFEST.json index af25ce8..6be2067 100644 --- a/adapters/mac-doubao/PROJECT_MANIFEST.json +++ b/adapters/mac-doubao/PROJECT_MANIFEST.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "adapterId": "mac-doubao", "adapterVersion": "2.19.9", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "platform": "macos", "architecture": "arm64", "clientBundleId": "com.bot.pc.doubao", @@ -11,9 +11,9 @@ "runtimeTransport": "loopback-cdp", "officialApplicationReadOnly": true, "release": { - "assetIdentity": "mac-doubao-2.19.9-r2-macos-arm64", - "sourceArchive": "mac-doubao-2.19.9-r2-macos-arm64.zip", - "clientArchive": "cc-theme-mac-doubao-2.19.9-r2-macos-arm64.zip", + "assetIdentity": "mac-doubao-2.19.9-r4-macos-arm64", + "sourceArchive": "mac-doubao-2.19.9-r4-macos-arm64.zip", + "clientArchive": "cc-theme-mac-doubao-2.19.9-r4-macos-arm64.zip", "publicationStatus": "unpublished-development", "overwritePolicy": "replace-unpublished-development-revision" } diff --git a/adapters/mac-doubao/assets/skin.css b/adapters/mac-doubao/assets/skin.css index a357d0f..d39f185 100644 --- a/adapters/mac-doubao/assets/skin.css +++ b/adapters/mac-doubao/assets/skin.css @@ -137,12 +137,73 @@ html[data-cc-theme-doubao-version] [data-cc-theme-doubao-role="sidebar-inner"] { /* Adapter-owned immersive scene recipe using only cataloged Doubao roles. */ html[data-cc-theme-doubao-presentation="immersive-scene-v1"] { --cc-doubao-scene-gold: #c8a55a; - --cc-doubao-scene-line: color-mix(in srgb, var(--cc-doubao-scene-gold) 32%, transparent); - --cc-doubao-scene-panel: color-mix(in srgb, var(--cc-doubao-surface-raised) 82%, transparent); + --cc-doubao-scene-surface-base: var(--cc-doubao-theme-surface-base, #0D0D0E); + --cc-doubao-scene-surface-raised: var(--cc-doubao-theme-surface-raised, #171513); + --cc-doubao-scene-sidebar-surface: var(--cc-doubao-theme-sidebar-surface, #171513); + --cc-doubao-scene-header-surface: var(--cc-doubao-theme-header-surface, #171513); + --cc-doubao-scene-scrim-start: var(--cc-doubao-theme-main-scrim-start, rgba(8, 8, 10, .84)); + --cc-doubao-scene-scrim-mid: var(--cc-doubao-theme-main-scrim-mid, rgba(12, 10, 10, .50)); + --cc-doubao-scene-scrim-end: var(--cc-doubao-theme-main-scrim-end, rgba(13, 10, 9, .18)); + --cc-doubao-scene-line: color-mix(in srgb, var(--cc-doubao-scene-gold) var(--cc-doubao-scene-texture-opacity), transparent); + --cc-doubao-scene-panel: color-mix(in srgb, var(--cc-doubao-scene-surface-raised) var(--cc-doubao-scene-panel-opacity), transparent); + --cc-doubao-scene-text: var(--cc-doubao-theme-text, #F3EAD7); + --cc-doubao-scene-muted: var(--cc-doubao-theme-muted, #B5A386); + /* Native controls keep their own dark glyphs. Put only their bounded + interaction surfaces on a light parchment instead of recoloring SVGs. */ + --cc-doubao-scene-interactive-surface: #F7F0E1; + --cc-doubao-scene-interactive-text: #241C15; + --cc-doubao-scene-interactive-muted: #6F5F4B; + /* The fixed profile values are deliberately represented as bounded paint + inputs, never as host geometry or arbitrary CSS supplied by a package. */ + --cc-doubao-scene-etched-highlight: color-mix(in srgb, #ffefc5 var(--cc-doubao-scene-etched-highlight-opacity), transparent); + --cc-doubao-scene-navigation-frame: inset 0 0 0 var(--cc-doubao-scene-density-inset) var(--cc-doubao-scene-line), inset 0 1px var(--cc-doubao-scene-etched-highlight), inset -1px 0 rgba(255, 239, 197, .05); + --cc-doubao-scene-composer-frame: 0 12px 28px rgba(0, 0, 0, .28), inset 0 0 0 var(--cc-doubao-scene-density-inset) var(--cc-doubao-scene-line), inset 0 1px var(--cc-doubao-scene-etched-highlight); + --cc-doubao-scene-card-frame: 0 14px 32px rgba(0, 0, 0, .28), inset 0 0 0 var(--cc-doubao-scene-density-inset) var(--cc-doubao-scene-line), inset 0 1px var(--cc-doubao-scene-etched-highlight); +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"][data-cc-theme-doubao-appearance="light"] { + --cc-doubao-scene-surface-base: var(--cc-doubao-light-theme-surface-base, var(--cc-doubao-theme-surface-base, #F7F0E1)); + --cc-doubao-scene-surface-raised: var(--cc-doubao-light-theme-surface-raised, var(--cc-doubao-theme-surface-raised, #FFF9ED)); + --cc-doubao-scene-sidebar-surface: var(--cc-doubao-light-theme-sidebar-surface, var(--cc-doubao-theme-sidebar-surface, #EFE4CE)); + --cc-doubao-scene-header-surface: var(--cc-doubao-light-theme-header-surface, var(--cc-doubao-theme-header-surface, #FFF9ED)); + --cc-doubao-scene-scrim-start: var(--cc-doubao-light-theme-main-scrim-start, var(--cc-doubao-theme-main-scrim-start, rgba(247, 240, 225, .94))); + --cc-doubao-scene-scrim-mid: var(--cc-doubao-light-theme-main-scrim-mid, var(--cc-doubao-theme-main-scrim-mid, rgba(247, 240, 225, .62))); + --cc-doubao-scene-scrim-end: var(--cc-doubao-light-theme-main-scrim-end, var(--cc-doubao-theme-main-scrim-end, rgba(247, 240, 225, .28))); + --cc-doubao-scene-text: var(--cc-doubao-light-theme-text, var(--cc-doubao-theme-text, #241C15)); + --cc-doubao-scene-muted: var(--cc-doubao-light-theme-muted, var(--cc-doubao-theme-muted, #6F5F4B)); + --cc-doubao-scene-interactive-surface: #FFFFFF; + --cc-doubao-scene-interactive-text: #241C15; + --cc-doubao-scene-interactive-muted: #6F5F4B; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"][data-cc-theme-doubao-appearance="dark"] { + --cc-doubao-scene-surface-base: var(--cc-doubao-dark-theme-surface-base, var(--cc-doubao-theme-surface-base, #0D0D0E)); + --cc-doubao-scene-surface-raised: var(--cc-doubao-dark-theme-surface-raised, var(--cc-doubao-theme-surface-raised, #171513)); + --cc-doubao-scene-sidebar-surface: var(--cc-doubao-dark-theme-sidebar-surface, var(--cc-doubao-theme-sidebar-surface, #171513)); + --cc-doubao-scene-header-surface: var(--cc-doubao-dark-theme-header-surface, var(--cc-doubao-theme-header-surface, #171513)); + --cc-doubao-scene-scrim-start: var(--cc-doubao-dark-theme-main-scrim-start, var(--cc-doubao-theme-main-scrim-start, rgba(8, 8, 10, .84))); + --cc-doubao-scene-scrim-mid: var(--cc-doubao-dark-theme-main-scrim-mid, var(--cc-doubao-theme-main-scrim-mid, rgba(12, 10, 10, .50))); + --cc-doubao-scene-scrim-end: var(--cc-doubao-dark-theme-main-scrim-end, var(--cc-doubao-theme-main-scrim-end, rgba(13, 10, 9, .18))); + --cc-doubao-scene-text: var(--cc-doubao-dark-theme-text, var(--cc-doubao-theme-text, #F3EAD7)); + --cc-doubao-scene-muted: var(--cc-doubao-dark-theme-muted, var(--cc-doubao-theme-muted, #B5A386)); + --cc-doubao-scene-interactive-surface: #F7F0E1; + --cc-doubao-scene-interactive-text: #241C15; + --cc-doubao-scene-interactive-muted: #6F5F4B; } html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="shell"] { - background: linear-gradient(90deg, rgba(9, 8, 9, .84), rgba(14, 11, 10, .45) 58%, rgba(13, 10, 9, .15)) !important; + background: linear-gradient(90deg, + color-mix(in srgb, var(--cc-doubao-scene-scrim-start) var(--cc-doubao-scene-panel-opacity), transparent), + color-mix(in srgb, var(--cc-doubao-scene-scrim-mid) var(--cc-doubao-scene-panel-opacity), transparent) 58%, + color-mix(in srgb, var(--cc-doubao-scene-scrim-end) var(--cc-doubao-scene-panel-opacity), transparent)) !important; +} + +html[data-cc-theme-doubao-version][data-cc-theme-doubao-presentation="immersive-scene-v1"][data-cc-theme-doubao-surface-context="home"] +[data-cc-theme-doubao-role="shell"] { + background: linear-gradient(90deg, + color-mix(in srgb, var(--cc-doubao-scene-scrim-start) var(--cc-doubao-scene-panel-opacity), transparent), + color-mix(in srgb, var(--cc-doubao-scene-scrim-mid) var(--cc-doubao-scene-panel-opacity), transparent) 58%, + color-mix(in srgb, var(--cc-doubao-scene-scrim-end) var(--cc-doubao-scene-panel-opacity), transparent)) !important; } html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( @@ -150,8 +211,17 @@ html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( [data-cc-theme-doubao-role="header-surface"], [data-cc-theme-doubao-role="right-side"] ) { + background: var(--cc-doubao-scene-panel) !important; border-color: var(--cc-doubao-scene-line) !important; - box-shadow: inset -1px 0 rgba(255, 239, 197, .05) !important; + box-shadow: var(--cc-doubao-scene-navigation-frame) !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="sidebar"] { + background: color-mix(in srgb, var(--cc-doubao-scene-sidebar-surface) var(--cc-doubao-scene-panel-opacity), var(--cc-doubao-scene-surface-base)) !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="header-surface"] { + background: color-mix(in srgb, var(--cc-doubao-scene-header-surface) var(--cc-doubao-scene-panel-opacity), var(--cc-doubao-scene-surface-base)) !important; } html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( @@ -159,7 +229,10 @@ html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( [data-cc-theme-doubao-role="message-list"], [data-cc-theme-doubao-role="chat-list"] ) { - background: linear-gradient(90deg, rgba(10, 9, 10, .46), rgba(16, 13, 11, .14)) !important; + background: linear-gradient(90deg, + color-mix(in srgb, var(--cc-doubao-scene-scrim-start) var(--cc-doubao-scene-panel-opacity), transparent), + color-mix(in srgb, var(--cc-doubao-scene-scrim-mid) var(--cc-doubao-scene-panel-opacity), transparent) 58%, + color-mix(in srgb, var(--cc-doubao-scene-scrim-end) var(--cc-doubao-scene-panel-opacity), transparent)) !important; } html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( @@ -169,7 +242,132 @@ html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( ) { background: var(--cc-doubao-scene-panel) !important; border: 1px solid var(--cc-doubao-scene-line) !important; - box-shadow: 0 14px 32px rgba(0, 0, 0, .28), inset 0 1px rgba(255, 239, 197, .05) !important; + box-shadow: var(--cc-doubao-scene-card-frame) !important; +} + +/* The immersive recipe owns only documented semantic roles. It gives the + dark scene a readable text bridge without reaching into host classes, + geometry, focus treatment, or font metrics. */ +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( + [data-cc-theme-doubao-role="sidebar"], + [data-cc-theme-doubao-role="sidebar-item"], + [data-cc-theme-doubao-role="sidebar-action"], + [data-cc-theme-doubao-role="sidebar-footer"], + [data-cc-theme-doubao-role="header-surface"], + [data-cc-theme-doubao-role="header-title"], + [data-cc-theme-doubao-role="home-title"], + [data-cc-theme-doubao-role="suggestion"], + [data-cc-theme-doubao-role="message"], + [data-cc-theme-doubao-role="assistant-content"], + [data-cc-theme-doubao-role="user-content"], + [data-cc-theme-doubao-role="composer-toolbar"], + [data-cc-theme-doubao-role="composer-action"], + [data-cc-theme-doubao-role="overlay-surface"], + [data-cc-theme-doubao-role="reference-card"], + [data-cc-theme-doubao-role="document-card"], + [data-cc-theme-doubao-role="followup-suggestion"] +) { + color: var(--cc-doubao-scene-text) !important; +} + +/* Scene text content bridge: cataloged content wrappers can contain native + descendants with a direct light-mode color. Preserve native typography and + interaction, but inherit the verified scene text color inside those bounded + semantic surfaces so a dark backdrop never leaves black home or composer + content behind. */ +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( + [data-cc-theme-doubao-role="sidebar"], + [data-cc-theme-doubao-role="header-surface"], + [data-cc-theme-doubao-role="home-title"], + [data-cc-theme-doubao-role="suggestion"], + [data-cc-theme-doubao-role="followup-suggestion"], + [data-cc-theme-doubao-role="message"], + [data-cc-theme-doubao-role="assistant-content"], + [data-cc-theme-doubao-role="user-content"], + [data-cc-theme-doubao-role="reference-card"], + [data-cc-theme-doubao-role="document-card"], + [data-cc-theme-doubao-role="overlay-surface"], + [data-cc-theme-doubao-role="composer"], + [data-cc-theme-doubao-role="composer-toolbar"], + [data-cc-theme-doubao-role="create-action"] +) :is(div, span, a, p, button, strong, em, li, h1, h2, h3, h4, h5, h6, blockquote, code) { + color: inherit !important; +} + +/* The documented create action and composer contain host-owned black icons. + Keep those controls native, but give them a contrast-safe surface rather + than placing their glyphs and text on the dark scene panel. */ +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="create-action"] { + background: var(--cc-doubao-scene-interactive-surface) !important; + color: var(--cc-doubao-scene-interactive-text) !important; + border-color: var(--cc-doubao-scene-line) !important; + box-shadow: var(--cc-doubao-scene-navigation-frame) !important; +} + +/* The create action lives inside the sidebar text bridge. Repeat the bounded + semantic override for its native descendants so the sidebar's cream text + cannot win the cascade over this light interaction surface. */ +html[data-cc-theme-doubao-presentation="immersive-scene-v1"][data-cc-theme-doubao-version] [data-cc-theme-doubao-role="create-action"], +html[data-cc-theme-doubao-presentation="immersive-scene-v1"][data-cc-theme-doubao-version] [data-cc-theme-doubao-role="create-action"] :is(div, span, a, p, button, strong, em, li, h1, h2, h3, h4, h5, h6, blockquote, code) { + color: var(--cc-doubao-scene-interactive-text) !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="sidebar-item"][data-cc-theme-doubao-state="selected"] { + background: color-mix(in srgb, var(--cc-doubao-scene-gold) 18%, var(--cc-doubao-scene-panel)) !important; + border-color: var(--cc-doubao-scene-line) !important; +} + +/* A host-selected conversation can be painted by an internal class that has + no stable accessibility state. Give every owned list item a dark, bounded + resting frame so an unmarked host selection cannot regress to white behind + the scene's cream text. The explicit selected marker above remains the + stronger affordance whenever the renderer can establish it. */ +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="sidebar-item"] { + background: color-mix(in srgb, var(--cc-doubao-scene-panel) 94%, var(--cc-doubao-scene-gold)) !important; + border-color: transparent !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( + [data-cc-theme-doubao-role="assistant-content"], + [data-cc-theme-doubao-role="user-content"], + [data-cc-theme-doubao-role="reference-card"], + [data-cc-theme-doubao-role="document-card"], + [data-cc-theme-doubao-role="overlay-surface"] +) :is(p, span, strong, em, li, h1, h2, h3, h4, h5, h6, blockquote, code, a) { + color: inherit !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="muted-text"] { + color: var(--cc-doubao-scene-muted) !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="composer"] { + background: var(--cc-doubao-scene-interactive-surface) !important; + color: var(--cc-doubao-scene-interactive-text) !important; + border: 1px solid var(--cc-doubao-scene-line) !important; + box-shadow: var(--cc-doubao-scene-composer-frame) !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="composer"] > :is(div, textarea) { + background: transparent !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="composer-input"] { + color: var(--cc-doubao-scene-interactive-text) !important; + caret-color: var(--cc-doubao-scene-interactive-text) !important; + background: transparent !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] [data-cc-theme-doubao-role="composer-input"]::placeholder { + color: var(--cc-doubao-scene-interactive-muted) !important; + opacity: 1 !important; +} + +html[data-cc-theme-doubao-presentation="immersive-scene-v1"] :is( + [data-cc-theme-doubao-role="composer-toolbar"], + [data-cc-theme-doubao-role="composer-action"] +) { + color: var(--cc-doubao-scene-interactive-text) !important; } /* Native controls, content, typography, icons, focus rings, overlays, and motion diff --git a/adapters/mac-doubao/contracts/adapter-capability.json b/adapters/mac-doubao/contracts/adapter-capability.json index 0e85b8c..35e5330 100644 --- a/adapters/mac-doubao/contracts/adapter-capability.json +++ b/adapters/mac-doubao/contracts/adapter-capability.json @@ -1,9 +1,9 @@ { "kind": "cc-theme.adapter-capability", - "revision": 4, + "revision": 5, "adapterId": "mac-doubao", "adapterVersion": "2.19.9", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "displayName": "Mac Doubao", "availability": "available", "capabilityVersion": "1.3.0", @@ -103,17 +103,54 @@ "immersive-scene-v1": { "profileVersion": 1, "geometryPolicy": "scene-bounded", - "surfaces": { - "shell": "exact", - "navigation": "exact", - "home": "exact", - "conversation": "exact", - "composer": "exact", - "cards": "exact", - "overlays": "exact" + "sceneSemantics": { + "scope": "presentation-scene", + "surfaces": { + "shell": { "decision": "exact", "consumerId": "doubao.scene.shell", "diagnostic": "scene-surface-shell-exact" }, + "navigation": { "decision": "exact", "consumerId": "doubao.scene.navigation-frame", "diagnostic": "scene-surface-navigation-exact" }, + "home": { "decision": "exact", "consumerId": "doubao.scene.home-frame", "diagnostic": "scene-surface-home-exact" }, + "conversation": { "decision": "exact", "consumerId": "doubao.scene.conversation-frame", "diagnostic": "scene-surface-conversation-exact" }, + "composer": { "decision": "exact", "consumerId": "doubao.scene.composer-frame", "diagnostic": "scene-surface-composer-exact" }, + "cards": { "decision": "exact", "consumerId": "doubao.scene.card-frame", "diagnostic": "scene-surface-cards-exact" }, + "overlays": { "decision": "exact", "consumerId": "doubao.scene.overlay-frame", "diagnostic": "scene-surface-overlays-exact" } + }, + "parameters": { + "density": { "decision": "exact", "consumerId": "doubao.scene.density-inset", "diagnostic": "scene-parameter-density-exact" }, + "borderTreatment": { "decision": "exact", "consumerId": "doubao.scene.etched-highlight", "diagnostic": "scene-parameter-border-treatment-exact" }, + "textureIntensity": { "decision": "exact", "consumerId": "doubao.scene.texture-line", "diagnostic": "scene-parameter-texture-intensity-exact" }, + "surfaceOpacity": { "decision": "exact", "consumerId": "doubao.scene.panel-opacity", "diagnostic": "scene-parameter-surface-opacity-exact" }, + "navigationTreatment": { "decision": "exact", "consumerId": "doubao.scene.navigation-frame", "diagnostic": "scene-parameter-navigation-treatment-exact" }, + "composerTreatment": { "decision": "exact", "consumerId": "doubao.scene.composer-frame", "diagnostic": "scene-parameter-composer-treatment-exact" }, + "cardTreatment": { "decision": "exact", "consumerId": "doubao.scene.card-frame", "diagnostic": "scene-parameter-card-treatment-exact" } + }, + "assetSlots": { + "scene.backdrop": { "decision": "exact", "consumerId": "doubao.scene.background", "diagnostic": "scene-asset-backdrop-exact" } + } } } }, + "presentationBoundaries": { + "nativeControls": { + "decision": "unsupported", + "consumerId": null, + "diagnostic": "scene-native-controls-host-owned" + }, + "layout": { + "decision": "unsupported", + "consumerId": null, + "diagnostic": "scene-layout-host-owned" + }, + "uncataloguedPortals": { + "decision": "unsupported", + "consumerId": null, + "diagnostic": "scene-uncatalogued-portals-unsupported" + }, + "fonts": { + "decision": "unsupported", + "consumerId": null, + "diagnostic": "scene-fonts-host-owned" + } + }, "localRuntimeOverrides": { "editableTokens": [], "baseHash": false, diff --git a/adapters/mac-doubao/contracts/adapter-release-manifest.json b/adapters/mac-doubao/contracts/adapter-release-manifest.json index e188bcd..985bcad 100644 --- a/adapters/mac-doubao/contracts/adapter-release-manifest.json +++ b/adapters/mac-doubao/contracts/adapter-release-manifest.json @@ -3,10 +3,10 @@ "revision": 1, "adapterId": "mac-doubao", "adapterVersion": "2.19.9", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "platform": "macos", "architecture": "arm64", - "assetIdentity": "mac-doubao-2.19.9-r2-macos-arm64", + "assetIdentity": "mac-doubao-2.19.9-r4-macos-arm64", "entries": [ "README.md", "PROJECT_MANIFEST.json", diff --git a/adapters/mac-doubao/contracts/theme-style-catalog.json b/adapters/mac-doubao/contracts/theme-style-catalog.json index f1cc5fe..cc6b41b 100644 --- a/adapters/mac-doubao/contracts/theme-style-catalog.json +++ b/adapters/mac-doubao/contracts/theme-style-catalog.json @@ -4,10 +4,10 @@ "catalogVersion": 4, "adapterId": "mac-doubao", "adapterVersion": "2.19.9", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "catalogId": "mac-doubao-theme", "geometryPolicy": "native", - "description": "Unified Theme media and structural translucency for Doubao. Native typography, icons, control paint, focus, borders, overlays, shadows, geometry, and motion remain host-owned.", + "description": "Unified Theme media and structural translucency for Doubao. immersive-scene-v1 additionally owns bounded role-based scene frames and readable text paint for its documented surfaces; its exact profile claim is limited to that paint/frame scope. Native font metrics, icons, control behavior, focus, geometry, and motion remain host-owned.", "groups": [ { "id": "foundation" }, { "id": "navigation" }, @@ -95,10 +95,10 @@ "icons", "control-backgrounds", "hover-and-pressed-states", - "selected-states", + "selected-state-interaction", "composer-boundary-and-focus", "suggestion-cards", - "dialogs-and-popovers", + "dialog-and-popover-controls-and-interaction", "borders-and-shadows", "animations-and-transitions", "geometry" diff --git a/adapters/mac-doubao/package.json b/adapters/mac-doubao/package.json index d08a699..91e7a1e 100644 --- a/adapters/mac-doubao/package.json +++ b/adapters/mac-doubao/package.json @@ -4,10 +4,10 @@ "ccThemeAdapter": { "adapterId": "mac-doubao", "adapterVersion": "2.19.9", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "platform": "macos", "architecture": "arm64", - "assetIdentity": "mac-doubao-2.19.9-r2-macos-arm64" + "assetIdentity": "mac-doubao-2.19.9-r4-macos-arm64" }, "private": true, "type": "module", diff --git a/adapters/mac-doubao/scripts/adapter-capability.mjs b/adapters/mac-doubao/scripts/adapter-capability.mjs index 6232ff7..02693cf 100644 --- a/adapters/mac-doubao/scripts/adapter-capability.mjs +++ b/adapters/mac-doubao/scripts/adapter-capability.mjs @@ -26,6 +26,16 @@ const COLOR_KEYS = new Set([ const REQUIRED_COLORS = ["surfaceBase", "text", "textMuted", "action", "actionForeground", "focusRing"]; const COLOR_PATTERN = /^(?:#[0-9a-f]{6}|rgba?\([0-9., %]+\))$/i; const HASH_PATTERN = /^[0-9a-f]{64}$/; +const PRESENTATION_MAPPING_DECISIONS = new Set(["exact", "approximate", "unsupported"]); +const PRESENTATION_CONSUMER_ID = /^[A-Za-z][A-Za-z0-9.-]{2,119}$/; +const PRESENTATION_DIAGNOSTIC = /^[a-z][a-z0-9-]{2,159}$/; +const SCENE_SURFACES = ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"]; +const SCENE_PARAMETERS = [ + "density", "borderTreatment", "textureIntensity", "surfaceOpacity", + "navigationTreatment", "composerTreatment", "cardTreatment", +]; +const SCENE_ASSET_SLOTS = ["scene.backdrop"]; +const PRESENTATION_BOUNDARIES = ["nativeControls", "layout", "uncataloguedPortals", "fonts"]; const plainObject = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null; @@ -42,6 +52,77 @@ function requireKeys(value, keys, label) { if (missing.length) throw new Error(`${label} requires fields: ${missing.join(", ")}`); } +function validatePresentationDecision(value, label) { + const decision = objectWithKeys(value, new Set(["decision", "consumerId", "diagnostic"]), label); + requireKeys(decision, ["decision", "consumerId", "diagnostic"], label); + if (!PRESENTATION_MAPPING_DECISIONS.has(decision.decision)) { + throw new Error(`${label}.decision is invalid`); + } + if (typeof decision.diagnostic !== "string" || !PRESENTATION_DIAGNOSTIC.test(decision.diagnostic)) { + throw new Error(`${label}.diagnostic is invalid`); + } + if (decision.decision === "unsupported") { + if (decision.consumerId !== null) throw new Error(`${label}.consumerId must be null when unsupported`); + } else if (typeof decision.consumerId !== "string" || !PRESENTATION_CONSUMER_ID.test(decision.consumerId)) { + throw new Error(`${label}.consumerId is invalid`); + } + return structuredClone(decision); +} + +function validatePresentationScope(value, keys, label) { + const scope = objectWithKeys(value, new Set(keys), label); + requireKeys(scope, keys, label); + return Object.fromEntries(keys.map((key) => [key, validatePresentationDecision(scope[key], `${label}.${key}`)])); +} + +/** + * Validates the self-contained portion of the published capability that + * describes immersive-scene-v1. It intentionally accepts only semantic + * consumer identifiers: no selectors, CSS, layout coordinates, or commands + * can appear in the capability advertisement. + */ +export function validatePresentationCapabilityMetadata(raw) { + const capability = plainObject(raw); + if (!capability) throw new Error("Adapter capability must be an object"); + const profiles = objectWithKeys(capability.presentationProfiles, new Set(["immersive-scene-v1"]), "Adapter presentationProfiles"); + requireKeys(profiles, ["immersive-scene-v1"], "Adapter presentationProfiles"); + const profile = objectWithKeys(profiles["immersive-scene-v1"], new Set([ + "profileVersion", "geometryPolicy", "sceneSemantics", + ]), "immersive-scene-v1 capability"); + requireKeys(profile, ["profileVersion", "geometryPolicy", "sceneSemantics"], "immersive-scene-v1 capability"); + if (profile.profileVersion !== 1 || profile.geometryPolicy !== "scene-bounded") { + throw new Error("immersive-scene-v1 capability has an invalid profile identity"); + } + const semantics = objectWithKeys(profile.sceneSemantics, new Set([ + "scope", "surfaces", "parameters", "assetSlots", + ]), "immersive-scene-v1 sceneSemantics"); + requireKeys(semantics, ["scope", "surfaces", "parameters", "assetSlots"], "immersive-scene-v1 sceneSemantics"); + if (semantics.scope !== "presentation-scene") { + throw new Error("immersive-scene-v1 sceneSemantics.scope is invalid"); + } + const boundaries = validatePresentationScope( + capability.presentationBoundaries, + PRESENTATION_BOUNDARIES, + "Adapter presentationBoundaries", + ); + for (const [boundary, value] of Object.entries(boundaries)) { + if (value.decision !== "unsupported" || value.consumerId !== null) { + throw new Error(`Adapter presentationBoundaries.${boundary} must be unsupported`); + } + } + return { + profileVersion: profile.profileVersion, + geometryPolicy: profile.geometryPolicy, + sceneSemantics: { + scope: semantics.scope, + surfaces: validatePresentationScope(semantics.surfaces, SCENE_SURFACES, "immersive-scene-v1 sceneSemantics.surfaces"), + parameters: validatePresentationScope(semantics.parameters, SCENE_PARAMETERS, "immersive-scene-v1 sceneSemantics.parameters"), + assetSlots: validatePresentationScope(semantics.assetSlots, SCENE_ASSET_SLOTS, "immersive-scene-v1 sceneSemantics.assetSlots"), + }, + presentationBoundaries: boundaries, + }; +} + function optionalText(value, maximum, label) { if (value !== null && (typeof value !== "string" || value.length > maximum)) { throw new Error(`${label} must be null or a bounded string`); @@ -98,7 +179,7 @@ function validateInvocation(value) { typeof identity.version !== "string" || !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(identity.version)) { throw new Error("Adapter identity is invalid"); } - const core = objectWithKeys(invocation.sharedCore, new Set(["tokens", "background", "accessibility"]), "Adapter Shared Core"); + const core = objectWithKeys(invocation.sharedCore, new Set(["tokens", "background", "accessibility", "appearanceVariants"]), "Adapter Shared Core"); requireKeys(core, ["tokens", "background", "accessibility"], "Adapter Shared Core"); const profiles = objectWithKeys(invocation.targetProfiles, new Set([ADAPTER_ID]), "Adapter Target Profiles"); requireKeys(profiles, [ADAPTER_ID], "Adapter Target Profiles"); @@ -146,6 +227,19 @@ function validateColors(value) { return colors; } +function validateAppearanceVariants(value) { + if (value === undefined) return null; + const variants = objectWithKeys(value, new Set(["light", "dark"]), "Shared Core appearanceVariants"); + for (const mode of ["light", "dark"]) { + const variant = objectWithKeys(variants[mode], new Set(["colors"]), `Shared Core appearanceVariants.${mode}`); + const colors = validateColors(variant.colors); + if (Object.keys(colors).length !== COLOR_KEYS.size) { + throw new Error(`Shared Core appearanceVariants.${mode}.colors must declare every semantic color`); + } + } + return variants; +} + function validateFonts(value) { const fonts = objectWithKeys(value, new Set(["ui", "display", "code"]), "Shared Core fonts"); if (!Object.keys(fonts).length) throw new Error("Shared Core fonts must not be empty"); @@ -224,6 +318,20 @@ function projectColors(colors, diagnostics, paletteStrategy) { return semanticColors; } +function projectAppearanceVariant(colors) { + const semanticColors = {}; + for (const key of [ + "surfaceBase", "surfaceRaised", "action", "actionForeground", "focusRing", + "sidebarSurface", "headerSurface", "mainScrimStart", "mainScrimMid", "mainScrimEnd", + ]) { + semanticColors[key] = colors[key]; + } + return { + colors: { text: colors.text, muted: colors.textMuted }, + semanticColors, + }; +} + function projectFonts(fonts, diagnostics) { for (const key of ["ui", "display", "code"]) { if (fonts[key] !== undefined) diagnostics.push(diagnostic( @@ -375,6 +483,7 @@ export async function projectThemeFamilyAdapter(value) { const tokens = objectWithKeys(core.tokens, new Set(["colors", "fonts", "appearance"]), "Shared Core tokens"); requireKeys(tokens, ["colors", "fonts"], "Shared Core tokens"); const colors = validateColors(tokens.colors); + const appearanceVariants = validateAppearanceVariants(core.appearanceVariants); const fonts = validateFonts(tokens.fonts); const appearance = validateAppearance(tokens.appearance ?? {}); const accessibility = validateAccessibility(core.accessibility); @@ -397,6 +506,12 @@ export async function projectThemeFamilyAdapter(value) { ...(background.backgroundVideo ? { backgroundVideo: background.backgroundVideo } : {}), colors: { text: colors.text, muted: colors.textMuted }, semanticColors, + ...(appearanceVariants ? { + appearanceVariants: { + light: projectAppearanceVariant(appearanceVariants.light.colors), + dark: projectAppearanceVariant(appearanceVariants.dark.colors), + }, + } : {}), ...(Object.keys(targetFonts).length ? { fonts: targetFonts } : {}), ...(Object.keys(targetAppearance).length ? { appearance: targetAppearance } : {}), }; diff --git a/adapters/mac-doubao/scripts/adapter-release.mjs b/adapters/mac-doubao/scripts/adapter-release.mjs index a8be415..32c7f85 100644 --- a/adapters/mac-doubao/scripts/adapter-release.mjs +++ b/adapters/mac-doubao/scripts/adapter-release.mjs @@ -25,8 +25,8 @@ export async function assembleAdapterRelease(destinationRoot) { const manifest = JSON.parse(await fs.readFile(path.join(sourceRoot, "contracts/adapter-release-manifest.json"), "utf8")); if (manifest.kind !== "mac-doubao-adapter.release-manifest" || manifest.revision !== 1 || manifest.adapterId !== "mac-doubao" || manifest.adapterVersion !== "2.19.9" || - manifest.adapterReleaseRevision !== 2 || manifest.platform !== "macos" || manifest.architecture !== "arm64" || - manifest.assetIdentity !== "mac-doubao-2.19.9-r2-macos-arm64" || !Array.isArray(manifest.entries)) { + manifest.adapterReleaseRevision !== 4 || manifest.platform !== "macos" || manifest.architecture !== "arm64" || + manifest.assetIdentity !== "mac-doubao-2.19.9-r4-macos-arm64" || !Array.isArray(manifest.entries)) { throw new Error("Invalid Mac Doubao Adapter release manifest"); } await fs.mkdir(destinationRoot, { recursive: true }); diff --git a/adapters/mac-doubao/scripts/injector.mjs b/adapters/mac-doubao/scripts/injector.mjs index 4555f4d..3bb9d51 100644 --- a/adapters/mac-doubao/scripts/injector.mjs +++ b/adapters/mac-doubao/scripts/injector.mjs @@ -69,7 +69,9 @@ async function readStableFile(file, label, maxBytes) { function variablesFor(theme) { const semantic = theme.semanticColors ?? {}; + const colors = theme.colors ?? {}; const appearance = theme.appearance ?? {}; + const scene = theme.presentation?.parameters; const surfaceBase = semantic.surfaceBase ?? "#10151F"; const surfaceRaised = semantic.surfaceRaised ?? semantic.sidebarSurface ?? surfaceBase; const sidebarSurface = semantic.sidebarSurface ?? surfaceRaised; @@ -77,7 +79,7 @@ function variablesFor(theme) { const scrimStart = semantic.mainScrimStart ?? `color-mix(in srgb, ${surfaceBase} 72%, transparent)`; const scrimMid = semantic.mainScrimMid ?? `color-mix(in srgb, ${surfaceBase} 54%, transparent)`; const scrimEnd = semantic.mainScrimEnd ?? `color-mix(in srgb, ${surfaceBase} 34%, transparent)`; - return { + const variables = { "--cc-doubao-theme-surface-base": surfaceBase, "--cc-doubao-theme-surface-raised": surfaceRaised, "--cc-doubao-theme-sidebar-surface": sidebarSurface, @@ -95,6 +97,36 @@ function variablesFor(theme) { "--cc-doubao-backdrop-blur": `${appearance.backdropBlurPx ?? 18}px`, "--cc-doubao-saturation": String(appearance.backdropSaturation ?? 1), }; + // A presentation can provide two complete semantic palettes. They remain + // inert for ordinary themes; the bounded immersive recipe selects one only + // after the host has established its effective light/dark appearance. + for (const mode of ["light", "dark"]) { + const variant = theme.appearanceVariants?.[mode]; + if (!variant) continue; + const variantSemantic = variant.semanticColors ?? {}; + const variantColors = variant.colors ?? {}; + const prefix = `--cc-doubao-${mode}-theme-`; + variables[`${prefix}surface-base`] = variantSemantic.surfaceBase; + variables[`${prefix}surface-raised`] = variantSemantic.surfaceRaised; + variables[`${prefix}sidebar-surface`] = variantSemantic.sidebarSurface; + variables[`${prefix}header-surface`] = variantSemantic.headerSurface; + variables[`${prefix}main-scrim-start`] = variantSemantic.mainScrimStart; + variables[`${prefix}main-scrim-mid`] = variantSemantic.mainScrimMid; + variables[`${prefix}main-scrim-end`] = variantSemantic.mainScrimEnd; + variables[`--cc-doubao-${mode}-theme-text`] = variantColors.text; + variables[`--cc-doubao-${mode}-theme-muted`] = variantColors.muted; + } + if (scene) { + variables["--cc-doubao-theme-text"] = colors.text; + variables["--cc-doubao-theme-muted"] = colors.muted; + variables["--cc-doubao-scene-panel-opacity"] = `${Math.round(scene.surfaceOpacity * 100)}%`; + variables["--cc-doubao-scene-texture-opacity"] = `${Math.round(scene.textureIntensity * 44)}%`; + // The profile permits one bounded density/border recipe only. Pass its + // concrete paint values, not CSS, selectors, or host geometry. + variables["--cc-doubao-scene-density-inset"] = scene.density === "comfortable" ? "1px" : "0px"; + variables["--cc-doubao-scene-etched-highlight-opacity"] = scene.borderTreatment === "etched" ? "5%" : "0%"; + } + return variables; } export async function loadRuntimePayload(themeDirectory) { diff --git a/adapters/mac-doubao/scripts/skin-theme.mjs b/adapters/mac-doubao/scripts/skin-theme.mjs index ea4f859..f97c536 100644 --- a/adapters/mac-doubao/scripts/skin-theme.mjs +++ b/adapters/mac-doubao/scripts/skin-theme.mjs @@ -4,7 +4,7 @@ export const ADAPTER_ID = "mac-doubao"; export const SKIN_THEME_KIND = "skin.theme"; const ROOT_KEYS = new Set([ - "kind", "id", "name", "sourceVersion", "image", "backgroundVideo", "colors", "semanticColors", "fonts", "appearance", "presentation", + "kind", "id", "name", "sourceVersion", "image", "backgroundVideo", "colors", "semanticColors", "appearanceVariants", "fonts", "appearance", "presentation", ]); const COLOR_PATTERN = /^(?:#[0-9a-f]{6}|rgba?\([0-9., %]+\))$/i; const COLOR_KEYS = new Set(["text", "muted"]); @@ -21,6 +21,14 @@ const APPEARANCE_KEYS = new Set([ "backgroundVideoPosterMode", "backgroundVideoScrimOpacity", "backgroundVideoPosition", ]); const IMMERSIVE_SCENE_SURFACES = new Set(["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"]); +const IMMERSIVE_SCENE_PRESENTATION_KEYS = new Set([ + "profileId", "profileVersion", "strictness", "geometryPolicy", "surfaces", "parameters", "assetSlots", "fallbackPolicy", +]); +const IMMERSIVE_SCENE_PARAMETER_KEYS = new Set([ + "density", "borderTreatment", "textureIntensity", "surfaceOpacity", "navigationTreatment", "composerTreatment", "cardTreatment", +]); +const IMMERSIVE_SCENE_ASSET_SLOT_KEYS = new Set(["scene.backdrop"]); +const IMMERSIVE_SCENE_FALLBACK_KEYS = new Set(["unsupportedSurface", "reducedMotion"]); const plainObject = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null; @@ -28,8 +36,7 @@ const plainObject = (value) => value && typeof value === "object" && !Array.isAr // projection; this release-local gate admits only the fixed inert envelope. function normalizePresentation(value, label) { const presentation = plainObject(value); - const allowed = new Set(["profileId", "profileVersion", "strictness", "geometryPolicy", "surfaces", "parameters", "assetSlots", "fallbackPolicy"]); - if (!presentation || Object.keys(presentation).some((key) => !allowed.has(key)) || + if (!presentation || Object.keys(presentation).some((key) => !IMMERSIVE_SCENE_PRESENTATION_KEYS.has(key)) || presentation.profileId !== "immersive-scene-v1" || presentation.profileVersion !== 1 || presentation.strictness !== "exact-required" || presentation.geometryPolicy !== "scene-bounded" || !Array.isArray(presentation.surfaces) || presentation.surfaces.length !== IMMERSIVE_SCENE_SURFACES.size || @@ -38,6 +45,22 @@ function normalizePresentation(value, label) { !plainObject(presentation.parameters) || !plainObject(presentation.assetSlots) || !plainObject(presentation.fallbackPolicy)) { throw new Error(`${label} must use the validated immersive-scene-v1 envelope`); } + const parameters = objectWithKeys(presentation.parameters, IMMERSIVE_SCENE_PARAMETER_KEYS, `${label} parameters`); + const assetSlots = objectWithKeys(presentation.assetSlots, IMMERSIVE_SCENE_ASSET_SLOT_KEYS, `${label} assetSlots`); + const fallbackPolicy = objectWithKeys(presentation.fallbackPolicy, IMMERSIVE_SCENE_FALLBACK_KEYS, `${label} fallbackPolicy`); + for (const key of IMMERSIVE_SCENE_PARAMETER_KEYS) { + if (!Object.hasOwn(parameters, key)) throw new Error(`${label} parameters requires ${key}`); + } + if (parameters.density !== "comfortable" || parameters.borderTreatment !== "etched" || + parameters.navigationTreatment !== "framed" || parameters.composerTreatment !== "anchored" || + parameters.cardTreatment !== "elevated" || !Number.isFinite(parameters.textureIntensity) || + parameters.textureIntensity < 0 || parameters.textureIntensity > 1 || + !Number.isFinite(parameters.surfaceOpacity) || parameters.surfaceOpacity < 0 || parameters.surfaceOpacity > 1 || + !Object.hasOwn(assetSlots, "scene.backdrop") || + localFile(assetSlots["scene.backdrop"], [".png", ".jpg", ".jpeg", ".webp"], `${label} assetSlots.scene.backdrop`) !== assetSlots["scene.backdrop"] || + fallbackPolicy.unsupportedSurface !== "block" || fallbackPolicy.reducedMotion !== "static") { + throw new Error(`${label} must use the validated immersive-scene-v1 values`); + } return structuredClone(presentation); } @@ -69,6 +92,25 @@ function colors(value, keys, label) { return result; } +function appearanceVariants(value) { + if (value === undefined) return null; + const variants = objectWithKeys(value, new Set(["light", "dark"]), "Theme appearanceVariants"); + const normalized = {}; + for (const mode of ["light", "dark"]) { + const variant = objectWithKeys(variants[mode], new Set(["colors", "semanticColors"]), `Theme appearanceVariants.${mode}`); + const variantColors = colors(variant.colors, COLOR_KEYS, `Theme appearanceVariants.${mode}.colors`); + const variantSemantic = colors(variant.semanticColors, SEMANTIC_COLOR_KEYS, `Theme appearanceVariants.${mode}.semanticColors`); + for (const key of COLOR_KEYS) { + if (!Object.hasOwn(variantColors, key)) throw new Error(`Theme appearanceVariants.${mode}.colors requires ${key}`); + } + for (const key of ["surfaceBase", "surfaceRaised", "action", "actionForeground", "focusRing", "sidebarSurface", "headerSurface", "mainScrimStart", "mainScrimMid", "mainScrimEnd"]) { + if (!Object.hasOwn(variantSemantic, key)) throw new Error(`Theme appearanceVariants.${mode}.semanticColors requires ${key}`); + } + normalized[mode] = { colors: variantColors, semanticColors: variantSemantic }; + } + return normalized; +} + function fonts(value) { const result = objectWithKeys(value, FONT_KEYS, "Theme fonts", true); for (const [key, families] of Object.entries(result)) { @@ -133,6 +175,7 @@ export function normalizeSkinTheme(value, label = "Doubao theme") { if (!Object.hasOwn(semanticColors, key)) throw new Error(`${label} semanticColors requires ${key}`); } fonts(theme.fonts); + const normalizedAppearanceVariants = appearanceVariants(theme.appearanceVariants); const themeAppearance = appearance(theme.appearance); const presentation = theme.presentation === undefined ? null : normalizePresentation(theme.presentation, `${label} presentation`); if (!theme.backgroundVideo && [ @@ -142,6 +185,7 @@ export function normalizeSkinTheme(value, label = "Doubao theme") { } const normalized = structuredClone(theme); if (presentation) normalized.presentation = presentation; + if (normalizedAppearanceVariants) normalized.appearanceVariants = normalizedAppearanceVariants; normalized.appearance = { ...themeAppearance, paletteStrategy: themeAppearance.paletteStrategy ?? "system", diff --git a/adapters/mac-doubao/tests/immersive-presentation-contract.test.mjs b/adapters/mac-doubao/tests/immersive-presentation-contract.test.mjs new file mode 100644 index 0000000..30c816e --- /dev/null +++ b/adapters/mac-doubao/tests/immersive-presentation-contract.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { validatePresentationProfileCapability } from "../../../app/packages/shared-core/presentation.mjs"; +import { loadAdapterRegistry } from "../../../app/packages/adapter-sdk/adapter-registry.mjs"; +import { validatePresentationCapabilityMetadata } from "../scripts/adapter-capability.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const [capability, skinCss, injector, renderer] = await Promise.all([ + fs.readFile(path.join(root, "contracts", "adapter-capability.json"), "utf8").then(JSON.parse), + fs.readFile(path.join(root, "assets", "skin.css"), "utf8"), + fs.readFile(path.join(root, "scripts", "injector.mjs"), "utf8"), + fs.readFile(path.join(root, "assets", "renderer-inject.js"), "utf8"), +]); + +const profile = capability.presentationProfiles?.["immersive-scene-v1"]; +const REQUIRED_SURFACES = ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"]; +const REQUIRED_PARAMETERS = [ + "density", "borderTreatment", "textureIntensity", "surfaceOpacity", + "navigationTreatment", "composerTreatment", "cardTreatment", +]; +const exactRecord = (consumerId, diagnostic) => ({ decision: "exact", consumerId, diagnostic }); +const PRESENTATION_BOUNDARIES = { + nativeControls: "scene-native-controls-host-owned", + layout: "scene-layout-host-owned", + uncataloguedPortals: "scene-uncatalogued-portals-unsupported", + fonts: "scene-fonts-host-owned", +}; + +test("immersive-scene capability makes its exact-required semantic scope machine-readable", () => { + assert.equal(profile.profileVersion, 1); + assert.equal(profile.geometryPolicy, "scene-bounded"); + assert.equal(profile.sceneSemantics.scope, "presentation-scene"); + for (const [scope, keys] of [ + [profile.sceneSemantics.surfaces, REQUIRED_SURFACES], + [profile.sceneSemantics.parameters, REQUIRED_PARAMETERS], + [profile.sceneSemantics.assetSlots, ["scene.backdrop"]], + ]) { + assert.deepEqual(Object.keys(scope).sort(), [...keys].sort()); + for (const key of keys) { + assert.equal(scope[key].decision, "exact", key); + assert.match(scope[key].consumerId, /^[A-Za-z][A-Za-z0-9.-]{2,119}$/, key); + assert.match(scope[key].diagnostic, /^[a-z][a-z0-9-]{2,159}$/, key); + } + } + assert.deepEqual(profile.sceneSemantics.parameters.density, exactRecord( + "doubao.scene.density-inset", "scene-parameter-density-exact", + )); +}); + +test("surface opacity scales the Doubao scene veil instead of leaving an opaque base at zero", () => { + assert.match(injector, /--cc-doubao-scene-panel-opacity.*surfaceOpacity/, + "the bounded runtime payload must expose the validated opacity"); + assert.match(skinCss, /var\(--cc-doubao-scene-scrim-start\) var\(--cc-doubao-scene-panel-opacity\)/, + "the main scene scrim must consume that opacity"); + assert.match(skinCss, /data-cc-theme-doubao-surface-context="home"\][\s\S]*?var\(--cc-doubao-scene-scrim-start\) var\(--cc-doubao-scene-panel-opacity\)/, + "the higher-specificity home surface cannot leave a separate fixed scrim behind"); + assert.doesNotMatch(skinCss, /--cc-doubao-scene-panel:[^;]*var\(--cc-doubao-scene-surface-base\)\);/, + "zero opacity must not resolve to an opaque fallback surface"); +}); + +test("capability loader and closed shared schema preserve the rich scene semantics", () => { + const local = validatePresentationCapabilityMetadata(capability); + const shared = validatePresentationProfileCapability(profile); + assert.deepEqual(local.sceneSemantics, shared.sceneSemantics); + const loaded = loadAdapterRegistry().capabilities.find(({ adapterId }) => adapterId === "mac-doubao"); + assert.deepEqual(loaded?.presentationProfiles?.["immersive-scene-v1"], profile); + + const unknownSceneField = structuredClone(capability); + unknownSceneField.presentationProfiles["immersive-scene-v1"].sceneSemantics.unsafeSelector = "#host"; + assert.throws( + () => validatePresentationCapabilityMetadata(unknownSceneField), + /unsupported fields: unsafeSelector/, + ); + const legacyString = structuredClone(capability); + legacyString.presentationProfiles["immersive-scene-v1"].sceneSemantics.parameters.cardTreatment = "exact"; + assert.throws( + () => validatePresentationCapabilityMetadata(legacyString), + /must be an object/, + ); + assert.throws( + () => validatePresentationProfileCapability(legacyString.presentationProfiles["immersive-scene-v1"]), + /must be an object/, + ); +}); + +test("presentation boundaries explicitly exclude host-owned native controls, layout, portals, and fonts", () => { + const local = validatePresentationCapabilityMetadata(capability); + assert.deepEqual(local.presentationBoundaries, capability.presentationBoundaries); + for (const [boundary, diagnostic] of Object.entries(PRESENTATION_BOUNDARIES)) { + assert.deepEqual(capability.presentationBoundaries[boundary], { + decision: "unsupported", + consumerId: null, + diagnostic, + }, boundary); + } + const unsafeBoundary = structuredClone(capability); + unsafeBoundary.presentationBoundaries.layout = { + decision: "exact", + consumerId: "doubao.scene.host-layout", + diagnostic: "scene-layout-exact", + }; + assert.throws( + () => validatePresentationCapabilityMetadata(unsafeBoundary), + /presentationBoundaries\.layout must be unsupported/, + ); + const unknownBoundary = structuredClone(capability); + unknownBoundary.presentationBoundaries.nativeControls.css = "button { color: red; }"; + assert.throws( + () => validatePresentationCapabilityMetadata(unknownBoundary), + /unsupported fields: css/, + ); +}); + +test("every exact immersive parameter has a bounded payload or role-frame consumer", () => { + for (const [parameter, payloadConsumer, roleConsumer] of [ + ["density", "--cc-doubao-scene-density-inset", "--cc-doubao-scene-navigation-frame"], + ["borderTreatment", "--cc-doubao-scene-etched-highlight-opacity", "--cc-doubao-scene-etched-highlight"], + ["textureIntensity", "--cc-doubao-scene-texture-opacity", "--cc-doubao-scene-line"], + ["surfaceOpacity", "--cc-doubao-scene-panel-opacity", "--cc-doubao-scene-panel"], + ]) { + assert.match(injector, new RegExp(parameter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), parameter); + assert.match(injector, new RegExp(payloadConsumer.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), parameter); + assert.match(skinCss, new RegExp(roleConsumer.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), parameter); + } + for (const [parameter, frame] of [ + ["navigationTreatment", "--cc-doubao-scene-navigation-frame"], + ["composerTreatment", "--cc-doubao-scene-composer-frame"], + ["cardTreatment", "--cc-doubao-scene-card-frame"], + ]) { + assert.match(skinCss, new RegExp(frame.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), parameter); + } + assert.match(renderer, /backgroundNode\.style\.backgroundImage\s*=/, "scene.backdrop must reach the owned background node"); + assert.match(skinCss, /#cc-theme-doubao-background\s*\{/, "scene.backdrop must have a bounded paint layer"); +}); + +test("native typography, control interaction, focus, and unrecognised portals remain outside the exact scene scope", () => { + const fields = capability.sharedCore.fields; + for (const font of ["ui", "display", "code"]) { + const entry = fields.find(({ source }) => source === `tokens.fonts.${font}`); + assert.equal(entry?.decision, "unsupported", font); + assert.equal(entry?.diagnostic, "host-native-typography", font); + } + for (const color of ["actionHover", "actionPressed", "hoverSurface", "pressedSurface", "selectedSurface", "selectedHoverSurface"]) { + const entry = fields.find(({ source }) => source === `tokens.colors.${color}`); + assert.equal(entry?.decision, "unsupported", color); + assert.equal(entry?.diagnostic, "host-native-control-paint", color); + } + const focus = fields.find(({ source }) => source === "accessibility.preserveSystemFocusRing"); + assert.equal(focus?.decision, "exact"); + assert.equal(focus?.diagnostic, "host-focus-preserved"); + assert.doesNotMatch(skinCss, /:focus-visible\s*\{/); + assert.doesNotMatch(skinCss, /font-family:\s*var\(--cc-doubao-theme/); + assert.match(renderer, /\["overlay-surface", ':is\(\[role="menu"\], \[role="listbox"\], \[role="dialog"\]/); +}); + +test("scene text bridge remains contrast-safe on the documented dark scene frame while arbitrary host contrast remains unclaimed", () => { + const luminance = (hex) => { + const [red, green, blue] = hex.slice(1).match(/../g).map((part) => Number.parseInt(part, 16) / 255); + const linear = (channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue); + }; + const contrast = (first, second) => { + const [light, dark] = [luminance(first), luminance(second)].sort((left, right) => right - left); + return (light + 0.05) / (dark + 0.05); + }; + + assert.ok(contrast("#F3EAD7", "#0D0D0E") >= 4.5, "scene text"); + assert.ok(contrast("#B5A386", "#0D0D0E") >= 4.5, "scene muted text"); + assert.ok(contrast("#241C15", "#F7F0E1") >= 4.5, "native-icon interaction surface text"); + for (const key of ["minimumTextContrast", "minimumLargeTextContrast"]) { + const entry = capability.sharedCore.fields.find(({ source }) => source === `accessibility.${key}`); + assert.equal(entry?.decision, "unsupported", key); + assert.equal(entry?.diagnostic, "contrast-audit-unavailable", key); + } +}); diff --git a/adapters/mac-doubao/tests/injector-payload.test.mjs b/adapters/mac-doubao/tests/injector-payload.test.mjs index 7c98cfa..afd30bb 100644 --- a/adapters/mac-doubao/tests/injector-payload.test.mjs +++ b/adapters/mac-doubao/tests/injector-payload.test.mjs @@ -107,6 +107,36 @@ test("renderer payload carries only structural theme paint and leaves host contr assert.equal(payload.variables["--cc-doubao-font-code"], undefined); }); +test("a dual-appearance scene exports bounded light and dark palette variables", async (t) => { + const directory = await fixture(); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const themePath = path.join(directory, "theme.json"); + const theme = JSON.parse(await fs.readFile(themePath, "utf8")); + const semantic = (surface, text) => ({ + surfaceBase: surface, + surfaceRaised: surface, + action: "#795021", + actionForeground: "#FFFFFF", + focusRing: "#795021", + sidebarSurface: surface, + headerSurface: surface, + mainScrimStart: surface, + mainScrimMid: surface, + mainScrimEnd: surface, + }); + theme.appearanceVariants = { + light: { colors: { text: "#241C15", muted: "#6F5F4B" }, semanticColors: semantic("#F7F0E1") }, + dark: { colors: { text: "#F3EAD7", muted: "#B5A386" }, semanticColors: semantic("#0D0D0E") }, + }; + await fs.writeFile(themePath, JSON.stringify(theme)); + + const payload = await loadRuntimePayload(directory); + assert.equal(payload.variables["--cc-doubao-light-theme-text"], "#241C15"); + assert.equal(payload.variables["--cc-doubao-dark-theme-text"], "#F3EAD7"); + assert.equal(payload.variables["--cc-doubao-light-theme-surface-base"], "#F7F0E1"); + assert.equal(payload.variables["--cc-doubao-dark-theme-surface-base"], "#0D0D0E"); +}); + test("unsupported status tokens cannot enter a normalized Doubao runtime payload", async (t) => { const directory = await fixture(); t.after(() => fs.rm(directory, { recursive: true, force: true })); @@ -168,3 +198,80 @@ test("dormant control colors do not reject a host-owned native control palette", assert.equal(payload.variables["--cc-doubao-action"], undefined); assert.equal(payload.variables["--cc-doubao-action-foreground"], undefined); }); + +test("immersive-scene payload consumes only its bounded text and numeric scene parameters", async (t) => { + const directory = await fixture(); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const themePath = path.join(directory, "theme.json"); + const theme = JSON.parse(await fs.readFile(themePath, "utf8")); + theme.presentation = { + profileId: "immersive-scene-v1", + profileVersion: 1, + strictness: "exact-required", + geometryPolicy: "scene-bounded", + surfaces: ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], + parameters: { + density: "comfortable", + borderTreatment: "etched", + textureIntensity: 0.36, + surfaceOpacity: 0.72, + navigationTreatment: "framed", + composerTreatment: "anchored", + cardTreatment: "elevated", + }, + assetSlots: { "scene.backdrop": "background.webp" }, + fallbackPolicy: { unsupportedSurface: "block", reducedMotion: "static" }, + }; + await fs.writeFile(themePath, JSON.stringify(theme)); + + const payload = await loadRuntimePayload(directory); + + assert.equal(payload.presentationProfile, "immersive-scene-v1"); + assert.equal(payload.variables["--cc-doubao-theme-text"], "#E9EEF7"); + assert.equal(payload.variables["--cc-doubao-theme-muted"], "#AAB5C5"); + assert.equal(payload.variables["--cc-doubao-scene-panel-opacity"], "72%"); + assert.equal(payload.variables["--cc-doubao-scene-texture-opacity"], "16%"); + assert.equal(payload.variables["--cc-doubao-scene-density-inset"], "1px"); + assert.equal(payload.variables["--cc-doubao-scene-etched-highlight-opacity"], "5%"); + assert.equal(payload.variables["--cc-doubao-action"], undefined); + assert.equal(payload.variables["--cc-doubao-font-ui"], undefined); +}); + +test("immersive-scene rejects unknown or unsafe presentation values before payload construction", async (t) => { + const directory = await fixture(); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const themePath = path.join(directory, "theme.json"); + const theme = JSON.parse(await fs.readFile(themePath, "utf8")); + theme.presentation = { + profileId: "immersive-scene-v1", + profileVersion: 1, + strictness: "exact-required", + geometryPolicy: "scene-bounded", + surfaces: ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], + parameters: { + density: "comfortable", + borderTreatment: "etched", + textureIntensity: 0.36, + surfaceOpacity: 0.72, + navigationTreatment: "framed", + composerTreatment: "anchored", + cardTreatment: "elevated", + }, + assetSlots: { "scene.backdrop": "background.webp" }, + fallbackPolicy: { unsupportedSurface: "block", reducedMotion: "static" }, + }; + + theme.presentation.parameters.css = "body { display: none }"; + await fs.writeFile(themePath, JSON.stringify(theme)); + await assert.rejects(() => loadRuntimePayload(directory), /presentation parameters/i); + + delete theme.presentation.parameters.css; + theme.presentation.assetSlots["scene.backdrop"] = "../outside.png"; + await fs.writeFile(themePath, JSON.stringify(theme)); + await assert.rejects(() => loadRuntimePayload(directory), /assetSlots\.scene\.backdrop/i); + + theme.presentation.assetSlots["scene.backdrop"] = "background.webp"; + theme.presentation.parameters.density = "spacious"; + await fs.writeFile(themePath, JSON.stringify(theme)); + await assert.rejects(() => loadRuntimePayload(directory), /validated immersive-scene-v1 values/i); +}); diff --git a/adapters/mac-doubao/tests/renderer-runtime.test.mjs b/adapters/mac-doubao/tests/renderer-runtime.test.mjs index fe4857e..684c75e 100644 --- a/adapters/mac-doubao/tests/renderer-runtime.test.mjs +++ b/adapters/mac-doubao/tests/renderer-runtime.test.mjs @@ -24,17 +24,36 @@ test("verified structural surfaces expose the theme media without taking control assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="composer"\]::after/); }); -test("adaptive and system palettes preserve native icons, type, interaction states, and overlays", () => { +test("adaptive and system palettes preserve native icons, type, and interaction states", () => { assert.doesNotMatch(skinCss, /:where\(#chat-route-main\) \*/); assert.doesNotMatch(skinCss, /:where\(#flow_chat_sidebar\) \*/); assert.doesNotMatch(skinCss, /:where\(svg, path\)/); assert.doesNotMatch(skinCss, /fill:\s*currentColor\s*!important/); assert.doesNotMatch(skinCss, /stroke:\s*currentColor\s*!important/); assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="create-action"\][\s\S]*?>\s*:first-child\s*>\s*svg/); - assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="overlay-surface"\]\s*\{/); assert.doesNotMatch(skinCss, /transition:\s*color 140ms/); }); +test("immersive scene consumes only cataloged role paint and its bounded parameters", () => { + for (const role of [ + "sidebar", "header-surface", "home-title", "suggestion", "assistant-content", "user-content", + "composer", "composer-input", "composer-toolbar", "composer-action", "reference-card", + "document-card", "overlay-surface", + ]) { + assert.match(skinCss, new RegExp(`data-cc-theme-doubao-role="${role}"`), role); + } + assert.match(skinCss, /--cc-doubao-scene-panel-opacity/); + assert.match(skinCss, /--cc-doubao-scene-texture-opacity/); + assert.match(skinCss, /--cc-doubao-theme-text/); + assert.match(skinCss, /--cc-doubao-theme-muted/); + assert.match(skinCss, /data-cc-theme-doubao-state="selected"/); + assert.match(skinCss, /data-cc-theme-doubao-role="sidebar-item"\] \{/); + assert.match(skinCss, /\[data-cc-theme-doubao-role="composer"\]\s*>\s*:is\(div, textarea\)/); + assert.doesNotMatch(skinCss, /font-family:\s*var\(--cc-doubao-theme/); + assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="composer"\]\s*>\s*\*/); + assert.doesNotMatch(skinCss, /html\[data-cc-theme-doubao-presentation="immersive-scene-v1"\]\s+\*/); +}); + test("host light and dark appearances own coherent native text and control palettes", () => { assert.match(skinCss, /data-cc-theme-doubao-appearance="light"/); assert.match(skinCss, /data-cc-theme-doubao-appearance="dark"/); @@ -47,10 +66,24 @@ test("host light and dark appearances own coherent native text and control palet assert.doesNotMatch(skinCss, /font-family:\s*var\(--cc-doubao-font-(?:ui|display|code)\)/); }); -test("composer, suggestions, and focus indicators remain native-owned", () => { - assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="composer"\][^{]*\{[^}]*box-shadow:/); +test("immersive scenes select a complete theme-owned palette for each host appearance", () => { + const blockFor = (mode) => skinCss.match(new RegExp( + `html\\[data-cc-theme-doubao-presentation="immersive-scene-v1"\\]\\[data-cc-theme-doubao-appearance="${mode}"\\]\\s*\\{([\\s\\S]*?)\\n\\}`, + ))?.[1] ?? ""; + const light = blockFor("light"); + const dark = blockFor("dark"); + for (const [mode, block] of [["light", light], ["dark", dark]]) { + for (const token of ["surface-base", "sidebar-surface", "header-surface", "main-scrim-start", "text", "muted"]) { + assert.match(block, new RegExp(`--cc-doubao-${mode}-theme-${token}`), `${mode}: ${token}`); + } + } + assert.match(light, /--cc-doubao-scene-interactive-surface:\s*#FFFFFF/); + assert.match(dark, /--cc-doubao-scene-interactive-surface:\s*#F7F0E1/); +}); + +test("immersive composer paint is bounded while native suggestion interaction and focus remain owned", () => { + assert.match(skinCss, /data-cc-theme-doubao-presentation="immersive-scene-v1"[\s\S]*?\[data-cc-theme-doubao-role="composer"\][^{]*\{[^}]*box-shadow:/); assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="composer"\]:focus-within/); - assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="composer-input"\][^{]*\{[^}]*caret-color:/); assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="suggestion"\][^{]*\{[^}]*border:/); assert.doesNotMatch(skinCss, /\[data-cc-theme-doubao-role="suggestion"\][^{]*\{[^}]*backdrop-filter:/); assert.doesNotMatch(skinCss, /:focus-visible\s*\{/); @@ -58,6 +91,31 @@ test("composer, suggestions, and focus indicators remain native-owned", () => { assert.doesNotMatch(skinCss, /html\[data-cc-theme-doubao-version\]\s+a\s*\{/); }); +test("immersive scene gives nested home, suggestion, and composer text an explicit readable bridge", () => { + const readableBridge = skinCss.match( + /\/\* Scene text content bridge:[\s\S]*?\)\s*:is\(div, span, a, p, button, strong, em, li, h1, h2, h3, h4, h5, h6, blockquote, code\)\s*\{[\s\S]*?\}/, + )?.[0] ?? ""; + for (const role of ["home-title", "suggestion", "followup-suggestion", "composer", "composer-toolbar", "create-action"]) { + assert.match(readableBridge, new RegExp(`data-cc-theme-doubao-role="${role}"`), role); + } + assert.match(readableBridge, /color:\s*inherit\s*!important/); + assert.match(skinCss, /\[data-cc-theme-doubao-role="composer"\]\s*>\s*:is\(div, textarea\)\s*\{\s*background:\s*transparent\s*!important/); + assert.doesNotMatch(readableBridge, /font-family|:focus-visible|backdrop-filter/); +}); + +test("immersive scene keeps native dark-icon controls on contrast-safe parchment interaction surfaces", () => { + assert.match(skinCss, /--cc-doubao-scene-interactive-surface:\s*#F7F0E1/); + assert.match(skinCss, /--cc-doubao-scene-interactive-text:\s*#241C15/); + assert.match(skinCss, /data-cc-theme-doubao-role="create-action"\]\s*\{[\s\S]*?background:\s*var\(--cc-doubao-scene-interactive-surface\)/); + assert.match( + skinCss, + /data-cc-theme-doubao-role="create-action"\]\s*:is\(div, span, a, p, button, strong, em, li, h1, h2, h3, h4, h5, h6, blockquote, code\)\s*\{[\s\S]*?color:\s*var\(--cc-doubao-scene-interactive-text\)\s*!important/, + ); + assert.match(skinCss, /data-cc-theme-doubao-role="composer"\]\s*\{[\s\S]*?background:\s*var\(--cc-doubao-scene-interactive-surface\)/); + assert.doesNotMatch(skinCss, /:where\(svg, path\)/); + assert.doesNotMatch(skinCss, /filter:\s*invert/); +}); + test("home greeting reveal mask stays native but cannot overflow onto the themed canvas", () => { const rule = skinCss.match( /html\[data-cc-theme-doubao-version\]\s+\[data-cc-theme-doubao-role="home-title"\]\s*\{([^}]*)\}/, @@ -259,7 +317,7 @@ test("deferred video source reaches playing state and is fully disposed", async }); const applied = runtime.apply({ - version: "2.19.9-r2", + version: "2.19.9-r4", generation: "fedcba9876543210", documentIdentity: "d".repeat(64), styleText: "#cc-theme-doubao-background-video { object-fit: cover; }", @@ -319,7 +377,7 @@ test("removal cancels a pending decoded-frame callback without reviving stale vi clearInterval() {}, }); runtime.apply({ - version: "2.19.9-r2", + version: "2.19.9-r4", generation: "abababababababab", documentIdentity: "9".repeat(64), styleText: "#cc-theme-doubao-background-video { object-fit: cover; }", @@ -353,7 +411,7 @@ test("deferred video transfer commits one generation-bound renderer Blob", async matchMedia() { return { matches: false, addEventListener() {}, removeEventListener() {} }; }, }); runtime.apply({ - version: "2.19.9-r2", + version: "2.19.9-r4", generation: "1234567890abcdef", documentIdentity: "e".repeat(64), styleText: "#cc-theme-doubao-background-video { object-fit: cover; }", @@ -392,7 +450,7 @@ test("deferred video transfer cannot commit after its runtime generation is remo matchMedia() { return { matches: false, addEventListener() {}, removeEventListener() {} }; }, }); runtime.apply({ - version: "2.19.9-r2", + version: "2.19.9-r4", generation: "aaaaaaaaaaaaaaaa", documentIdentity: "f".repeat(64), styleText: "#cc-theme-doubao-background-video { object-fit: cover; }", @@ -444,7 +502,7 @@ test("surface reconciliation marks new route and portal roles without leaking ob clearTimeout() {}, }); runtime.apply({ - version: "2.19.9-r2", + version: "2.19.9-r4", generation: "bbbbbbbbbbbbbbbb", documentIdentity: "1".repeat(64), styleText: "[data-cc-theme-doubao-role] { color: var(--cc-doubao-text); }", @@ -507,7 +565,7 @@ test("host appearance changes reconcile without replacing the runtime generation matchMedia() { return { matches: false, addEventListener() {}, removeEventListener() {} }; }, }); runtime.apply({ - version: "2.19.9-r2", + version: "2.19.9-r4", generation: "cdcdcdcdcdcdcdcd", documentIdentity: "8".repeat(64), styleText: "[data-cc-theme-doubao-role] { color: var(--cc-doubao-text); }", diff --git a/adapters/mac-workbuddy/CONTEXT.md b/adapters/mac-workbuddy/CONTEXT.md index 63d5a34..0e31ace 100644 --- a/adapters/mac-workbuddy/CONTEXT.md +++ b/adapters/mac-workbuddy/CONTEXT.md @@ -16,7 +16,7 @@ canonical 目录;canonical 与旧目录同时存在时只信任 canonical, 公开版本轴不使用 Adapter 自有 SemVer:`adapterVersion` 必须等于受支持 WorkBuddy 的 `CFBundleShortVersionString`,当前是 `5.2.6`。`CFBundleVersion=5.2.6` 只属于兼容证据与 Manager compile context;同一 ShortVersion 下的修复以 `adapterReleaseRevision=1` 起始递增。当前 macOS arm64 -资产身份为 `mac-workbuddy-5.2.6-r2-macos-arm64`,由根目录 `PROJECT_MANIFEST.json` 统一约束。 +资产身份为 `mac-workbuddy-5.2.6-r4-macos-arm64`,由根目录 `PROJECT_MANIFEST.json` 统一约束。 ## Theme asset ownership diff --git a/adapters/mac-workbuddy/GOTHIC_VOID_CRUSADE_QA.md b/adapters/mac-workbuddy/GOTHIC_VOID_CRUSADE_QA.md new file mode 100644 index 0000000..a1070b4 --- /dev/null +++ b/adapters/mac-workbuddy/GOTHIC_VOID_CRUSADE_QA.md @@ -0,0 +1,60 @@ +# Gothic Void Crusade — mac-workbuddy QA record + +## Scope and source + +- Theme identity: `gothic-void-crusade` / `1.0.0`. +- QA package checked on 2026-07-22: + `themes/dist/gothic-void-crusade-1.0.0.cctheme`, SHA-256 + `31ada36fb16215ed29a01a8e0a004acd2fe394d73e3f2cc45bce36e8980e06b0`. +- Host: WorkBuddy for macOS `5.2.6`. This record does not assert a result for + other WorkBuddy versions, embedded webviews, or uncatalogued documents. + +## Adapter-owned result + +`immersive-scene-v1` repeats the closed Shared Core presentation envelope in +the standalone normalizer. It accepts no selectors, CSS, scripts, host paths, +or layout directives. `scene.backdrop` must bind the validated theme image. + +The bounded renderer recipe consumes every accepted presentation parameter: + +- `density`, `borderTreatment`, `navigationTreatment`, `composerTreatment`, + and `cardTreatment` select the fixed v1 paint recipe; and +- `textureIntensity` (`0`–`1`) and `surfaceOpacity` (`0`–`1`) are + validated numeric values written into an owned style element and consumed by + the scene card, composer, navigation, and overlay paint. + +This adds paint only. It neither changes the host layout nor replaces native +window controls, focus semantics, keyboard handling, or unrecognised nodes. + +## Live verification + +A fresh restore → apply → verify session used 66 reconciled renderer roles. +The following real surfaces were checked: + +- Settings modal (`1040×720`), account popover, action menu/listbox: dark + Gothic surfaces, cream primary text, muted secondary text, etched gold edge, + and bounded shadow; +- composer (`722×70`): default/focused text, caret, placeholder, and Chinese / + English input; and +- CC Theme colour wells and text fields: normal input and focus behavior + remained usable. + +The session used the static image path, so no video playback was started. The +existing reduced-motion path remained static. Final `restore` returned the +host to native appearance and removed the injector state, watcher, and CDP +listener on port 9342. + +## Accessibility and remaining limits + +- The current source uses action `#8F6431`, hover `#795021`, pressed + `#70482B`, and foreground `#FFF8E9`. Their foreground contrast ratios are + respectively `4.921:1`, `6.658:1`, and `7.490:1`, satisfying the theme's + declared `4.5:1` minimum for these button states. +- Uncatalogued portals, third-party webviews, and nodes without a verified + `data-workbuddy-skin-role` remain native. A future surface must first obtain + stable evidence, an owned role, a bounded consumer, and a regression before + it can be represented as supported. +- This source change alters the unpublished `5.2.6-r4` Adapter bytes. It must + receive a new qualified Adapter package/release before it can enter a + signed Manager runtime; it must never overwrite an already published + immutable Adapter identity. diff --git a/adapters/mac-workbuddy/PROJECT_MANIFEST.json b/adapters/mac-workbuddy/PROJECT_MANIFEST.json index a445b35..bc39556 100644 --- a/adapters/mac-workbuddy/PROJECT_MANIFEST.json +++ b/adapters/mac-workbuddy/PROJECT_MANIFEST.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "adapterId": "mac-workbuddy", "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "platform": "macos", "architecture": "arm64", "host": { @@ -16,9 +16,9 @@ } }, "release": { - "assetIdentity": "mac-workbuddy-5.2.6-r2-macos-arm64", - "sourceArchive": "mac-workbuddy-5.2.6-r2-macos-arm64.zip", - "clientArchive": "cc-theme-mac-workbuddy-5.2.6-r2-macos-arm64.zip", + "assetIdentity": "mac-workbuddy-5.2.6-r4-macos-arm64", + "sourceArchive": "mac-workbuddy-5.2.6-r4-macos-arm64.zip", + "clientArchive": "cc-theme-mac-workbuddy-5.2.6-r4-macos-arm64.zip", "publicationStatus": "unpublished-development", "overwritePolicy": "replace-unpublished-development-revision" } diff --git a/adapters/mac-workbuddy/README.md b/adapters/mac-workbuddy/README.md index d2718dc..c74d579 100644 --- a/adapters/mac-workbuddy/README.md +++ b/adapters/mac-workbuddy/README.md @@ -18,8 +18,8 @@ DOM/CSS ownership 标记;它们属于 Adapter 私有 Implementation,不是 公开 `adapterVersion` 严格跟随宿主 `CFBundleShortVersionString`,当前为 `5.2.6`;精确 `CFBundleVersion` 只保存在兼容证据与 Manager compile context。相同宿主版本下的 Adapter 修复通过 -正整数 `adapterReleaseRevision` 区分,当前为 `2`。本轮制品身份固定为 -`mac-workbuddy-5.2.6-r2-macos-arm64`,单一机器源见 `PROJECT_MANIFEST.json`。 +正整数 `adapterReleaseRevision` 区分,当前为 `4`。本轮制品身份固定为 +`mac-workbuddy-5.2.6-r4-macos-arm64`,单一机器源见 `PROJECT_MANIFEST.json`。 ## 主题资产所有权 @@ -154,8 +154,8 @@ npm run build:source 两个构建都读取 `contracts/adapter-release-manifest.json` 的显式白名单,不再复制整个仓库。发布合同会 拒绝 `presets/`、`themes/`、`theme-sources/`、测试夹具、主题文档、`.cctheme` 与图片/视频媒体。 发布 ZIP 只包含 Adapter 固定引擎、契约、Catalog、兼容证据、生命周期脚本和中性自有 UI 资源。 -当前源码包为 `mac-workbuddy-5.2.6-r2-macos-arm64.zip`,客户端包为 -`cc-theme-mac-workbuddy-5.2.6-r2-macos-arm64.zip`。当前 r2 仍是未正式发布的开发制品,prepare +当前源码包为 `mac-workbuddy-5.2.6-r4-macos-arm64.zip`,客户端包为 +`cc-theme-mac-workbuddy-5.2.6-r4-macos-arm64.zip`。当前 r4 仍是未正式发布的开发制品,prepare 可以原位重建并使旧摘要立即失效;首次正式发布后,同 revision 不可覆盖,后续修复必须递增 `adapterReleaseRevision`。 历史 staging/ZIP 属于变更前制品,已从工作区移除且永久不可发布。 diff --git a/adapters/mac-workbuddy/assets/renderer-inject.js b/adapters/mac-workbuddy/assets/renderer-inject.js index d59e1bb..59a9bbd 100644 --- a/adapters/mac-workbuddy/assets/renderer-inject.js +++ b/adapters/mac-workbuddy/assets/renderer-inject.js @@ -30,6 +30,7 @@ const GENERATION_COUNT_KEY = "__WORKBUDDY_SKIN_GENERATION_COUNT__"; const THEME_SETTINGS_RESULT_KEY = "__WORKBUDDY_CC_THEME_SETTINGS_RESULT__"; const DEFERRED_INTERACTIVE_ATLAS_SOURCE = "workbuddy-skin:deferred-interactive-atlas"; + const sceneStaticMotionPolicy = THEME.presentation?.fallbackPolicy?.reducedMotion === "static"; const root = document.documentElement; const ROLE_ATTRIBUTE = CATALOG.interpreter?.roleAttribute || "data-workbuddy-skin-role"; const uiInterpreter = createUiInterpreter({ @@ -57,6 +58,7 @@ const previous = window[STATE_KEY]; if (previous?.revision === REVISION && previous?.videoUrl === VIDEO_URL) { + previous.enforceSceneReducedMotion?.(); previous.reconcile?.(); return previous.inspect?.() ?? { installed: true, version: VERSION, revision: REVISION }; } @@ -121,53 +123,55 @@ getComputedStyle(root).getPropertyValue(name).trim() || fallbackValue; const nativeMix = (name, fallbackValue, opacity) => `color-mix(in srgb, ${nativeColor(name, fallbackValue)} ${opacity}%, transparent)`; - const customPalette = { - background: THEME.colors.background, - panel: THEME.colors.panel, - panelAlt: THEME.colors.panelAlt, - accent: THEME.colors.accent, - accentAlt: THEME.colors.accentAlt, - secondary: THEME.colors.secondary, - highlight: THEME.colors.highlight, - text: THEME.colors.text, - textStrong: THEME.semanticColors.textStrong, - textSecondary: THEME.semanticColors.textSecondary, - textMuted: THEME.colors.muted, - textDisabled: THEME.semanticColors.textDisabled, - icon: THEME.semanticColors.iconPrimary, - iconMuted: THEME.semanticColors.iconMuted, - placeholder: THEME.semanticColors.placeholder, - line: THEME.colors.line, - borderSubtle: THEME.semanticColors.borderSubtle, - border: THEME.semanticColors.borderDefault, - surface: THEME.semanticColors.surfaceBase, - surfaceRaised: THEME.semanticColors.surfaceRaised, - surfaceElevated: THEME.semanticColors.surfaceElevated, - surfaceMuted: THEME.semanticColors.surfaceMuted, - action: THEME.semanticColors.action, - actionHover: THEME.semanticColors.actionHover, - actionPressed: THEME.semanticColors.actionPressed, - actionForeground: THEME.semanticColors.actionForeground, - hover: THEME.semanticColors.hoverSurface, - pressed: THEME.semanticColors.pressedSurface, - selected: THEME.semanticColors.selectedSurface, - focus: THEME.semanticColors.focusRing, - link: THEME.semanticColors.link, - sidebar: THEME.semanticColors.sidebarSurface, - header: THEME.semanticColors.headerSurface, - mainScrimStart: THEME.semanticColors.mainScrimStart, - mainScrimMid: THEME.semanticColors.mainScrimMid, - mainScrimEnd: THEME.semanticColors.mainScrimEnd, - composer: THEME.semanticColors.composerSurface, - overlayScrim: THEME.semanticColors.overlayScrim, - detailScrim: THEME.semanticColors.detailScrim, - shadowColor: THEME.semanticColors.shadowColor, - danger: THEME.semanticColors.danger, - divider: THEME.semanticColors.divider, - controlTrack: THEME.semanticColors.controlTrack, - controlTrackActive: THEME.semanticColors.controlTrackActive, - controlThumb: THEME.semanticColors.controlThumb, - }; + const paletteFromTheme = (theme) => ({ + background: theme.colors.background, + panel: theme.colors.panel, + panelAlt: theme.colors.panelAlt, + accent: theme.colors.accent, + accentAlt: theme.colors.accentAlt, + secondary: theme.colors.secondary, + highlight: theme.colors.highlight, + text: theme.colors.text, + textStrong: theme.semanticColors.textStrong, + textSecondary: theme.semanticColors.textSecondary, + textMuted: theme.colors.muted, + textDisabled: theme.semanticColors.textDisabled, + icon: theme.semanticColors.iconPrimary, + iconMuted: theme.semanticColors.iconMuted, + placeholder: theme.semanticColors.placeholder, + line: theme.colors.line, + borderSubtle: theme.semanticColors.borderSubtle, + border: theme.semanticColors.borderDefault, + surface: theme.semanticColors.surfaceBase, + surfaceRaised: theme.semanticColors.surfaceRaised, + surfaceElevated: theme.semanticColors.surfaceElevated, + surfaceMuted: theme.semanticColors.surfaceMuted, + action: theme.semanticColors.action, + actionHover: theme.semanticColors.actionHover, + actionPressed: theme.semanticColors.actionPressed, + actionForeground: theme.semanticColors.actionForeground, + hover: theme.semanticColors.hoverSurface, + pressed: theme.semanticColors.pressedSurface, + selected: theme.semanticColors.selectedSurface, + focus: theme.semanticColors.focusRing, + link: theme.semanticColors.link, + sidebar: theme.semanticColors.sidebarSurface, + header: theme.semanticColors.headerSurface, + mainScrimStart: theme.semanticColors.mainScrimStart, + mainScrimMid: theme.semanticColors.mainScrimMid, + mainScrimEnd: theme.semanticColors.mainScrimEnd, + composer: theme.semanticColors.composerSurface, + overlayScrim: theme.semanticColors.overlayScrim, + detailScrim: theme.semanticColors.detailScrim, + shadowColor: theme.semanticColors.shadowColor, + danger: theme.semanticColors.danger, + divider: theme.semanticColors.divider, + controlTrack: theme.semanticColors.controlTrack, + controlTrackActive: theme.semanticColors.controlTrackActive, + controlThumb: theme.semanticColors.controlThumb, + }); + const customPaletteFor = (mode) => paletteFromTheme(THEME.appearanceVariants?.[mode] ?? THEME); + const customPalette = customPaletteFor(nativeMode); const systemPaletteFor = (mode) => { const fallback = fallbackForMode(mode); return { @@ -328,7 +332,7 @@ const styleSourcesForSettings = (settings, mode = nativeShellMode()) => { const system = settings.paletteStrategy === "system"; const sources = { - palette: { ...(system ? systemPaletteFor(mode) : customPalette) }, + palette: { ...(system ? systemPaletteFor(mode) : customPaletteFor(mode)) }, layout: { ...baseLayout }, host: system ? {} : { ...customHostBridge }, }; @@ -364,8 +368,51 @@ root.classList.add("workbuddy-skin"); root.dataset.workbuddySkinVersion = VERSION; root.dataset.workbuddySkinTheme = THEME.id; - if (THEME.presentation?.profileId === "immersive-scene-v1") root.dataset.workbuddySkinPresentation = "immersive-scene-v1"; - else delete root.dataset.workbuddySkinPresentation; + const sceneBackdrop = THEME.presentation?.profileId === "immersive-scene-v1" + ? THEME.presentation.assetSlots["scene.backdrop"] : null; + let scenePresentationCss = ""; + if (THEME.presentation?.profileId === "immersive-scene-v1") { + const scene = THEME.presentation.parameters; + if (sceneBackdrop !== THEME.image) throw new Error("Immersive scene backdrop must bind the theme image"); + const sceneNumber = (value, minimum, maximum, name) => { + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) { + throw new Error(`Invalid immersive scene ${name}`); + } + return Number(value.toFixed(3)).toString(); + }; + const textureIntensity = sceneNumber(scene.textureIntensity, 0, 1, "textureIntensity"); + const surfaceOpacity = sceneNumber(scene.surfaceOpacity, 0, 1, "surfaceOpacity"); + const surfacePercent = Number(scene.surfaceOpacity * 100).toFixed(1).replace(/\.0$/, ""); + root.dataset.workbuddySkinPresentation = "immersive-scene-v1"; + root.dataset.workbuddySkinSceneDensity = scene.density; + root.dataset.workbuddySkinSceneBorderTreatment = scene.borderTreatment; + root.dataset.workbuddySkinSceneBackdrop = sceneBackdrop; + root.dataset.workbuddySkinSceneTextureIntensity = textureIntensity; + root.dataset.workbuddySkinSceneSurfaceOpacity = surfaceOpacity; + root.dataset.workbuddySkinSceneNavigationTreatment = scene.navigationTreatment; + root.dataset.workbuddySkinSceneComposerTreatment = scene.composerTreatment; + root.dataset.workbuddySkinSceneCardTreatment = scene.cardTreatment; + scenePresentationCss = `html.workbuddy-skin[data-workbuddy-skin-presentation="immersive-scene-v1"] {\n` + + ` --cc-theme-scene-density-tracking: .01em;\n` + + ` --cc-theme-scene-etched-line: color-mix(in srgb, #c8a55a 36%, transparent);\n` + + ` --cc-theme-scene-texture-alpha: ${textureIntensity};\n` + + ` --cc-theme-scene-content-opacity: ${surfacePercent}%;\n` + + ` --cc-theme-scene-surface-layer: color-mix(in srgb, var(--wbs-surface-raised) ${surfacePercent}%, transparent);\n` + + ` --cc-theme-scene-navigation-frame: color-mix(in srgb, #c8a55a 44%, transparent);\n` + + ` --cc-theme-scene-composer-anchor: linear-gradient(90deg, color-mix(in srgb, #c8a55a 18%, transparent), transparent 42%);\n` + + ` --cc-theme-scene-card-elevation: linear-gradient(140deg, rgba(200, 165, 90, var(--cc-theme-scene-texture-alpha)), transparent 38%);\n` + + `}`; + } else { + delete root.dataset.workbuddySkinPresentation; + delete root.dataset.workbuddySkinSceneDensity; + delete root.dataset.workbuddySkinSceneBorderTreatment; + delete root.dataset.workbuddySkinSceneBackdrop; + delete root.dataset.workbuddySkinSceneTextureIntensity; + delete root.dataset.workbuddySkinSceneSurfaceOpacity; + delete root.dataset.workbuddySkinSceneNavigationTreatment; + delete root.dataset.workbuddySkinSceneComposerTreatment; + delete root.dataset.workbuddySkinSceneCardTreatment; + } root.dataset.workbuddySkinMode = THEME.shellMode; root.dataset.workbuddySkinPalette = activePaletteStrategy; root.dataset.workbuddySkinNativeMode = nativeMode; @@ -381,6 +428,13 @@ style.dataset.workbuddySkinOwned = "true"; style.textContent = CSS_TEXT; (document.head || root).append(style); + if (scenePresentationCss) { + const presentationStyle = document.createElement("style"); + presentationStyle.dataset.workbuddySkinOwned = "true"; + presentationStyle.dataset.workbuddySkinPresentation = "immersive-scene-v1"; + presentationStyle.textContent = scenePresentationCss; + (document.head || root).append(presentationStyle); + } const background = document.createElement("div"); background.id = BACKGROUND_ID; @@ -397,7 +451,7 @@ let videoPlayingMs = null; let videoToggle = null; let videoUserPaused = previousVideoUserPaused; - let videoMotionOverride = previousVideoMotionOverride; + let videoMotionOverride = sceneStaticMotionPolicy ? false : previousVideoMotionOverride; let videoDisabled = previousVideoDisabled; let videoPlaybackEpoch = 0; let videoTransport = VIDEO_URL ? "rejected" : "none"; @@ -419,6 +473,7 @@ let tryThemeSettingsUpdate = () => false; let lastNativeSettingsNav = null; let updateThemeSettingsBackgroundControl = () => {}; + const reducedMotionActive = () => motionPreference.matches && (sceneStaticMotionPolicy || !videoMotionOverride); const trustedVideoResource = (() => { if (!VIDEO_URL) return null; try { @@ -555,7 +610,7 @@ return; } backgroundVideo.hidden = false; - const reduced = motionPreference.matches && !videoMotionOverride; + const reduced = reducedMotionActive(); const shouldPlay = !document.hidden && !videoUserPaused && !reduced; if (!shouldPlay) { backgroundVideo.pause(); @@ -648,7 +703,7 @@ backgroundVideo.setAttribute("playsinline", ""); backgroundVideo.setAttribute("disablepictureinpicture", ""); backgroundVideo.dataset.playbackState = videoDisabled ? "disabled" : - motionPreference.matches && !videoMotionOverride + reducedMotionActive() ? "reduced-motion" : videoUserPaused ? "paused" : "loading"; backgroundVideo.hidden = videoDisabled; backgroundVideo.addEventListener("loadeddata", () => { @@ -1509,8 +1564,12 @@ const syncNativeMode = () => { const mode = nativeShellMode(); root.dataset.workbuddySkinNativeMode = mode; - if (activePaletteStrategy !== "system") return; - uiInterpreter.applyStyleSources(styleSourcesForSettings(currentThemeSettings, mode), { resetMissing: true }); + // A variant-aware presentation has an explicit palette for each host + // appearance. Reapply its bounded variables when WorkBuddy switches + // Light/Dark, even when the selected strategy is adaptive or custom. + if (activePaletteStrategy === "system" || THEME.appearanceVariants) { + uiInterpreter.applyStyleSources(styleSourcesForSettings(currentThemeSettings, mode), { resetMissing: true }); + } }; const nativeThemeObserver = new MutationObserver(syncNativeMode); nativeThemeObserver.observe(document.body, { attributes: true, attributeFilter: ["class"] }); @@ -1562,7 +1621,7 @@ videoUserPaused, videoDisabled, documentHidden: document.hidden, - reducedMotion: motionPreference.matches && !videoMotionOverride, + reducedMotion: reducedMotionActive(), generationInstallCount, generationTimingsMs: { installed: 0, @@ -1625,6 +1684,14 @@ delete root.dataset.workbuddySkinVersion; delete root.dataset.workbuddySkinTheme; delete root.dataset.workbuddySkinPresentation; + delete root.dataset.workbuddySkinSceneDensity; + delete root.dataset.workbuddySkinSceneBorderTreatment; + delete root.dataset.workbuddySkinSceneBackdrop; + delete root.dataset.workbuddySkinSceneTextureIntensity; + delete root.dataset.workbuddySkinSceneSurfaceOpacity; + delete root.dataset.workbuddySkinSceneNavigationTreatment; + delete root.dataset.workbuddySkinSceneComposerTreatment; + delete root.dataset.workbuddySkinSceneCardTreatment; delete root.dataset.workbuddySkinMode; delete root.dataset.workbuddySkinPalette; delete root.dataset.workbuddySkinNativeMode; @@ -1652,6 +1719,13 @@ interactiveRuntimeState, interactiveController, interactiveAtlasUrl, + enforceSceneReducedMotion: () => { + if (!sceneStaticMotionPolicy) return false; + videoMotionOverride = false; + updateVideoStateObject(); + syncVideoPlayback(); + return true; + }, themeSettingsActive, themeSettingsSession, setDeferredInteractiveAtlasSource, diff --git a/adapters/mac-workbuddy/assets/skin.css b/adapters/mac-workbuddy/assets/skin.css index ccf923e..a3ff7cd 100644 --- a/adapters/mac-workbuddy/assets/skin.css +++ b/adapters/mac-workbuddy/assets/skin.css @@ -85,14 +85,17 @@ html.workbuddy-skin #workbuddy-skin-background-video[data-playback-state="disabl /* Adapter-owned immersive scene recipe: every selector is a versioned WorkBuddy Surface Role and does not replace native controls or focus. */ html.workbuddy-skin[data-workbuddy-skin-presentation="immersive-scene-v1"] [data-workbuddy-skin-role="shell"] { - background: linear-gradient(90deg, rgba(9, 8, 9, .83), rgba(14, 11, 10, .44) 57%, rgba(13, 10, 9, .15)) !important; + background: linear-gradient(90deg, + color-mix(in srgb, var(--wbs-main-scrim-start) var(--cc-theme-scene-content-opacity), transparent), + color-mix(in srgb, var(--wbs-main-scrim-mid) var(--cc-theme-scene-content-opacity), transparent) 57%, + color-mix(in srgb, var(--wbs-main-scrim-end) var(--cc-theme-scene-content-opacity), transparent)) !important; } html.workbuddy-skin:not([data-workbuddy-skin-palette="system"]):not([data-workbuddy-skin-video-state="disabled"])[data-workbuddy-skin-presentation="immersive-scene-v1"] :is( [data-workbuddy-skin-role="sidebar"], [data-workbuddy-skin-role="topbar"] ) { - background: color-mix(in srgb, var(--wbs-surface-raised) 94%, #090707) !important; + background: color-mix(in srgb, var(--wbs-surface-raised) 94%, var(--wbs-surface-base)) !important; border-color: color-mix(in srgb, #c8a55a 32%, transparent) !important; box-shadow: var(--wbs-shadow-sidebar) !important; } @@ -104,7 +107,17 @@ html.workbuddy-skin[data-workbuddy-skin-presentation="immersive-scene-v1"] :is( [data-workbuddy-skin-role="page-expert"], [data-workbuddy-skin-role="page-automation"] ) { - background: linear-gradient(90deg, rgba(10, 9, 10, .52), rgba(16, 13, 11, .22)) !important; + background: linear-gradient(90deg, + color-mix(in srgb, var(--wbs-main-scrim-start) var(--cc-theme-scene-content-opacity), transparent), + color-mix(in srgb, var(--wbs-main-scrim-mid) var(--cc-theme-scene-content-opacity), transparent) 57%, + color-mix(in srgb, var(--wbs-main-scrim-end) var(--cc-theme-scene-content-opacity), transparent)) !important; +} + +html.workbuddy-skin[data-workbuddy-skin-presentation="immersive-scene-v1"] #workbuddy-skin-background-scrim { + background: linear-gradient(115deg, + color-mix(in srgb, var(--wbs-main-scrim-start) var(--cc-theme-scene-content-opacity), transparent) 0%, + color-mix(in srgb, var(--wbs-main-scrim-mid) var(--cc-theme-scene-content-opacity), transparent) 54%, + color-mix(in srgb, var(--wbs-main-scrim-end) var(--cc-theme-scene-content-opacity), transparent) 100%) !important; } html.workbuddy-skin[data-workbuddy-skin-presentation="immersive-scene-v1"] :is( @@ -1471,6 +1484,57 @@ html.workbuddy-skin #root [data-workbuddy-skin-role="sidebar-nav-row"].active border-color: transparent !important; } +/* + * immersive-scene-v1 is a bounded, paint-only recipe. The renderer writes + * these values only after the standalone normalizer has accepted the bounded + * profile contract; arbitrary theme CSS or geometry never reaches the host. + */ +html.workbuddy-skin:not([data-workbuddy-skin-palette="system"])[data-workbuddy-skin-presentation="immersive-scene-v1"][data-workbuddy-skin-scene-density="comfortable"] #root + [data-workbuddy-skin-role="home-heading"] { + letter-spacing: var(--cc-theme-scene-density-tracking, normal) !important; +} + +html.workbuddy-skin:not([data-workbuddy-skin-palette="system"]):not([data-workbuddy-skin-video-state="disabled"])[data-workbuddy-skin-presentation="immersive-scene-v1"][data-workbuddy-skin-scene-border-treatment="etched"][data-workbuddy-skin-scene-navigation-treatment="framed"] #root :is( + [data-workbuddy-skin-role="sidebar-nav-row"], + [data-workbuddy-skin-role="sidebar-nav-action"] +) { + outline: 1px solid var(--cc-theme-scene-navigation-frame, transparent); + outline-offset: -1px; +} + +html.workbuddy-skin:not([data-workbuddy-skin-palette="system"])[data-workbuddy-skin-presentation="immersive-scene-v1"][data-workbuddy-skin-scene-border-treatment="etched"][data-workbuddy-skin-scene-composer-treatment="anchored"] #root + [data-workbuddy-skin-role="composer"] { + background-color: var(--cc-theme-scene-surface-layer, var(--wbs-composer)) !important; + background-image: var(--cc-theme-scene-composer-anchor, none) !important; + border-color: var(--cc-theme-scene-etched-line, var(--wbs-border)) !important; +} + +html.workbuddy-skin:not([data-workbuddy-skin-palette="system"])[data-workbuddy-skin-presentation="immersive-scene-v1"][data-workbuddy-skin-scene-border-treatment="etched"][data-workbuddy-skin-scene-card-treatment="elevated"] #root :is( + [data-workbuddy-skin-role="quick-action"], + [data-workbuddy-skin-role="project-card"], + [data-workbuddy-skin-role="project-template-card"], + [data-workbuddy-skin-role="expert-scene-card"], + [data-workbuddy-skin-role="expert-card"], + [data-workbuddy-skin-role="automation-template-card"], + [data-workbuddy-skin-role="artifact-card"] +) { + background-color: var(--cc-theme-scene-surface-layer, var(--wbs-surface-raised)) !important; + background-image: var(--cc-theme-scene-card-elevation, none) !important; + border-color: var(--cc-theme-scene-etched-line, var(--wbs-border-subtle)) !important; +} + +html.workbuddy-skin:not([data-workbuddy-skin-palette="system"]):not([data-workbuddy-skin-video-state="disabled"])[data-workbuddy-skin-presentation="immersive-scene-v1"][data-workbuddy-skin-scene-border-treatment="etched"] #root :is( + [data-workbuddy-skin-role="overlay-popover"], + [data-workbuddy-skin-role="overlay-account"], + [data-workbuddy-skin-role="overlay-search-modal"], + [data-workbuddy-skin-role="overlay-settings-modal"], + [data-workbuddy-skin-role="overlay-create-modal"], + [data-workbuddy-skin-role="overlay-listbox"] +) { + background-color: var(--cc-theme-scene-surface-layer, var(--wbs-surface-elevated)) !important; + border-color: var(--cc-theme-scene-etched-line, var(--wbs-border)) !important; +} + @media (prefers-reduced-motion: reduce) { html.workbuddy-skin #workbuddy-skin-background-video { display: none !important; diff --git a/adapters/mac-workbuddy/compatibility/workbuddy-macos/5.2.6/ui-surface-catalog.json b/adapters/mac-workbuddy/compatibility/workbuddy-macos/5.2.6/ui-surface-catalog.json index 91e60b1..de52358 100644 --- a/adapters/mac-workbuddy/compatibility/workbuddy-macos/5.2.6/ui-surface-catalog.json +++ b/adapters/mac-workbuddy/compatibility/workbuddy-macos/5.2.6/ui-surface-catalog.json @@ -3,7 +3,7 @@ "catalogVersion": 2, "adapter": "mac-workbuddy", "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "capturedAt": "2026-07-18", "capture": { "mode": "read-only native UI traversal plus CDP computed-style audit", diff --git a/adapters/mac-workbuddy/contracts/adapter-capability.json b/adapters/mac-workbuddy/contracts/adapter-capability.json index c49119b..10ed00e 100644 --- a/adapters/mac-workbuddy/contracts/adapter-capability.json +++ b/adapters/mac-workbuddy/contracts/adapter-capability.json @@ -4,7 +4,7 @@ "capabilityVersion": 1, "adapterId": "mac-workbuddy", "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "platform": "macos", "architecture": "arm64", "available": true, @@ -110,17 +110,38 @@ "immersive-scene-v1": { "profileVersion": 1, "geometryPolicy": "scene-bounded", - "surfaces": { - "shell": "exact", - "navigation": "exact", - "home": "exact", - "conversation": "exact", - "composer": "exact", - "cards": "exact", - "overlays": "exact" + "sceneSemantics": { + "scope": "presentation-scene", + "surfaces": { + "shell": { "decision": "exact", "consumerId": "workbuddy.presentation.surface.shell.paint", "diagnostic": "immersive-scene-shell-exact" }, + "navigation": { "decision": "exact", "consumerId": "workbuddy.presentation.surface.navigation.paint", "diagnostic": "immersive-scene-navigation-exact" }, + "home": { "decision": "exact", "consumerId": "workbuddy.presentation.surface.home.paint", "diagnostic": "immersive-scene-home-exact" }, + "conversation": { "decision": "exact", "consumerId": "workbuddy.presentation.surface.conversation.paint", "diagnostic": "immersive-scene-conversation-exact" }, + "composer": { "decision": "exact", "consumerId": "workbuddy.presentation.surface.composer.paint", "diagnostic": "immersive-scene-composer-exact" }, + "cards": { "decision": "exact", "consumerId": "workbuddy.presentation.surface.cards.paint", "diagnostic": "immersive-scene-cards-exact" }, + "overlays": { "decision": "exact", "consumerId": "workbuddy.presentation.surface.overlays.paint", "diagnostic": "immersive-scene-overlays-exact" } + }, + "parameters": { + "density": { "decision": "exact", "consumerId": "workbuddy.presentation.parameter.density.heading-tracking", "diagnostic": "immersive-scene-density-exact" }, + "borderTreatment": { "decision": "exact", "consumerId": "workbuddy.presentation.parameter.border-treatment.etched-frame", "diagnostic": "immersive-scene-border-treatment-exact" }, + "textureIntensity": { "decision": "exact", "consumerId": "workbuddy.presentation.parameter.texture-intensity.card-elevation", "diagnostic": "immersive-scene-texture-intensity-exact" }, + "surfaceOpacity": { "decision": "exact", "consumerId": "workbuddy.presentation.parameter.surface-opacity.scene-layer", "diagnostic": "immersive-scene-surface-opacity-exact" }, + "navigationTreatment": { "decision": "exact", "consumerId": "workbuddy.presentation.parameter.navigation-treatment.framed-nav", "diagnostic": "immersive-scene-navigation-treatment-exact" }, + "composerTreatment": { "decision": "exact", "consumerId": "workbuddy.presentation.parameter.composer-treatment.anchored-composer", "diagnostic": "immersive-scene-composer-treatment-exact" }, + "cardTreatment": { "decision": "exact", "consumerId": "workbuddy.presentation.parameter.card-treatment.elevated-cards", "diagnostic": "immersive-scene-card-treatment-exact" } + }, + "assetSlots": { + "scene.backdrop": { "decision": "exact", "consumerId": "workbuddy.presentation.asset.backdrop.runtime-background", "diagnostic": "immersive-scene-backdrop-exact" } + } } } }, + "presentationBoundaries": { + "nativeControls": { "decision": "unsupported", "consumerId": null, "diagnostic": "native-controls-host-owned" }, + "layout": { "decision": "unsupported", "consumerId": null, "diagnostic": "native-layout-preserved" }, + "uncataloguedPortals": { "decision": "unsupported", "consumerId": null, "diagnostic": "uncatalogued-portal-unsupported" }, + "fonts": { "decision": "unsupported", "consumerId": null, "diagnostic": "presentation-font-override-unsupported" } + }, "localRuntimeOverrides": { "kind": "theme.runtime-settings", "schema": "contracts/theme-runtime-settings.schema.json", diff --git a/adapters/mac-workbuddy/contracts/adapter-capability.schema.json b/adapters/mac-workbuddy/contracts/adapter-capability.schema.json index 2862b8e..fe5c8c6 100644 --- a/adapters/mac-workbuddy/contracts/adapter-capability.schema.json +++ b/adapters/mac-workbuddy/contracts/adapter-capability.schema.json @@ -8,7 +8,7 @@ "kind", "schemaVersion", "capabilityVersion", "adapterId", "adapterVersion", "adapterReleaseRevision", "platform", "architecture", "available", "runtimeApplyAvailable", "catalogs", "compatibility", "sharedCore", - "targetProfile", "localRuntimeOverrides", "paletteStrategy", "transactionSeam", "migration" + "targetProfile", "presentationProfiles", "presentationBoundaries", "localRuntimeOverrides", "paletteStrategy", "transactionSeam", "migration" ], "properties": { "kind": { "const": "cc-theme.adapter-capability" }, @@ -16,7 +16,7 @@ "capabilityVersion": { "type": "integer", "const": 1 }, "adapterId": { "const": "mac-workbuddy" }, "adapterVersion": { "const": "5.2.6" }, - "adapterReleaseRevision": { "type": "integer", "const": 2 }, + "adapterReleaseRevision": { "type": "integer", "const": 4 }, "platform": { "const": "macos" }, "architecture": { "const": "arm64" }, "available": { "const": true }, @@ -94,6 +94,44 @@ } } }, + "presentationProfiles": { + "type": "object", + "additionalProperties": false, + "required": ["immersive-scene-v1"], + "properties": { + "immersive-scene-v1": { + "type": "object", + "additionalProperties": false, + "required": ["profileVersion", "geometryPolicy", "sceneSemantics"], + "properties": { + "profileVersion": { "const": 1 }, + "geometryPolicy": { "const": "scene-bounded" }, + "sceneSemantics": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "surfaces", "parameters", "assetSlots"], + "properties": { + "scope": { "const": "presentation-scene" }, + "surfaces": { "$ref": "#/$defs/immersiveSurfaceDecisions" }, + "parameters": { "$ref": "#/$defs/immersiveParameterDecisions" }, + "assetSlots": { "$ref": "#/$defs/immersiveAssetSlotDecisions" } + } + } + } + } + } + }, + "presentationBoundaries": { + "type": "object", + "additionalProperties": false, + "required": ["nativeControls", "layout", "uncataloguedPortals", "fonts"], + "properties": { + "nativeControls": { "$ref": "#/$defs/unsupportedPresentationBoundary" }, + "layout": { "$ref": "#/$defs/unsupportedPresentationBoundary" }, + "uncataloguedPortals": { "$ref": "#/$defs/unsupportedPresentationBoundary" }, + "fonts": { "$ref": "#/$defs/unsupportedPresentationBoundary" } + } + }, "localRuntimeOverrides": { "type": "object", "additionalProperties": false, @@ -146,6 +184,60 @@ } }, "$defs": { + "exactPresentationDecision": { + "type": "object", + "additionalProperties": false, + "required": ["decision", "consumerId", "diagnostic"], + "properties": { + "decision": { "const": "exact" }, + "consumerId": { "type": "string", "pattern": "^workbuddy\\.presentation\\.(surface|parameter|asset)\\.[a-z0-9.-]{1,120}$" }, + "diagnostic": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,79}$" } + } + }, + "immersiveSurfaceDecisions": { + "type": "object", + "additionalProperties": false, + "required": ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], + "properties": { + "shell": { "$ref": "#/$defs/exactPresentationDecision" }, + "navigation": { "$ref": "#/$defs/exactPresentationDecision" }, + "home": { "$ref": "#/$defs/exactPresentationDecision" }, + "conversation": { "$ref": "#/$defs/exactPresentationDecision" }, + "composer": { "$ref": "#/$defs/exactPresentationDecision" }, + "cards": { "$ref": "#/$defs/exactPresentationDecision" }, + "overlays": { "$ref": "#/$defs/exactPresentationDecision" } + } + }, + "immersiveParameterDecisions": { + "type": "object", + "additionalProperties": false, + "required": ["density", "borderTreatment", "textureIntensity", "surfaceOpacity", "navigationTreatment", "composerTreatment", "cardTreatment"], + "properties": { + "density": { "$ref": "#/$defs/exactPresentationDecision" }, + "borderTreatment": { "$ref": "#/$defs/exactPresentationDecision" }, + "textureIntensity": { "$ref": "#/$defs/exactPresentationDecision" }, + "surfaceOpacity": { "$ref": "#/$defs/exactPresentationDecision" }, + "navigationTreatment": { "$ref": "#/$defs/exactPresentationDecision" }, + "composerTreatment": { "$ref": "#/$defs/exactPresentationDecision" }, + "cardTreatment": { "$ref": "#/$defs/exactPresentationDecision" } + } + }, + "immersiveAssetSlotDecisions": { + "type": "object", + "additionalProperties": false, + "required": ["scene.backdrop"], + "properties": { "scene.backdrop": { "$ref": "#/$defs/exactPresentationDecision" } } + }, + "unsupportedPresentationBoundary": { + "type": "object", + "additionalProperties": false, + "required": ["decision", "consumerId", "diagnostic"], + "properties": { + "decision": { "const": "unsupported" }, + "consumerId": { "type": "null" }, + "diagnostic": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,79}$" } + } + }, "safeRelative": { "type": "string", "minLength": 3, diff --git a/adapters/mac-workbuddy/contracts/adapter-release-manifest.json b/adapters/mac-workbuddy/contracts/adapter-release-manifest.json index ac6c09d..a5aa389 100644 --- a/adapters/mac-workbuddy/contracts/adapter-release-manifest.json +++ b/adapters/mac-workbuddy/contracts/adapter-release-manifest.json @@ -3,10 +3,10 @@ "revision": 1, "adapterId": "mac-workbuddy", "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "platform": "macos", "architecture": "arm64", - "assetIdentity": "mac-workbuddy-5.2.6-r2-macos-arm64", + "assetIdentity": "mac-workbuddy-5.2.6-r4-macos-arm64", "entries": [ "VERSION", "PROJECT_MANIFEST.json", diff --git a/adapters/mac-workbuddy/contracts/cc-theme-package.json b/adapters/mac-workbuddy/contracts/cc-theme-package.json index f164bf4..8b53fec 100644 --- a/adapters/mac-workbuddy/contracts/cc-theme-package.json +++ b/adapters/mac-workbuddy/contracts/cc-theme-package.json @@ -13,7 +13,7 @@ "version": "5.2.6", "adapterId": "mac-workbuddy", "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "adapterContract": "skin.theme", "targetPath": "targets/macos-workbuddy/theme.json" } diff --git a/adapters/mac-workbuddy/contracts/skin-theme.schema.json b/adapters/mac-workbuddy/contracts/skin-theme.schema.json index 25f101a..ac4f0ff 100644 --- a/adapters/mac-workbuddy/contracts/skin-theme.schema.json +++ b/adapters/mac-workbuddy/contracts/skin-theme.schema.json @@ -168,7 +168,7 @@ "properties": { "density": { "const": "comfortable" }, "borderTreatment": { "const": "etched" }, "textureIntensity": { "type": "number", "minimum": 0, "maximum": 1 }, - "surfaceOpacity": { "type": "number", "minimum": 0.4, "maximum": 0.88 }, + "surfaceOpacity": { "type": "number", "minimum": 0, "maximum": 1 }, "navigationTreatment": { "const": "framed" }, "composerTreatment": { "const": "anchored" }, "cardTreatment": { "const": "elevated" } } }, diff --git a/adapters/mac-workbuddy/contracts/theme-settings-locales.json b/adapters/mac-workbuddy/contracts/theme-settings-locales.json index fa01de8..bebf075 100644 --- a/adapters/mac-workbuddy/contracts/theme-settings-locales.json +++ b/adapters/mac-workbuddy/contracts/theme-settings-locales.json @@ -4,7 +4,7 @@ "catalogVersion": 3, "adapter": "mac-workbuddy", "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "host": { "application": "WorkBuddy", "version": "5.2.6", diff --git a/adapters/mac-workbuddy/contracts/theme-style-catalog.json b/adapters/mac-workbuddy/contracts/theme-style-catalog.json index 090d098..21db513 100644 --- a/adapters/mac-workbuddy/contracts/theme-style-catalog.json +++ b/adapters/mac-workbuddy/contracts/theme-style-catalog.json @@ -4,7 +4,7 @@ "catalogVersion": 2, "adapterId": "mac-workbuddy", "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "catalogId": "mac-workbuddy-theme", "geometryPolicy": "native", "description": "Paint-only bindings consumed by the fixed WorkBuddy UI interpreter. DOM geometry remains host-owned.", diff --git a/adapters/mac-workbuddy/docs/ARCHITECTURE.md b/adapters/mac-workbuddy/docs/ARCHITECTURE.md index ac4f3c4..e133a14 100644 --- a/adapters/mac-workbuddy/docs/ARCHITECTURE.md +++ b/adapters/mac-workbuddy/docs/ARCHITECTURE.md @@ -4,9 +4,9 @@ Adapter 源码的唯一位置是 `adapters/mac-workbuddy`。公开 canonical 机器身份是 `mac-workbuddy`。 目录迁移不改变 WorkBuddy target、`.cctheme` 内的 `targets/macos-workbuddy/theme.json`,也不改变 -公开制品命名规则。当前资产身份是 `mac-workbuddy-5.2.6-r2-macos-arm64`,源码与客户端 ZIP 分别为 -`mac-workbuddy-5.2.6-r2-macos-arm64.zip` 与 -`cc-theme-mac-workbuddy-5.2.6-r2-macos-arm64.zip`。 +公开制品命名规则。当前资产身份是 `mac-workbuddy-5.2.6-r4-macos-arm64`,源码与客户端 ZIP 分别为 +`mac-workbuddy-5.2.6-r4-macos-arm64.zip` 与 +`cc-theme-mac-workbuddy-5.2.6-r4-macos-arm64.zip`。 源码内路径从 Adapter 自身位置解析;跨 Module 依赖必须使用当前 monorepo 布局并由合同测试约束。 客户端 ZIP 中的 `.mac-workbuddy` 是发布 Implementation 的私有运行目录,与源码路径相互独立且刻意稳定。 @@ -122,7 +122,7 @@ renderer session nonce 的职责是隔离不同 injector 进程并使旧 generat `PROJECT_MANIFEST.json` 是发布身份的机器源。公开 `adapterVersion` 严格等于受支持宿主的 `CFBundleShortVersionString`,当前为 `5.2.6`;精确 build 只保存在 `host.compatibilityEvidence` 与 Manager compile context,不能拼入版本号。同一 ShortVersion 下的修复使用正整数 -`adapterReleaseRevision`,当前为 `2`。Capability、三个 Catalog、package contract 和 release manifest +`adapterReleaseRevision`,当前为 `4`。Capability、三个 Catalog、package contract 和 release manifest 必须与该身份一致,否则构建在组装前失败。构建以原子 lock 协调。当前 r2 被机器清单明确标记为 `unpublished-development`,因此 Manager prepare 可以原位原子重建且旧摘要立即失效;首次正式发布时 切换为不可覆盖策略,此后同 revision 的 ZIP 或 SHA-256 sidecar 已存在即 fail closed。 diff --git a/adapters/mac-workbuddy/docs/UI-SURFACE-CATALOG.md b/adapters/mac-workbuddy/docs/UI-SURFACE-CATALOG.md index 3260935..84110b7 100644 --- a/adapters/mac-workbuddy/docs/UI-SURFACE-CATALOG.md +++ b/adapters/mac-workbuddy/docs/UI-SURFACE-CATALOG.md @@ -5,7 +5,7 @@ ## 采集基线 - WorkBuddy `5.2.6` / Electron `37.10.3` -- Adapter: `mac-workbuddy` `5.2.6` release `r2` +- Adapter: `mac-workbuddy` `5.2.6` release `r4` - 原生主题:`light` - 可视状态:`55` - 计算样式快照:`65` diff --git a/adapters/mac-workbuddy/package.json b/adapters/mac-workbuddy/package.json index aea1d3e..4dcfb7c 100644 --- a/adapters/mac-workbuddy/package.json +++ b/adapters/mac-workbuddy/package.json @@ -34,7 +34,7 @@ "ccThemeAdapter": { "adapterId": "mac-workbuddy", "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, + "adapterReleaseRevision": 4, "platform": "macos", "architecture": "arm64" }, diff --git a/adapters/mac-workbuddy/scripts/adapter-capability.mjs b/adapters/mac-workbuddy/scripts/adapter-capability.mjs index 287fcc0..a7a6c6f 100644 --- a/adapters/mac-workbuddy/scripts/adapter-capability.mjs +++ b/adapters/mac-workbuddy/scripts/adapter-capability.mjs @@ -15,7 +15,7 @@ export const ADAPTER_CAPABILITY_KIND = "cc-theme.adapter-capability"; export const TARGET_PROFILE_KIND = "cc-theme.target-profile"; export const ADAPTER_ID = "mac-workbuddy"; export const ADAPTER_VERSION = "5.2.6"; -export const ADAPTER_RELEASE_REVISION = 2; +export const ADAPTER_RELEASE_REVISION = 4; export const ADAPTER_PLATFORM = "macos"; export const ADAPTER_ARCHITECTURE = "arm64"; @@ -35,7 +35,7 @@ export function validateAdapterCapability(value, label = "WorkBuddy adapter capa const capability = exactKeys(value, [ "kind", "schemaVersion", "capabilityVersion", "adapterId", "adapterVersion", "adapterReleaseRevision", "platform", "architecture", "available", "runtimeApplyAvailable", - "catalogs", "compatibility", "sharedCore", "targetProfile", "presentationProfiles", "localRuntimeOverrides", "paletteStrategy", + "catalogs", "compatibility", "sharedCore", "targetProfile", "presentationProfiles", "presentationBoundaries", "localRuntimeOverrides", "paletteStrategy", "transactionSeam", "migration", ], label); if (capability.kind !== ADAPTER_CAPABILITY_KIND || capability.schemaVersion !== 1 || @@ -119,12 +119,33 @@ export function validateAdapterCapability(value, label = "WorkBuddy adapter capa profileIds.add(field.id); } const presentationProfiles = exactKeys(capability.presentationProfiles, ["immersive-scene-v1"], `${label}.presentationProfiles`); - const immersive = exactKeys(presentationProfiles["immersive-scene-v1"], ["profileVersion", "geometryPolicy", "surfaces"], `${label}.presentationProfiles.immersive-scene-v1`); - const sceneSurfaces = exactKeys(immersive.surfaces, ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], `${label}.presentationProfiles.immersive-scene-v1.surfaces`); - if (immersive.profileVersion !== 1 || immersive.geometryPolicy !== "scene-bounded" || - Object.values(sceneSurfaces).some((value) => value !== "exact")) { + const immersive = exactKeys(presentationProfiles["immersive-scene-v1"], ["profileVersion", "geometryPolicy", "sceneSemantics"], `${label}.presentationProfiles.immersive-scene-v1`); + const sceneSemantics = exactKeys(immersive.sceneSemantics, ["scope", "surfaces", "parameters", "assetSlots"], `${label}.presentationProfiles.immersive-scene-v1.sceneSemantics`); + const exactSceneDecisions = (value, keys, scope) => { + const decisions = exactKeys(value, keys, `${label}.presentationProfiles.immersive-scene-v1.sceneSemantics.${scope}`); + for (const [name, declaration] of Object.entries(decisions)) { + const entry = exactKeys(declaration, ["decision", "consumerId", "diagnostic"], `${label}.presentationProfiles.immersive-scene-v1.sceneSemantics.${scope}.${name}`); + if (entry.decision !== "exact" || typeof entry.consumerId !== "string" || + !/^workbuddy\.presentation\.(?:surface|parameter|asset)\.[a-z0-9.-]{1,120}$/.test(entry.consumerId) || + typeof entry.diagnostic !== "string" || !/^[a-z][a-z0-9-]{0,79}$/.test(entry.diagnostic)) { + throw new Error(`${label} requires exact immersive scene semantics for ${scope}.${name}`); + } + } + return decisions; + }; + exactSceneDecisions(sceneSemantics.surfaces, ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], "surfaces"); + exactSceneDecisions(sceneSemantics.parameters, ["density", "borderTreatment", "textureIntensity", "surfaceOpacity", "navigationTreatment", "composerTreatment", "cardTreatment"], "parameters"); + exactSceneDecisions(sceneSemantics.assetSlots, ["scene.backdrop"], "assetSlots"); + if (immersive.profileVersion !== 1 || immersive.geometryPolicy !== "scene-bounded" || sceneSemantics.scope !== "presentation-scene") { throw new Error(`${label} has an invalid immersive scene capability declaration`); } + const presentationBoundaries = exactKeys(capability.presentationBoundaries, ["nativeControls", "layout", "uncataloguedPortals", "fonts"], `${label}.presentationBoundaries`); + for (const [scope, declaration] of Object.entries(presentationBoundaries)) { + const boundary = exactKeys(declaration, ["decision", "consumerId", "diagnostic"], `${label}.presentationBoundaries.${scope}`); + if (boundary.decision !== "unsupported" || boundary.consumerId !== null || typeof boundary.diagnostic !== "string" || !/^[a-z][a-z0-9-]{0,79}$/.test(boundary.diagnostic)) { + throw new Error(`${label} must keep ${scope} outside the immersive exact scope`); + } + } const localRuntimeOverrides = exactKeys(capability.localRuntimeOverrides, [ "kind", "schema", "schemaVersion", "catalog", "catalogVersion", "editableTokens", "rebase", "incompatible", ], `${label}.localRuntimeOverrides`); diff --git a/adapters/mac-workbuddy/scripts/adapter-release.mjs b/adapters/mac-workbuddy/scripts/adapter-release.mjs index b6f837c..cbbac3e 100644 --- a/adapters/mac-workbuddy/scripts/adapter-release.mjs +++ b/adapters/mac-workbuddy/scripts/adapter-release.mjs @@ -105,8 +105,8 @@ export async function assembleAdapterRelease(destinationRoot, { sourceRoot = pro const raw = JSON.parse(await fs.readFile(manifestPath, "utf8")); if (raw?.kind !== "workbuddy-adapter.release-manifest" || raw?.revision !== 1 || raw?.adapterId !== "mac-workbuddy" || raw?.adapterVersion !== "5.2.6" || - raw?.adapterReleaseRevision !== 2 || raw?.platform !== "macos" || raw?.architecture !== "arm64" || - raw?.assetIdentity !== "mac-workbuddy-5.2.6-r2-macos-arm64" || !Array.isArray(raw.entries)) { + raw?.adapterReleaseRevision !== 4 || raw?.platform !== "macos" || raw?.architecture !== "arm64" || + raw?.assetIdentity !== "mac-workbuddy-5.2.6-r4-macos-arm64" || !Array.isArray(raw.entries)) { throw new Error("Invalid WorkBuddy Adapter release manifest"); } await fs.mkdir(destinationRoot, { recursive: true }); diff --git a/adapters/mac-workbuddy/scripts/skin-theme.mjs b/adapters/mac-workbuddy/scripts/skin-theme.mjs index b0e3865..9400d0f 100644 --- a/adapters/mac-workbuddy/scripts/skin-theme.mjs +++ b/adapters/mac-workbuddy/scripts/skin-theme.mjs @@ -52,7 +52,7 @@ const DIRECTIONAL_KEYS = new Set([ ]); const TOP_LEVEL_KEYS = new Set([ "kind", "id", "name", "image", "backgroundVideo", "interactiveBackground", - "colors", "semanticColors", "fonts", "appearance", "presentation", + "colors", "semanticColors", "appearanceVariants", "fonts", "appearance", "presentation", ]); const COLOR_KEYS = new Set([ "background", "panel", "panelAlt", "accent", "accentAlt", "secondary", "highlight", "text", "muted", "line", @@ -70,9 +70,17 @@ const APPEARANCE_KEYS = new Set([ "backgroundScrimOpacity", "backdropBlurPx", "backdropSaturation", "radiusScale", ]); const IMMERSIVE_SCENE_SURFACES = new Set(["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"]); +const IMMERSIVE_SCENE_PARAMETER_KEYS = new Set([ + "density", "borderTreatment", "textureIntensity", "surfaceOpacity", + "navigationTreatment", "composerTreatment", "cardTreatment", +]); +const IMMERSIVE_SCENE_ASSET_SLOT_KEYS = new Set(["scene.backdrop"]); +const IMMERSIVE_SCENE_FALLBACK_KEYS = new Set(["unsupportedSurface", "reducedMotion"]); +const SAFE_LOCAL_SCENE_IMAGE = /^(?!.*[:/\\])[^\u0000-\u001f]+\.(?:png|jpe?g|webp)$/i; // Adapter releases are standalone. Shared Core owns detailed validation before -// projection; this release-local gate admits only the fixed inert envelope. +// projection; this release-local gate repeats the closed profile boundary so a +// standalone Engine cannot turn profile data into a host-specific payload. function normalizePresentation(value, label) { const presentation = plainObject(value); const allowed = new Set(["profileId", "profileVersion", "strictness", "geometryPolicy", "surfaces", "parameters", "assetSlots", "fallbackPolicy"]); @@ -85,6 +93,36 @@ function normalizePresentation(value, label) { !plainObject(presentation.parameters) || !plainObject(presentation.assetSlots) || !plainObject(presentation.fallbackPolicy)) { throw new Error(`${label} must use the validated immersive-scene-v1 envelope`); } + const parameters = presentation.parameters; + const assetSlots = presentation.assetSlots; + const fallbackPolicy = presentation.fallbackPolicy; + rejectUnsupportedKeys(parameters, IMMERSIVE_SCENE_PARAMETER_KEYS, `${label}.parameters`); + rejectUnsupportedKeys(assetSlots, IMMERSIVE_SCENE_ASSET_SLOT_KEYS, `${label}.assetSlots`); + rejectUnsupportedKeys(fallbackPolicy, IMMERSIVE_SCENE_FALLBACK_KEYS, `${label}.fallbackPolicy`); + for (const [group, required] of [ + ["parameters", IMMERSIVE_SCENE_PARAMETER_KEYS], + ["assetSlots", IMMERSIVE_SCENE_ASSET_SLOT_KEYS], + ["fallbackPolicy", IMMERSIVE_SCENE_FALLBACK_KEYS], + ]) { + const missing = [...required].filter((key) => !Object.hasOwn(presentation[group], key)); + if (missing.length) throw new Error(`${label}.${group} is missing required fields: ${missing.join(", ")}`); + } + if (parameters.density !== "comfortable") throw new Error(`${label}.parameters.density must be comfortable`); + if (parameters.borderTreatment !== "etched") throw new Error(`${label}.parameters.borderTreatment must be etched`); + if (!Number.isFinite(parameters.textureIntensity) || parameters.textureIntensity < 0 || parameters.textureIntensity > 1) { + throw new Error(`${label}.parameters.textureIntensity must be a number from 0 to 1`); + } + if (!Number.isFinite(parameters.surfaceOpacity) || parameters.surfaceOpacity < 0 || parameters.surfaceOpacity > 1) { + throw new Error(`${label}.parameters.surfaceOpacity must be a number from 0 to 1`); + } + if (parameters.navigationTreatment !== "framed") throw new Error(`${label}.parameters.navigationTreatment must be framed`); + if (parameters.composerTreatment !== "anchored") throw new Error(`${label}.parameters.composerTreatment must be anchored`); + if (parameters.cardTreatment !== "elevated") throw new Error(`${label}.parameters.cardTreatment must be elevated`); + if (typeof assetSlots["scene.backdrop"] !== "string" || !SAFE_LOCAL_SCENE_IMAGE.test(assetSlots["scene.backdrop"])) { + throw new Error(`${label}.assetSlots.scene.backdrop must be a safe local image filename`); + } + if (fallbackPolicy.unsupportedSurface !== "block") throw new Error(`${label}.fallbackPolicy.unsupportedSurface must be block`); + if (fallbackPolicy.reducedMotion !== "static") throw new Error(`${label}.fallbackPolicy.reducedMotion must be static`); return structuredClone(presentation); } @@ -236,6 +274,9 @@ export function normalizeSkinTheme(value, label = "Theme config") { const backgroundVideo = optionalMediaName(raw.backgroundVideo, "backgroundVideo", label); const interactiveBackground = normalizeInteractiveBackground(raw.interactiveBackground, backgroundVideo, label); const presentation = raw.presentation === undefined ? null : normalizePresentation(raw.presentation, `${label} presentation`); + if (presentation && presentation.assetSlots["scene.backdrop"] !== raw.image) { + throw new Error(`${label} presentation.scene.backdrop must bind the theme image`); + } const backgroundVideoPosterMode = appearance.backgroundVideoPosterMode ?? "image"; if (!["none", "image"].includes(backgroundVideoPosterMode)) { throw new Error(`${label} appearance.backgroundVideoPosterMode must be none or image`); @@ -244,7 +285,7 @@ export function normalizeSkinTheme(value, label = "Theme config") { throw new Error(`${label} appearance.backgroundVideoPosterMode requires backgroundVideo`); } - return { + const normalized = { kind: SKIN_THEME_KIND, adapter: MAC_WORKBUDDY_ADAPTER_ID, id: raw.id, @@ -325,4 +366,38 @@ export function normalizeSkinTheme(value, label = "Theme config") { backgroundScrimOpacity: boundedNumber(appearance.backgroundScrimOpacity, 1, 0, 1), }, }; + if (raw.appearanceVariants !== undefined) { + const variants = plainObject(raw.appearanceVariants); + if (!variants || Object.keys(variants).some((key) => key !== "light" && key !== "dark")) { + throw new Error(`${label} appearanceVariants must contain only light and dark`); + } + normalized.appearanceVariants = {}; + for (const mode of ["light", "dark"]) { + const variant = plainObject(variants[mode]); + if (!variant || Object.keys(variant).some((key) => key !== "colors" && key !== "semanticColors") || + !plainObject(variant.colors) || !plainObject(variant.semanticColors)) { + throw new Error(`${label} appearanceVariants.${mode} must contain colors and semanticColors`); + } + const palette = normalizeSkinTheme({ + kind: SKIN_THEME_KIND, + id: raw.id, + name: raw.name, + image: raw.image, + // A palette variant contains paint only, but the outer validated + // theme still owns the media contract. Preserve the bounded video + // presence so a legitimate poster policy is not misread as a + // dangling appearance field while normalizing the variant. + backgroundVideo: raw.backgroundVideo, + colors: variant.colors, + semanticColors: variant.semanticColors, + fonts: raw.fonts, + appearance: raw.appearance, + }, `${label} appearanceVariants.${mode}`); + normalized.appearanceVariants[mode] = { + colors: palette.colors, + semanticColors: palette.semanticColors, + }; + } + } + return normalized; } diff --git a/adapters/mac-workbuddy/scripts/theme-settings-locales.mjs b/adapters/mac-workbuddy/scripts/theme-settings-locales.mjs index eafd5c6..6d29fad 100644 --- a/adapters/mac-workbuddy/scripts/theme-settings-locales.mjs +++ b/adapters/mac-workbuddy/scripts/theme-settings-locales.mjs @@ -18,7 +18,7 @@ export function validateThemeSettingsLocales(value, label = "Theme Settings Loca if (!contract || contract.kind !== THEME_SETTINGS_LOCALES_KIND || contract.schemaVersion !== 2 || contract.catalogVersion !== 3 || contract.adapter !== "mac-workbuddy" || contract.adapterVersion !== "5.2.6" || - contract.adapterReleaseRevision !== 2) { + contract.adapterReleaseRevision !== 4) { throw new Error(`${label} has an invalid identity`); } if (!Array.isArray(contract.locales) || contract.locales.length < 2 || diff --git a/adapters/mac-workbuddy/scripts/theme-style-catalog.mjs b/adapters/mac-workbuddy/scripts/theme-style-catalog.mjs index cf41d77..6917a02 100644 --- a/adapters/mac-workbuddy/scripts/theme-style-catalog.mjs +++ b/adapters/mac-workbuddy/scripts/theme-style-catalog.mjs @@ -20,7 +20,7 @@ export function validateThemeStyleCatalog(value, runtimeRoles, label = "Theme St const catalog = plainObject(value); if (!catalog || catalog.kind !== STYLE_CATALOG_KIND || catalog.schemaVersion !== 1 || catalog.catalogVersion !== 2 || catalog.adapterId !== "mac-workbuddy" || - catalog.adapterVersion !== "5.2.6" || catalog.adapterReleaseRevision !== 2 || + catalog.adapterVersion !== "5.2.6" || catalog.adapterReleaseRevision !== 4 || catalog.catalogId !== "mac-workbuddy-theme") { throw new Error(`${label} has an invalid identity`); } diff --git a/adapters/mac-workbuddy/scripts/ui-surface-catalog.mjs b/adapters/mac-workbuddy/scripts/ui-surface-catalog.mjs index b86ebe3..9304509 100644 --- a/adapters/mac-workbuddy/scripts/ui-surface-catalog.mjs +++ b/adapters/mac-workbuddy/scripts/ui-surface-catalog.mjs @@ -41,7 +41,7 @@ export function validateUiSurfaceCatalog(catalog) { add(catalog.adapter === "mac-workbuddy", "adapter must be mac-workbuddy"); add(catalog.adapterVersion === DEFAULT_VERSION, `adapterVersion must match the supported WorkBuddy ShortVersion ${DEFAULT_VERSION}`); - add(catalog.adapterReleaseRevision === 2, + add(catalog.adapterReleaseRevision === 4, "adapterReleaseRevision must be the current positive Adapter release revision"); add(Boolean(catalog.target?.version), "target.version is required"); add(catalog.capture?.visualStates >= 1, "capture.visualStates must be positive"); diff --git a/adapters/mac-workbuddy/scripts/workbuddy-theme-projection.mjs b/adapters/mac-workbuddy/scripts/workbuddy-theme-projection.mjs index 13eb053..31c678a 100644 --- a/adapters/mac-workbuddy/scripts/workbuddy-theme-projection.mjs +++ b/adapters/mac-workbuddy/scripts/workbuddy-theme-projection.mjs @@ -24,7 +24,7 @@ const APPEARANCE_KEYS = ["shellMode", "backdropBlurPx", "backdropSaturation", "r const ACCESSIBILITY_KEYS = ["reducedMotion", "minimumTextContrast", "minimumLargeTextContrast", "preserveSystemFocusRing", "transparencyFallback"]; const COPY_KEYS = ["brandSubtitle", "tagline", "projectPrefix", "projectLabel", "statusText", "quote"]; const ROOT_KEYS_V1 = ["kind", "schemaVersion", "id", "name", "version", "tokens", "background", "accessibility", "copy", "targets", "overrides"]; -const ROOT_KEYS_V2 = ["kind", "schemaVersion", "id", "name", "version", "sharedCore", "targets", "targetProfiles"]; +const ROOT_KEYS_V2 = ["kind", "schemaVersion", "id", "name", "version", "sharedCore", "presentation", "targets", "targetProfiles"]; const SAFE_COLOR = /^(?:#[0-9a-f]{6}|rgba?\([0-9., %]+\))$/i; const plainObject = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null; @@ -95,6 +95,19 @@ function validateColors(value) { return colors; } +function validateAppearanceVariants(value) { + if (value === undefined) return null; + const variants = objectWithKeys(value, ["light", "dark"], "theme.sharedCore.appearanceVariants"); + for (const mode of ["light", "dark"]) { + const variant = objectWithKeys(variants[mode], ["colors"], `theme.sharedCore.appearanceVariants.${mode}`); + const colors = validateColors(variant.colors); + if (Object.keys(colors).length !== COLOR_KEYS.length) { + throw new Error(`theme.sharedCore.appearanceVariants.${mode}.colors must declare every semantic color`); + } + } + return variants; +} + function validateFonts(value) { const fonts = objectWithKeys(value, FONT_KEYS, "theme.tokens.fonts"); for (const [key, families] of Object.entries(fonts)) { @@ -189,6 +202,35 @@ function valueAt(value, dotted) { return dotted.split(".").reduce((cursor, part) => cursor?.[part], value); } +function projectAppearanceVariant(colors, decisions) { + const output = { colors: {}, semanticColors: {} }; + for (const key of COLOR_KEYS) { + const field = decisions.get(`tokens.colors.${key}`); + if (!field) throw new Error(`Capability has no WorkBuddy decision for tokens.colors.${key}`); + if (field.decision === "exact") { + setPath(output, field.target, colors[key]); + } else if (field.decision === "approximate" && valueAt(output, field.target) === undefined) { + setPath(output, field.target, colors[key]); + } + } + return output; +} + +function immersiveSceneCapabilityState(capability, presentation) { + if (presentation === undefined) return { requested: false, exact: true }; + const declared = capability.presentationProfiles?.[presentation?.profileId]; + const scene = declared?.sceneSemantics; + const scopes = [ + ["surfaces", ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"]], + ["parameters", ["density", "borderTreatment", "textureIntensity", "surfaceOpacity", "navigationTreatment", "composerTreatment", "cardTreatment"]], + ["assetSlots", ["scene.backdrop"]], + ]; + const exact = presentation?.profileId === "immersive-scene-v1" && presentation?.profileVersion === 1 && + declared?.profileVersion === 1 && declared?.geometryPolicy === presentation?.geometryPolicy && + scopes.every(([scope, names]) => names.every((name) => scene?.[scope]?.[name]?.decision === "exact")); + return { requested: true, exact }; +} + export async function projectUnifiedThemeForWorkBuddy(value, { targetProfile = null, clientVersion = null, surfaceCatalogVersion = null, compatibilityAttempt = false } = {}) { const capability = await loadAdapterCapability(); const diagnostics = []; @@ -205,8 +247,10 @@ export async function projectUnifiedThemeForWorkBuddy(value, { targetProfile = n let accessibilitySource; let copySource = {}; let embeddedTargetProfile = null; + let presentationSource; let targetRequested = true; - if (rawTheme.schemaVersion === 1) { + const legacySchema = rawTheme.schemaVersion === 1 && rawTheme.sharedCore === undefined; + if (legacySchema) { theme = objectWithKeys(rawTheme, ROOT_KEYS_V1, "theme"); tokens = objectWithKeys(theme.tokens, ["colors", "fonts", "appearance"], "theme.tokens"); backgroundSource = theme.background; @@ -214,7 +258,7 @@ export async function projectUnifiedThemeForWorkBuddy(value, { targetProfile = n copySource = theme.copy ?? {}; } else { theme = objectWithKeys(rawTheme, ROOT_KEYS_V2, "theme"); - const sharedCore = objectWithKeys(theme.sharedCore, ["tokens", "background", "accessibility"], "theme.sharedCore"); + const sharedCore = objectWithKeys(theme.sharedCore, ["tokens", "background", "accessibility", "appearanceVariants"], "theme.sharedCore"); tokens = objectWithKeys(sharedCore.tokens, ["colors", "fonts", "appearance"], "theme.sharedCore.tokens"); backgroundSource = sharedCore.background; accessibilitySource = sharedCore.accessibility; @@ -234,14 +278,16 @@ export async function projectUnifiedThemeForWorkBuddy(value, { targetProfile = n } } embeddedTargetProfile = profiles[ADAPTER_ID] ?? null; + presentationSource = theme.presentation; } if (targetProfile && embeddedTargetProfile && canonicalJson(targetProfile) !== canonicalJson(embeddedTargetProfile)) { throw new Error("Explicit WorkBuddy Target Profile conflicts with the unified theme profile"); } - const merged = rawTheme.schemaVersion === 1 + const merged = legacySchema ? mergeLegacy({ ...theme, tokens }, diagnostics) : { colors: tokens.colors, fonts: tokens.fonts, appearance: tokens.appearance ?? {}, background: backgroundSource, copy: copySource }; const colors = validateColors(merged.colors); + const appearanceVariants = legacySchema ? null : validateAppearanceVariants(theme.sharedCore.appearanceVariants); const fonts = validateFonts(merged.fonts); const appearance = validateAppearance(merged.appearance); const background = validateBackground(merged.background); @@ -301,6 +347,12 @@ export async function projectUnifiedThemeForWorkBuddy(value, { targetProfile = n mappingSummary.unsupported += 1; } } + if (appearanceVariants) { + output.appearanceVariants = { + light: projectAppearanceVariant(appearanceVariants.light.colors, decisions), + dark: projectAppearanceVariant(appearanceVariants.dark.colors, decisions), + }; + } for (const key of FONT_KEYS) { if (fonts[key] !== undefined) { setPath(output, decisions.get(`tokens.fonts.${key}`).target, fonts[key]); @@ -388,6 +440,40 @@ export async function projectUnifiedThemeForWorkBuddy(value, { targetProfile = n const profileValues = profile.values; output.appearance.paletteStrategy = profileValues.paletteStrategy ?? "system"; + const presentationState = immersiveSceneCapabilityState(capability, presentationSource); + let presentationApplyBlocked = false; + if (presentationState.requested) { + output.presentation = clone(presentationSource); + if (!presentationState.exact) { + presentationApplyBlocked = true; + diagnostics.push({ + severity: "error", + field: "presentation", + decision: "unsupported", + code: "immersive-scene-consumer-incomplete", + message: "WorkBuddy does not provide an exact consumer for every required immersive scene scope.", + }); + } else if (output.appearance.paletteStrategy === "system") { + presentationApplyBlocked = true; + diagnostics.push({ + severity: "error", + field: "presentation", + decision: "unsupported", + code: "immersive-scene-system-palette-inexact", + message: "The immersive scene requires adaptive or custom palette output; WorkBuddy system palette leaves scene paint dormant.", + }); + } else { + for (const [scope, boundary] of Object.entries(capability.presentationBoundaries)) { + diagnostics.push({ + severity: "info", + field: `presentation.boundaries.${scope}`, + decision: boundary.decision, + code: boundary.diagnostic, + message: `${scope} remains host-owned and is outside immersive-scene-v1 exact scope.`, + }); + } + } + } if (output.backgroundVideo) { output.appearance.backgroundVideoPosterMode = profileValues.backgroundVideoPosterMode ?? background.posterMode ?? "image"; if (profileValues.backgroundVideoPosterMode === undefined && background.posterMode !== undefined) recordExact("background.posterMode"); @@ -401,7 +487,7 @@ export async function projectUnifiedThemeForWorkBuddy(value, { targetProfile = n } let legacyCompatibility = null; - if (rawTheme.schemaVersion === 1) { + if (legacySchema) { const targetRoot = theme.targets === undefined ? {} : objectWithKeys(theme.targets, ["codex", "claude", "workbuddy"], "theme.targets"); const legacyTarget = targetRoot.workbuddy; targetRequested = Boolean(legacyTarget); @@ -438,7 +524,7 @@ export async function projectUnifiedThemeForWorkBuddy(value, { targetProfile = n ); const clientAllowed = exactClientAllowed || compatibilityClientAllowed; const surfaceAllowed = effectiveSurfaceVersion === capability.catalogs.uiSurfaceCatalogVersion; - const applyAllowed = targetRequested && clientAllowed && surfaceAllowed; + const applyAllowed = targetRequested && clientAllowed && surfaceAllowed && !presentationApplyBlocked; if (!targetRequested) diagnostics.push({ severity: "error", field: "targets", decision: "unsupported", code: "target-not-requested", message: "The unified theme does not include the WorkBuddy adapter target." }); if (!applyAllowed) diagnostics.push({ severity: "error", field: "compatibility", decision: "unsupported", code: "client-version-unsupported", message: "Compilation is deterministic, but apply is unavailable until the verified WorkBuddy client and UI Surface Catalog versions match." }); if (applyAllowed && compatibilityClientAllowed) diagnostics.push({ @@ -482,7 +568,7 @@ function neutralInvocation(value) { if (typeof identity.version !== "string" || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(identity.version)) { throw new Error("Adapter projector identity.version is invalid"); } - const core = objectWithKeys(invocation.sharedCore, ["tokens", "background", "accessibility"], "Adapter projector Shared Core"); + const core = objectWithKeys(invocation.sharedCore, ["tokens", "background", "accessibility", "appearanceVariants"], "Adapter projector Shared Core"); const profiles = objectWithKeys(invocation.targetProfiles, [ADAPTER_ID], "Adapter projector Target Profiles"); const context = objectWithKeys(invocation.compileContext, MANAGER_COMPILE_CONTEXT_KEYS, "Adapter projector compile context"); @@ -529,7 +615,7 @@ export async function projectThemeFamilyAdapter(value) { }, } : { kind: UNIFIED_KIND, - schemaVersion: 2, + schemaVersion: 1, id: identity.id, name: identity.name, version: identity.version, diff --git a/adapters/mac-workbuddy/tests/adapter-capability.test.mjs b/adapters/mac-workbuddy/tests/adapter-capability.test.mjs index b0c81ba..3f8d101 100644 --- a/adapters/mac-workbuddy/tests/adapter-capability.test.mjs +++ b/adapters/mac-workbuddy/tests/adapter-capability.test.mjs @@ -22,7 +22,7 @@ const styleCatalog = await loadThemeStyleCatalog(uiCatalog.runtimeRoles); assert.equal(capability.adapterVersion, "5.2.6"); assert.equal(capability.adapterVersion, uiCatalog.target.version); assert.equal(capability.adapterVersion, uiCatalog.adapterVersion); -assert.equal(capability.adapterReleaseRevision, 2); +assert.equal(capability.adapterReleaseRevision, 4); assert.equal(capability.adapterReleaseRevision, uiCatalog.adapterReleaseRevision); assert.equal(capability.platform, "macos"); assert.equal(capability.architecture, "arm64"); diff --git a/adapters/mac-workbuddy/tests/immersive-presentation-contract.test.mjs b/adapters/mac-workbuddy/tests/immersive-presentation-contract.test.mjs new file mode 100644 index 0000000..6832950 --- /dev/null +++ b/adapters/mac-workbuddy/tests/immersive-presentation-contract.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { normalizeSkinTheme } from "../scripts/skin-theme.mjs"; +import { loadAdapterCapability, validateAdapterCapability } from "../scripts/adapter-capability.mjs"; +import { loadUiSurfaceCatalog } from "../scripts/ui-surface-catalog.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const presentation = { + profileId: "immersive-scene-v1", + profileVersion: 1, + strictness: "exact-required", + geometryPolicy: "scene-bounded", + surfaces: ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], + parameters: { + density: "comfortable", + borderTreatment: "etched", + textureIntensity: 0.36, + surfaceOpacity: 0.72, + navigationTreatment: "framed", + composerTreatment: "anchored", + cardTreatment: "elevated", + }, + assetSlots: { "scene.backdrop": "background.png" }, + fallbackPolicy: { unsupportedSurface: "block", reducedMotion: "static" }, +}; + +const source = () => ({ + kind: "skin.theme", + id: "immersive-contract", + image: "background.png", + appearance: { paletteStrategy: "adaptive", shellMode: "auto" }, + presentation: structuredClone(presentation), +}); + +assert.deepEqual(normalizeSkinTheme(source()).presentation, presentation, + "the bounded immersive profile remains available to standalone WorkBuddy engines"); +assert.equal(normalizeSkinTheme({ ...source(), presentation: undefined }).presentation, undefined, + "existing themes without presentation retain their import path"); + +const unknownParameter = source(); +unknownParameter.presentation.parameters.untrustedCss = "body { display: none }"; +assert.throws(() => normalizeSkinTheme(unknownParameter), /presentation\.parameters.*unsupported|validated immersive-scene-v1/i); + +const badOpacity = source(); +badOpacity.presentation.parameters.surfaceOpacity = 1.1; +assert.throws(() => normalizeSkinTheme(badOpacity), /surfaceOpacity.*0.*1/); + +const validNonDefaultNumbers = source(); +validNonDefaultNumbers.presentation.parameters.textureIntensity = 0.18; +validNonDefaultNumbers.presentation.parameters.surfaceOpacity = 0.44; +assert.deepEqual(normalizeSkinTheme(validNonDefaultNumbers).presentation.parameters, validNonDefaultNumbers.presentation.parameters, + "every in-range immersive number remains available to the bounded renderer recipe"); + +const badAssetSlot = source(); +badAssetSlot.presentation.assetSlots["scene.overlay"] = "overlay.png"; +assert.throws(() => normalizeSkinTheme(badAssetSlot), /presentation\.assetSlots.*unsupported|validated immersive-scene-v1/i); + +const mismatchedBackdrop = source(); +mismatchedBackdrop.presentation.assetSlots["scene.backdrop"] = "other.png"; +assert.throws(() => normalizeSkinTheme(mismatchedBackdrop), /scene\.backdrop.*theme image/i); + +const badFallback = source(); +badFallback.presentation.fallbackPolicy.reducedMotion = "animate"; +assert.throws(() => normalizeSkinTheme(badFallback), /reducedMotion.*static/); + +const [renderer, css] = await Promise.all([ + fs.readFile(path.join(ROOT, "assets", "renderer-inject.js"), "utf8"), + fs.readFile(path.join(ROOT, "assets", "skin.css"), "utf8"), +]); +const [capability, surfaceCatalog] = await Promise.all([ + loadAdapterCapability(), + loadUiSurfaceCatalog(), +]); + +const immersiveCapability = capability.presentationProfiles["immersive-scene-v1"]; +assert.equal(immersiveCapability.sceneSemantics.scope, "presentation-scene", + "the scene recipe must never be confused with a host layout or native-control contract"); +assert.deepEqual(Object.keys(immersiveCapability.sceneSemantics).sort(), ["assetSlots", "parameters", "scope", "surfaces"], + "the Capability must publish every immersive scope without exposing host CSS or selectors"); +for (const scope of ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"]) { + assert.equal(immersiveCapability.sceneSemantics.surfaces[scope].decision, "exact", + `immersive surface ${scope} must be exact before strict presentation can compile`); + assert.match(immersiveCapability.sceneSemantics.surfaces[scope].consumerId, /^workbuddy\.presentation\.surface\./, + `immersive surface ${scope} must name its stable semantic consumer`); +} +for (const parameter of [ + "density", "borderTreatment", "textureIntensity", "surfaceOpacity", + "navigationTreatment", "composerTreatment", "cardTreatment", +]) { + assert.equal(immersiveCapability.sceneSemantics.parameters[parameter].decision, "exact", + `immersive parameter ${parameter} must publish an exact runtime consumer`); + assert.match(immersiveCapability.sceneSemantics.parameters[parameter].consumerId, /^workbuddy\.presentation\.parameter\./, + `immersive parameter ${parameter} must name its stable semantic consumer`); +} +assert.equal(immersiveCapability.sceneSemantics.assetSlots["scene.backdrop"].decision, "exact", + "the semantic backdrop slot must bind the verified theme image"); +assert.match(immersiveCapability.sceneSemantics.assetSlots["scene.backdrop"].consumerId, /^workbuddy\.presentation\.asset\./, + "the semantic backdrop slot must name its stable semantic consumer"); +for (const boundary of ["nativeControls", "layout", "uncataloguedPortals", "fonts"]) { + assert.equal(capability.presentationBoundaries[boundary].decision, "unsupported", + `${boundary} must remain outside the immersive profile's exact scope`); +} +const approximatePresentation = structuredClone(capability); +approximatePresentation.presentationProfiles["immersive-scene-v1"].sceneSemantics.parameters.textureIntensity.decision = "approximate"; +assert.throws(() => validateAdapterCapability(approximatePresentation), /exact immersive scene semantics/i, + "exact-required presentation must fail closed when a declared parameter loses its runtime consumer"); +const missingConsumer = structuredClone(capability); +delete missingConsumer.presentationProfiles["immersive-scene-v1"].sceneSemantics.parameters.textureIntensity.consumerId; +assert.throws(() => validateAdapterCapability(missingConsumer), /exact immersive scene semantics/i, + "an exact presentation declaration without a stable consumer ID must fail closed"); +for (const [dataset, source] of [ + ["workbuddySkinSceneDensity", "scene.density"], + ["workbuddySkinSceneBorderTreatment", "scene.borderTreatment"], + ["workbuddySkinSceneTextureIntensity", "textureIntensity"], + ["workbuddySkinSceneSurfaceOpacity", "surfaceOpacity"], + ["workbuddySkinSceneNavigationTreatment", "scene.navigationTreatment"], + ["workbuddySkinSceneComposerTreatment", "scene.composerTreatment"], + ["workbuddySkinSceneCardTreatment", "scene.cardTreatment"], +]) { + assert.match(renderer, new RegExp(`root\\.dataset\\.${dataset} = ${source.replace(".", "\\.")}`), + `${dataset} must be emitted from the verified presentation input`); + assert.match(renderer, new RegExp(`delete root\\.dataset\\.${dataset};`), + `${dataset} must be removed during restore`); +} +assert.match(renderer, /const sceneBackdrop = THEME\.presentation\?\.profileId === "immersive-scene-v1"\s*\? THEME\.presentation\.assetSlots\["scene\.backdrop"\]/, + "the semantic backdrop slot must be read by the renderer rather than treated as metadata only"); +assert.match(renderer, /sceneBackdrop !== THEME\.image/, + "a divergent backdrop slot must fail before it can reach the runtime"); +assert.match(renderer, /const sceneStaticMotionPolicy = THEME\.presentation\?\.fallbackPolicy\?\.reducedMotion === "static"/, + "the static reduced-motion fallback must be read by the runtime"); +assert.match(renderer, /motionPreference\.matches && \(sceneStaticMotionPolicy \|\| !videoMotionOverride\)/, + "a profile requiring static motion must not be bypassed by a previous video override"); +assert.match(renderer, /const customPaletteFor = \(mode\) => paletteFromTheme\(THEME\.appearanceVariants\?\.\[mode\] \?\? THEME\)/, + "a high-fidelity theme must select its exact light/dark palette before WorkBuddy writes style bindings"); +assert.match(renderer, /activePaletteStrategy === "system" \|\| THEME\.appearanceVariants/, + "a host appearance change must reapply a declared light/dark palette even when the strategy is adaptive or custom"); +for (const variable of [ + "--cc-theme-scene-density-tracking", + "--cc-theme-scene-etched-line", + "--cc-theme-scene-texture-alpha", + "--cc-theme-scene-content-opacity", + "--cc-theme-scene-surface-layer", + "--cc-theme-scene-navigation-frame", + "--cc-theme-scene-composer-anchor", + "--cc-theme-scene-card-elevation", +]) { + assert.ok(((renderer + css).match(new RegExp(variable.replace(/[-]/g, "\\-"), "g")) ?? []).length >= 2, + `${variable} must be defined by the bounded renderer recipe and consumed by an owned scene role`); +} +assert.match(renderer, /const textureIntensity = sceneNumber\(scene\.textureIntensity, 0, 1/, + "the renderer must accept every validated texture intensity instead of a single theme literal"); +assert.match(renderer, /const surfaceOpacity = sceneNumber\(scene\.surfaceOpacity, 0, 1/, + "the renderer must accept every validated surface opacity instead of a single theme literal"); +assert.match(css, /var\(--wbs-main-scrim-start\) var\(--cc-theme-scene-content-opacity\)/, + "the main WorkBuddy scene veil must become transparent when the validated opacity is zero"); +assert.match(css, /data-workbuddy-skin-role="overlay-settings-modal"[\s\S]*?--cc-theme-scene-surface-layer/, + "immersive overlays must consume the verified scene surface treatment"); +assert.match(css, /data-workbuddy-skin-role="composer"\] \{[\s\S]*?--cc-theme-scene-composer-anchor/, + "immersive composer paint must consume the anchored treatment"); + +const immersiveBlock = css.slice(css.indexOf("immersive-scene-v1 is a bounded, paint-only recipe")); +const catalogRoles = new Set(surfaceCatalog.runtimeRoles.map((entry) => entry.role)); +for (const role of immersiveBlock.matchAll(/data-workbuddy-skin-role="([a-z0-9-]+)"/g)) { + assert.ok(catalogRoles.has(role[1]), + `immersive CSS may only consume the versioned Surface Catalog role ${role[1]}`); +} +for (const [surface, role] of Object.entries({ + shell: "shell", + navigation: "sidebar-nav-row", + home: "page-home", + conversation: "page-chat", + composer: "composer", + cards: "project-card", + overlays: "overlay-settings-modal", +})) { + assert.match(css, new RegExp(`data-workbuddy-skin-presentation="immersive-scene-v1"[\\s\\S]{0,900}data-workbuddy-skin-role="${role}"`), + `immersive surface ${surface} must retain a bounded consumer from the versioned Surface Catalog`); +} +for (const selector of [ + 'data-workbuddy-skin-scene-density="comfortable"', + 'data-workbuddy-skin-scene-border-treatment="etched"', + 'data-workbuddy-skin-scene-navigation-treatment="framed"', + 'data-workbuddy-skin-scene-composer-treatment="anchored"', + 'data-workbuddy-skin-scene-card-treatment="elevated"', +]) assert.match(immersiveBlock, new RegExp(selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + `the validated semantic value ${selector} must gate a concrete paint consumer`); + +console.log("immersive-presentation-contract.test.mjs: ok"); diff --git a/adapters/mac-workbuddy/tests/release-identity.test.mjs b/adapters/mac-workbuddy/tests/release-identity.test.mjs index b9dfe25..5fcdeb8 100644 --- a/adapters/mac-workbuddy/tests/release-identity.test.mjs +++ b/adapters/mac-workbuddy/tests/release-identity.test.mjs @@ -17,12 +17,12 @@ try { assert.deepEqual(identity, { adapterId: "mac-workbuddy", adapterVersion: "5.2.6", - adapterReleaseRevision: 2, + adapterReleaseRevision: 4, platform: "macos", architecture: "arm64", - assetIdentity: "mac-workbuddy-5.2.6-r2-macos-arm64", - sourceArchive: "mac-workbuddy-5.2.6-r2-macos-arm64.zip", - clientArchive: "cc-theme-mac-workbuddy-5.2.6-r2-macos-arm64.zip", + assetIdentity: "mac-workbuddy-5.2.6-r4-macos-arm64", + sourceArchive: "mac-workbuddy-5.2.6-r4-macos-arm64.zip", + clientArchive: "cc-theme-mac-workbuddy-5.2.6-r4-macos-arm64.zip", }); const project = JSON.parse(await fs.readFile(path.join(root, "PROJECT_MANIFEST.json"), "utf8")); @@ -54,7 +54,7 @@ try { assert.equal(crypto.createHash("sha256").update(await fs.readFile(archive)).digest("hex"), firstHash, "a repeated unpublished-development source build was not deterministic"); - console.log("PASS: Adapter version follows WorkBuddy ShortVersion and unpublished r2 rebuilds deterministically until first publication."); + console.log("PASS: Adapter version follows WorkBuddy ShortVersion and unpublished r4 builds deterministically until first publication."); } finally { await fs.rm(temporary, { recursive: true, force: true }); } diff --git a/adapters/mac-workbuddy/tests/theme-settings-locales.test.mjs b/adapters/mac-workbuddy/tests/theme-settings-locales.test.mjs index c973589..144f4d3 100644 --- a/adapters/mac-workbuddy/tests/theme-settings-locales.test.mjs +++ b/adapters/mac-workbuddy/tests/theme-settings-locales.test.mjs @@ -18,7 +18,7 @@ const [locales, renderer, css, styleCatalog] = await Promise.all([ assert.equal(locales.kind, "theme.settings-locales"); assert.equal(locales.adapter, "mac-workbuddy"); assert.equal(locales.adapterVersion, locales.host.version); -assert.equal(locales.adapterReleaseRevision, 2); +assert.equal(locales.adapterReleaseRevision, 4); assert.deepEqual(locales.locales, ["zh-CN", "en-US"]); assert.equal(locales.defaultLocale, "zh-CN"); assert.equal(locales.host.application, "WorkBuddy"); diff --git a/adapters/mac-workbuddy/tests/theme-style-catalog.test.mjs b/adapters/mac-workbuddy/tests/theme-style-catalog.test.mjs index 93beb85..8b02fd4 100644 --- a/adapters/mac-workbuddy/tests/theme-style-catalog.test.mjs +++ b/adapters/mac-workbuddy/tests/theme-style-catalog.test.mjs @@ -17,7 +17,7 @@ const [styleCatalog, css, renderer, capability] = await Promise.all([ assert.equal(styleCatalog.kind, "theme.style-catalog"); assert.equal(styleCatalog.adapterId, "mac-workbuddy"); assert.equal(styleCatalog.adapterVersion, uiCatalog.target.version); -assert.equal(styleCatalog.adapterReleaseRevision, 2); +assert.equal(styleCatalog.adapterReleaseRevision, 4); assert.equal(styleCatalog.geometryPolicy, "native"); const configuredVariables = new Set(styleCatalog.bindings.map((binding) => binding.cssVariable)); const usedVariables = new Set([...css.matchAll(/--wbs-[a-z0-9-]+/g)].map((match) => match[0])); diff --git a/adapters/mac-workbuddy/tests/theme.test.mjs b/adapters/mac-workbuddy/tests/theme.test.mjs index 2f581c5..bfd9c81 100644 --- a/adapters/mac-workbuddy/tests/theme.test.mjs +++ b/adapters/mac-workbuddy/tests/theme.test.mjs @@ -30,6 +30,24 @@ assert.equal(normalized.backgroundVideo, "ignored.mp4"); assert.equal(normalized.appearance.backgroundVideoPosterMode, "image"); assert.equal(normalized.appearance.backgroundVideoPosition, undefined); assert.equal(normalized.appearance.backgroundScrimOpacity, 1); + +// Palette variants inherit only the already-validated outer media presence. +// This prevents a legal video poster policy from being rejected while still +// keeping variants closed to paint data. +const dualAppearanceVideo = normalizeSkinTheme({ + kind: "skin.theme", + id: "dual-appearance-video", + image: "background.png", + backgroundVideo: "ambient.mp4", + appearance: { backgroundVideoPosterMode: "image" }, + appearanceVariants: { + light: { colors: { text: "#1D2D3D", muted: "#5B6D7E" }, semanticColors: { surfaceBase: "#F7FAFC" } }, + dark: { colors: { text: "#F5F8FC", muted: "#B8C4D0" }, semanticColors: { surfaceBase: "#101820" } }, + }, +}); +assert.equal(dualAppearanceVideo.backgroundVideo, "ambient.mp4"); +assert.equal(dualAppearanceVideo.appearanceVariants.light.colors.text, "#1D2D3D"); +assert.equal(dualAppearanceVideo.appearanceVariants.dark.colors.text, "#F5F8FC"); assert.throws(() => normalizeSkinTheme({ kind: "skin.theme", id: "hero-is-not-a-workbuddy-field", diff --git a/adapters/mac-workbuddy/tests/ui-surface-catalog.test.mjs b/adapters/mac-workbuddy/tests/ui-surface-catalog.test.mjs index eabd4e0..aebb8a9 100644 --- a/adapters/mac-workbuddy/tests/ui-surface-catalog.test.mjs +++ b/adapters/mac-workbuddy/tests/ui-surface-catalog.test.mjs @@ -10,7 +10,7 @@ const catalog = await loadUiSurfaceCatalog(); assert.deepEqual(validateUiSurfaceCatalog(catalog), []); assert.equal(catalog.adapter, "mac-workbuddy"); assert.equal(catalog.adapterVersion, catalog.target.version); -assert.equal(catalog.adapterReleaseRevision, 2); +assert.equal(catalog.adapterReleaseRevision, 4); assert.equal(catalog.capture.visualStates, 55); assert.equal(catalog.capture.computedStyleSnapshots, 65); assert.ok(catalog.domHierarchy.length >= 18); diff --git a/adapters/mac-workbuddy/tests/workbuddy-theme-projection.test.mjs b/adapters/mac-workbuddy/tests/workbuddy-theme-projection.test.mjs index 59001c8..55cf788 100644 --- a/adapters/mac-workbuddy/tests/workbuddy-theme-projection.test.mjs +++ b/adapters/mac-workbuddy/tests/workbuddy-theme-projection.test.mjs @@ -71,6 +71,55 @@ for (const code of [ "focus-ring-strategy-approximate", "transparency-preference-unavailable", ]) assert.ok(projected.diagnostics.some((diagnostic) => diagnostic.code === code), `missing visible diagnostic ${code}`); +const immersiveScene = structuredClone(unifiedV2); +immersiveScene.presentation = { + profileId: "immersive-scene-v1", + profileVersion: 1, + strictness: "exact-required", + geometryPolicy: "scene-bounded", + surfaces: ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], + parameters: { + density: "comfortable", + borderTreatment: "etched", + textureIntensity: 0.28, + surfaceOpacity: 0.66, + navigationTreatment: "framed", + composerTreatment: "anchored", + cardTreatment: "elevated", + }, + assetSlots: { "scene.backdrop": "background.webp" }, + fallbackPolicy: { unsupportedSurface: "block", reducedMotion: "static" }, +}; +immersiveScene.targetProfiles["mac-workbuddy"] = profile({ paletteStrategy: "adaptive" }); +const immersiveProjection = await projectUnifiedThemeForWorkBuddy(immersiveScene, context); +assert.equal(immersiveProjection.applyAllowed, true, + "all exact immersive scopes with an adaptive palette must remain application-eligible"); +assert.deepEqual(immersiveProjection.theme.presentation, immersiveScene.presentation, + "the WorkBuddy projector must forward only the closed immersive scene envelope"); +for (const code of [ + "native-controls-host-owned", + "native-layout-preserved", + "uncatalogued-portal-unsupported", + "presentation-font-override-unsupported", +]) assert.ok(immersiveProjection.diagnostics.some((item) => item.code === code && item.decision === "unsupported"), + `immersive boundary ${code} must be a visible structured diagnostic`); + +const canonicalImmersiveSchema = structuredClone(immersiveScene); +canonicalImmersiveSchema.schemaVersion = 1; +const canonicalImmersiveProjection = await projectUnifiedThemeForWorkBuddy(canonicalImmersiveSchema, context); +assert.equal(canonicalImmersiveProjection.applyAllowed, true, + "the current Unified Theme v1 Shared Core contract must preserve immersive presentation through WorkBuddy projection"); +assert.equal(canonicalImmersiveProjection.theme.presentation?.profileId, "immersive-scene-v1", + "the current Unified Theme v1 presentation must not be mistaken for the retired legacy theme shape"); + +const immersiveSystem = structuredClone(immersiveScene); +immersiveSystem.targetProfiles["mac-workbuddy"] = profile({ paletteStrategy: "system" }); +const immersiveSystemProjection = await projectUnifiedThemeForWorkBuddy(immersiveSystem, context); +assert.equal(immersiveSystemProjection.applyAllowed, false, + "exact-required immersive output must fail closed when system palette would leave scene paint dormant"); +assert.ok(immersiveSystemProjection.diagnostics.some((item) => item.code === "immersive-scene-system-palette-inexact"), + "system-palette presentation rejection must be user-visible"); + const reordered = structuredClone(unifiedV2); reordered.sharedCore.tokens.colors = Object.fromEntries(Object.entries(reordered.sharedCore.tokens.colors).reverse()); reordered.sharedCore.tokens.fonts = Object.fromEntries(Object.entries(reordered.sharedCore.tokens.fonts).reverse()); diff --git a/app/config/release-baseline.json b/app/config/release-baseline.json new file mode 100644 index 0000000..418de21 --- /dev/null +++ b/app/config/release-baseline.json @@ -0,0 +1,53 @@ +{ + "kind": "cc-theme.manager-runtime-profile", + "schemaVersion": 1, + "managerVersion": "0.2.2", + "profile": "release-bundled-latest", + "adapters": [ + { + "adapterId": "mac-codex", + "adapterVersion": "26.715.71837", + "adapterReleaseRevision": 3, + "assetIdentity": "mac-codex-26.715.71837-r3-macos-arm64", + "source": { + "kind": "github-release-asset", + "capabilityVersion": "2.0.0", + "releaseTag": "cc-theme-v0.2.2-adapters.1", + "downloadUrl": "https://github.com/quanzhankeji/cc-theme/releases/download/cc-theme-v0.2.2-adapters.1/mac-codex-26.715.71837-r3-macos-arm64.ccadapter", + "bytes": 1098823, + "archiveSha256": "6dd7938810b1b7f5f3ee7ff2b800f1b1df917ee0466e11eda8ab50bb7332cd34", + "manifestSha256": "63c7e190e137dfcb1405e080a34c08fd62ecbf3c7a4b6ad408718e2cb6b2a1ea" + } + }, + { + "adapterId": "mac-doubao", + "adapterVersion": "2.19.9", + "adapterReleaseRevision": 4, + "assetIdentity": "mac-doubao-2.19.9-r4-macos-arm64", + "source": { + "kind": "github-release-asset", + "capabilityVersion": "1.3.0", + "releaseTag": "cc-theme-v0.2.2-adapters.1", + "downloadUrl": "https://github.com/quanzhankeji/cc-theme/releases/download/cc-theme-v0.2.2-adapters.1/mac-doubao-2.19.9-r4-macos-arm64.ccadapter", + "bytes": 187920, + "archiveSha256": "6eea72b489fedaef453b7e67b710706827b8e6be94b650a2e3dce67387613b8a", + "manifestSha256": "a14ee11246c2e1c31e24970a4f2d75a37241a3c121cbdd971faba8e3caa3ed38" + } + }, + { + "adapterId": "mac-workbuddy", + "adapterVersion": "5.2.6", + "adapterReleaseRevision": 4, + "assetIdentity": "mac-workbuddy-5.2.6-r4-macos-arm64", + "source": { + "kind": "github-release-asset", + "capabilityVersion": "1.0.0", + "releaseTag": "cc-theme-v0.2.2-adapters.1", + "downloadUrl": "https://github.com/quanzhankeji/cc-theme/releases/download/cc-theme-v0.2.2-adapters.1/mac-workbuddy-5.2.6-r4-macos-arm64.ccadapter", + "bytes": 763027, + "archiveSha256": "32437f3c59579c080567a5325f08d16da1e776187db59aa89337bdcb51ee90b0", + "manifestSha256": "a6a3cfa25345c6df8a5d1756fb8ee830e704ffb7d6f63d1df3adc325527a4d98" + } + } + ] +} diff --git a/app/config/transition-baseline.json b/app/config/transition-baseline.json index 77fd54a..6a08fa1 100644 --- a/app/config/transition-baseline.json +++ b/app/config/transition-baseline.json @@ -1,7 +1,7 @@ { "kind": "cc-theme.manager-runtime-profile", "schemaVersion": 1, - "managerVersion": "0.2.1", + "managerVersion": "0.2.2", "profile": "transition-baseline", "adapters": [ { diff --git a/app/docs/theme-workbench-architecture.md b/app/docs/theme-workbench-architecture.md new file mode 100644 index 0000000..954c9b5 --- /dev/null +++ b/app/docs/theme-workbench-architecture.md @@ -0,0 +1,87 @@ +# Theme Workbench:静态预览与公共主题编辑 + +## 产品边界 + +Theme Workbench 把“查看主题效果”和“从公共主题开始定制”放在同一个界面中,但两件事都 +不能触碰正在运行的宿主,也不能原地改写公共主题包。 + +主题卡片中的眼睛入口直接打开工作台;它不会改变当前选择主题、各客户端记住的主题或任何 +已注入主题。右侧的 Codex / WorkBuddy 目标切换只改变静态模拟预览,始终共享同一份草稿。 + +## 草稿模型 + +前端工作的唯一可变对象是派生草稿,而不是 `ThemeFamily`: + +```ts +{ + kind: "cc-theme.theme-workbench-draft", + revision: 1, + base: { themeId }, + previewAdapterId: "mac-codex" | "mac-workbuddy", + patch: { + textColors: { lightText: "#RRGGBB", darkText: "#RRGGBB" }, + surfaceOpacity: 0.00..1.00, + backgroundAsset: { + source: "inherit" | "uploaded", + mediaType: "image" | "video" | null, + fileName?: string, + mimeType?: string, + }, + }, +} +``` + +映射关系固定如下: + +| 工作台字段 | Unified Theme 目标 | 说明 | +|---|---|---| +| 主要文字颜色 | `sharedCore.tokens.colors.text` | 有 complete appearance variants 时分别更新 light/dark 的 `colors.text`;没有 variants 的主题只拥有一个 Shared Core token,工作台会把同一个颜色写入两种预览,不能制造不一致的双外观值。 | +| 背景图片 / 视频 | `sharedCore.background` | 图片仅 PNG/JPEG/WebP,视频仅 MP4;视频仍必须有静态图片作为底图。 | +| 主内容背景透明度 | `presentation.parameters.surfaceOpacity` | 合法范围 0–1(0–100%)。它缩放主内容层及主题定义的 `mainScrimStart/Mid/End` 场景遮罩;0% 时工作台不再额外暗化或模糊背景。它不是 `background.scrimOpacity`,后者只属于背景遮罩。 | + +草稿内的 `fileName` 和 MIME 仅是展示与保存意图,绝不是可被 Renderer 解释的路径。即时预览 +使用浏览器的 Blob URL;Blob URL 不会被序列化、发送给宿主或写入主题库。 + +## 本地可编辑版本(已实现) + +保存不是覆盖导入的 `.cctheme`,也不是只保留内存里的 UI 状态。Manager 为每个主题在自己的 +Application Support 空间维护一个原子、可校验的本地编辑版本: + +```text +themes// # 导入后的只读基线;family.json 校验保持有效 +theme-overrides// + override.json # 闭合元数据、基线 SHA-256、媒体摘要 + local-unified-theme.json # 由基线 + 受限草稿生成的完整有效主题源 + cc-theme-local-background. # 可选:经 magic/大小/SHA 校验的本地背景副本 +``` + +保存步骤是:验证导入包 → 验证草稿及媒体 → 从基线生成完整 `local-unified-theme.json` → 校验其 +Shared Core / Presentation 合同 → 写入临时目录 → 原子替换本地编辑目录。失败会清理临时目录, +不会改动基线或先前有效的本地版本。 + +本地编辑版本绑定导入基线的 SHA-256;若基线发生变化,Manager 会拒绝静默 rebase。用户可以先 +“恢复原始主题”,再对新基线重新编辑。恢复只删除 `theme-overrides/`,不删除导入主题、 +不删除 Adapter、也不触碰已注入的运行时状态。 + +编译和随后应用主题时,Manager 只会使用这个已验证的本地完整主题源;没有本地编辑版本时才使用 +导入基线。因此保存的属性会真正进入 Adapter 的 Projector/Normalizer 链路,而不是只影响预览。 +目前本地版本只用于本机管理,尚未提供“导出为新的 `.cctheme`”功能;那会是独立的显式导出流程。 + +## 已实现的工作台 + +- 左侧可收起的手风琴抽屉,包含三项受限编辑; +- 右侧 Codex Desktop / WorkBuddy 静态模拟器,修改立即反映; +- “保存更改”只有草稿与已保存版本不同才可点击,保存成功后会回到禁用态; +- 有本地编辑版本时,显示“恢复原始主题”;它删除本地副本并回到导入基线; +- 主题卡片眼睛入口具备 tooltip、`aria-label`、禁用态和回归测试; +- 工作台不调用 `apply_theme`,不启动客户端。 + +## Adapter 责任 + +当前 UI 的两个预览均明确标为 static simulation,不能被理解为真实宿主截图或应用结果。 +生产保真预览应由 Adapter 发布:输入只能是已校验的临时 `skin.theme` 与包内受控媒体 URL,输出 +包括 preview fidelity / diagnostics。Manager 负责工作台、草稿和会话,不得硬编码宿主 DOM、 +选择器、注入方式或版本判断。 + +真实预览入口应由所选 Adapter 的 preview capability 决定;不能从另一个客户端的映射或名称推断 +支持情况。 diff --git a/app/package.json b/app/package.json index e289dfd..7a85ad5 100644 --- a/app/package.json +++ b/app/package.json @@ -1,20 +1,23 @@ { "name": "cc-theme", "private": true, - "version": "0.2.1", + "version": "0.2.2", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "prepare:resources": "node scripts/prepare-runtime-resources.mjs", "prepare:transition-resources": "CC_THEME_RUNTIME_PROFILE=transition-baseline node scripts/prepare-runtime-resources.mjs", + "prepare:release-resources": "CC_THEME_RUNTIME_PROFILE=release-bundled-latest node scripts/prepare-runtime-resources.mjs", "prepare:runtime": "bash scripts/prepare-node-runtime.sh", "adapter:catalog:check": "node scripts/generate-adapter-version-catalog.mjs --check", "adapter:build": "node scripts/build-adapter-packages.mjs", - "verify:transition-resources": "node scripts/verify-runtime-resources.mjs .runtime-resources config/transition-baseline.json 0.2.1", + "verify:transition-resources": "node scripts/verify-runtime-resources.mjs .runtime-resources config/transition-baseline.json 0.2.2", + "verify:release-resources": "node scripts/verify-runtime-resources.mjs .runtime-resources config/release-baseline.json 0.2.2", "tauri:dev": "npm run prepare:runtime && tauri dev", "tauri:build": "npm run prepare:runtime && tauri build", "tauri:build:transition": "npm run prepare:runtime && CC_THEME_RUNTIME_PROFILE=transition-baseline tauri build", + "tauri:build:release": "npm run prepare:runtime && CC_THEME_RUNTIME_PROFILE=release-bundled-latest tauri build", "preview": "vite preview", "typecheck": "tsc -b --pretty false", "test:node": "npm run prepare:resources && node --test tests/*.test.mjs", diff --git a/app/packages/adapter-sdk/adapter-registry.mjs b/app/packages/adapter-sdk/adapter-registry.mjs index 35a14a2..5236077 100644 --- a/app/packages/adapter-sdk/adapter-registry.mjs +++ b/app/packages/adapter-sdk/adapter-registry.mjs @@ -130,6 +130,7 @@ function normalizeCapability(raw, descriptor) { }, compatibility: clone(raw.compatibility ?? {}), presentationProfiles: clone(raw.presentationProfiles ?? {}), + presentationBoundaries: clone(raw.presentationBoundaries ?? {}), projection: { module: descriptor.projectorModule, export: descriptor.projectorExport, diff --git a/app/packages/contracts/adapter-capability.schema.json b/app/packages/contracts/adapter-capability.schema.json index 92f4566..ab597bf 100644 --- a/app/packages/contracts/adapter-capability.schema.json +++ b/app/packages/contracts/adapter-capability.schema.json @@ -34,29 +34,12 @@ "presentationProfiles": { "type": "object", "additionalProperties": { - "type": "object", - "additionalProperties": false, - "required": ["profileVersion", "geometryPolicy", "surfaces"], - "properties": { - "profileVersion": { "type": "integer", "minimum": 1 }, - "geometryPolicy": { "enum": ["scene-bounded"] }, - "surfaces": { - "type": "object", - "additionalProperties": false, - "required": ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], - "properties": { - "shell": { "enum": ["exact", "approximated", "unsupported"] }, - "navigation": { "enum": ["exact", "approximated", "unsupported"] }, - "home": { "enum": ["exact", "approximated", "unsupported"] }, - "conversation": { "enum": ["exact", "approximated", "unsupported"] }, - "composer": { "enum": ["exact", "approximated", "unsupported"] }, - "cards": { "enum": ["exact", "approximated", "unsupported"] }, - "overlays": { "enum": ["exact", "approximated", "unsupported"] } - } - } - } + "$ref": "#/$defs/presentationProfile" } }, + "presentationBoundaries": { + "$ref": "#/$defs/presentationBoundaries" + }, "localRuntimeOverrides": { "type": "object", "additionalProperties": false, @@ -84,6 +67,103 @@ "raw": { "type": "object" } }, "$defs": { - "adapterId": { "enum": ["mac-codex", "mac-doubao", "mac-workbuddy"] } + "adapterId": { "enum": ["mac-codex", "mac-doubao", "mac-workbuddy"] }, + "mappingDecision": { + "type": "object", + "additionalProperties": false, + "required": ["decision", "consumerId", "diagnostic"], + "properties": { + "decision": { "enum": ["exact", "approximate", "unsupported"] }, + "consumerId": { "type": ["string", "null"], "maxLength": 120, "pattern": "^[A-Za-z][A-Za-z0-9.-]{2,119}$" }, + "diagnostic": { "type": "string", "minLength": 3, "maxLength": 160, "pattern": "^[a-z][a-z0-9-]{2,159}$" } + }, + "oneOf": [ + { + "properties": { + "decision": { "const": "exact" }, + "consumerId": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9.-]{2,119}$" } + } + }, + { + "properties": { + "decision": { "const": "approximate" }, + "consumerId": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9.-]{2,119}$" } + } + }, + { + "properties": { + "decision": { "const": "unsupported" }, + "consumerId": { "type": "null" } + } + } + ] + }, + "presentationProfile": { + "type": "object", + "additionalProperties": false, + "required": ["profileVersion", "geometryPolicy", "sceneSemantics"], + "properties": { + "profileVersion": { "const": 1 }, + "geometryPolicy": { "const": "scene-bounded" }, + "sceneSemantics": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "surfaces", "parameters", "assetSlots"], + "properties": { + "scope": { "const": "presentation-scene" }, + "surfaces": { "$ref": "#/$defs/sceneSurfaceMappings" }, + "parameters": { "$ref": "#/$defs/sceneParameterMappings" }, + "assetSlots": { "$ref": "#/$defs/sceneAssetSlotMappings" } + } + } + } + }, + "sceneSurfaceMappings": { + "type": "object", + "additionalProperties": false, + "required": ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], + "properties": { + "shell": { "$ref": "#/$defs/mappingDecision" }, + "navigation": { "$ref": "#/$defs/mappingDecision" }, + "home": { "$ref": "#/$defs/mappingDecision" }, + "conversation": { "$ref": "#/$defs/mappingDecision" }, + "composer": { "$ref": "#/$defs/mappingDecision" }, + "cards": { "$ref": "#/$defs/mappingDecision" }, + "overlays": { "$ref": "#/$defs/mappingDecision" } + } + }, + "sceneParameterMappings": { + "type": "object", + "additionalProperties": false, + "required": ["density", "borderTreatment", "textureIntensity", "surfaceOpacity", "navigationTreatment", "composerTreatment", "cardTreatment"], + "properties": { + "density": { "$ref": "#/$defs/mappingDecision" }, + "borderTreatment": { "$ref": "#/$defs/mappingDecision" }, + "textureIntensity": { "$ref": "#/$defs/mappingDecision" }, + "surfaceOpacity": { "$ref": "#/$defs/mappingDecision" }, + "navigationTreatment": { "$ref": "#/$defs/mappingDecision" }, + "composerTreatment": { "$ref": "#/$defs/mappingDecision" }, + "cardTreatment": { "$ref": "#/$defs/mappingDecision" } + } + }, + "sceneAssetSlotMappings": { + "type": "object", + "additionalProperties": false, + "required": ["scene.backdrop"], + "properties": { + "scene.backdrop": { "$ref": "#/$defs/mappingDecision" } + } + }, + "presentationBoundaries": { + "type": "object", + "additionalProperties": false, + "required": ["nativeControls", "layout", "uncataloguedPortals", "fonts"], + "properties": { + "nativeControls": { "$ref": "#/$defs/mappingDecision" }, + "layout": { "$ref": "#/$defs/mappingDecision" }, + "uncataloguedPortals": { "$ref": "#/$defs/mappingDecision" }, + "fonts": { "$ref": "#/$defs/mappingDecision" } + } + } } } diff --git a/app/packages/contracts/unified-theme-v1.schema.json b/app/packages/contracts/unified-theme-v1.schema.json index 2e45a48..aeab173 100644 --- a/app/packages/contracts/unified-theme-v1.schema.json +++ b/app/packages/contracts/unified-theme-v1.schema.json @@ -19,7 +19,8 @@ "properties": { "tokens": { "$ref": "#/$defs/tokens" }, "background": { "$ref": "#/$defs/background" }, - "accessibility": { "$ref": "#/$defs/accessibility" } + "accessibility": { "$ref": "#/$defs/accessibility" }, + "appearanceVariants": { "$ref": "#/$defs/appearanceVariants" } } }, "presentation": { "$ref": "#/$defs/immersiveScenePresentation" }, @@ -94,6 +95,35 @@ "composerSurface": { "$ref": "#/$defs/color" } } }, + "completeSemanticColors": { + "allOf": [ + { "$ref": "#/$defs/semanticColors" }, + { + "required": [ + "surfaceBase", "surfaceRaised", "surfaceElevated", "surfaceCode", "text", "textStrong", "textMuted", + "placeholder", "borderSubtle", "borderDefault", "borderStrong", "action", "actionHover", "actionPressed", + "actionForeground", "hoverSurface", "pressedSurface", "selectedSurface", "selectedHoverSurface", "focusRing", + "link", "danger", "success", "warning", "sidebarSurface", "headerSurface", "mainScrimStart", "mainScrimMid", + "mainScrimEnd", "composerSurface" + ] + } + ] + }, + "appearanceVariant": { + "type": "object", + "additionalProperties": false, + "required": ["colors"], + "properties": { "colors": { "$ref": "#/$defs/completeSemanticColors" } } + }, + "appearanceVariants": { + "type": "object", + "additionalProperties": false, + "required": ["light", "dark"], + "properties": { + "light": { "$ref": "#/$defs/appearanceVariant" }, + "dark": { "$ref": "#/$defs/appearanceVariant" } + } + }, "fonts": { "type": "object", "additionalProperties": false, @@ -231,7 +261,7 @@ "density": { "const": "comfortable" }, "borderTreatment": { "const": "etched" }, "textureIntensity": { "type": "number", "minimum": 0, "maximum": 1 }, - "surfaceOpacity": { "type": "number", "minimum": 0.4, "maximum": 0.88 }, + "surfaceOpacity": { "type": "number", "minimum": 0, "maximum": 1 }, "navigationTreatment": { "const": "framed" }, "composerTreatment": { "const": "anchored" }, "cardTreatment": { "const": "elevated" } diff --git a/app/packages/shared-core/compiler.mjs b/app/packages/shared-core/compiler.mjs index fd2d73d..8de3bac 100644 --- a/app/packages/shared-core/compiler.mjs +++ b/app/packages/shared-core/compiler.mjs @@ -9,9 +9,13 @@ import { import { IMMERSIVE_SCENE_PROFILE_ID, IMMERSIVE_SCENE_PROFILE_VERSION, + IMMERSIVE_SCENE_ASSET_SLOTS, + IMMERSIVE_SCENE_BOUNDARIES, + IMMERSIVE_SCENE_PARAMETERS, IMMERSIVE_SCENE_SURFACES, normalizePresentation, presentationCapability, + validatePresentationBoundaries, } from "./presentation.mjs"; export const UNIFIED_THEME_KIND = "cc-theme.unified-theme"; @@ -126,6 +130,98 @@ function validateColors(value, field, required = []) { } } +function parseColorComponent(value, field, { alpha = false } = {}) { + const source = value.trim(); + const percent = source.endsWith("%"); + const numeric = Number(percent ? source.slice(0, -1) : source); + if (!Number.isFinite(numeric)) throw new ThemeCompilerError("contains an invalid color component", field); + if (alpha) { + const normalized = percent ? numeric / 100 : numeric; + if (normalized < 0 || normalized > 1) throw new ThemeCompilerError("contains an out-of-range alpha channel", field); + return normalized; + } + const normalized = percent ? numeric * 2.55 : numeric; + if (normalized < 0 || normalized > 255) throw new ThemeCompilerError("contains an out-of-range RGB channel", field); + return normalized; +} + +function parseCssColor(value, field) { + const hex = /^#([0-9A-Fa-f]{6})$/.exec(value); + if (hex) { + return { + red: Number.parseInt(hex[1].slice(0, 2), 16), + green: Number.parseInt(hex[1].slice(2, 4), 16), + blue: Number.parseInt(hex[1].slice(4, 6), 16), + alpha: 1, + }; + } + const functional = /^(rgb|rgba)\((.*)\)$/.exec(value); + if (!functional) throw new ThemeCompilerError("cannot be used for an accessibility contrast audit", field); + const channels = functional[2].split(","); + const expected = functional[1] === "rgb" ? 3 : 4; + if (channels.length !== expected) throw new ThemeCompilerError("contains an invalid RGB color value", field); + return { + red: parseColorComponent(channels[0], field), + green: parseColorComponent(channels[1], field), + blue: parseColorComponent(channels[2], field), + alpha: expected === 4 ? parseColorComponent(channels[3], field, { alpha: true }) : 1, + }; +} + +function compositeColor(foreground, background) { + const alpha = foreground.alpha + background.alpha * (1 - foreground.alpha); + if (alpha === 0) return { red: 0, green: 0, blue: 0, alpha: 0 }; + return { + red: (foreground.red * foreground.alpha + background.red * background.alpha * (1 - foreground.alpha)) / alpha, + green: (foreground.green * foreground.alpha + background.green * background.alpha * (1 - foreground.alpha)) / alpha, + blue: (foreground.blue * foreground.alpha + background.blue * background.alpha * (1 - foreground.alpha)) / alpha, + alpha, + }; +} + +function relativeLuminance(color) { + const channel = (value) => { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(color.red) + 0.7152 * channel(color.green) + 0.0722 * channel(color.blue); +} + +function contrastRatio(foreground, background) { + const first = relativeLuminance(foreground); + const second = relativeLuminance(background); + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); +} + +function validateDeclaredTextContrast(colors, minimum, field) { + if (minimum === undefined) return; + const surfaceBase = parseCssColor(colors.surfaceBase, `${field}.surfaceBase`); + if (surfaceBase.alpha !== 1) { + throw new ThemeCompilerError("must be opaque when minimumTextContrast is declared", `${field}.surfaceBase`); + } + const resolvedSurface = (surface) => compositeColor(parseCssColor(colors[surface], `${field}.${surface}`), surfaceBase); + const check = (foregroundName, backgroundName, background) => { + const foreground = compositeColor(parseCssColor(colors[foregroundName], `${field}.${foregroundName}`), background); + const ratio = contrastRatio(foreground, background); + if (ratio < minimum) { + throw new ThemeCompilerError( + `contrast ${ratio.toFixed(2)}:1 is below declared minimumTextContrast ${minimum}:1`, + `${field}.${foregroundName}/${backgroundName}`, + ); + } + }; + + const actionBackgrounds = ["action", "actionHover", "actionPressed"].filter((name) => colors[name] !== undefined); + for (const backgroundName of actionBackgrounds) check("actionForeground", backgroundName, resolvedSurface(backgroundName)); + + const textSurfaces = ["surfaceBase", "surfaceRaised", "composerSurface"].filter((name) => colors[name] !== undefined); + for (const backgroundName of textSurfaces) { + const background = backgroundName === "surfaceBase" ? surfaceBase : resolvedSurface(backgroundName); + check("text", backgroundName, background); + check("textMuted", backgroundName, background); + } +} + function validateFonts(value, field) { const fonts = allowedObject(value, FONT_KEYS, field); if (!Object.keys(fonts).length) throw new ThemeCompilerError("must contain at least one font family", field); @@ -197,7 +293,11 @@ export function stableStringify(value, space = 2) { } const FAMILY_ROOT_KEYS = ["kind", "schemaVersion", "id", "name", "version", "sharedCore", "presentation", "targets", "targetProfiles"]; -const SHARED_CORE_KEYS = ["tokens", "background", "accessibility"]; +const SHARED_CORE_REQUIRED_KEYS = ["tokens", "background", "accessibility"]; +// `appearanceVariants` is deliberately additive. A family only needs this +// closed pair when it needs bespoke light/dark scene paint; existing themes +// continue to use their single semantic palette unchanged. +const SHARED_CORE_KEYS = [...SHARED_CORE_REQUIRED_KEYS, "appearanceVariants"]; const PROFILE_FORBIDDEN_KEYS = new Set([ "css", "javascript", "script", "html", "shader", "selector", "selectors", "command", "commands", "url", "urls", "path", "paths", ]); @@ -231,7 +331,7 @@ function validateThemeFamilyVersion(value, expectedSchemaVersion, registry = DEF string(theme.name, "theme.name", { min: 1, max: 80 }); string(theme.version, "theme.version", { max: 40, pattern: VERSION_PATTERN }); const core = allowedObject(theme.sharedCore, SHARED_CORE_KEYS, "theme.sharedCore"); - requireKeys(core, SHARED_CORE_KEYS, "theme.sharedCore"); + requireKeys(core, SHARED_CORE_REQUIRED_KEYS, "theme.sharedCore"); const tokens = allowedObject(core.tokens, TOKEN_KEYS, "theme.sharedCore.tokens"); requireKeys(tokens, ["colors", "fonts"], "theme.sharedCore.tokens"); validateColors(tokens.colors, "theme.sharedCore.tokens.colors", REQUIRED_COLOR_KEYS); @@ -245,6 +345,21 @@ function validateThemeFamilyVersion(value, expectedSchemaVersion, registry = DEF optionalNumber(accessibility.minimumLargeTextContrast, 3, 7, "theme.sharedCore.accessibility.minimumLargeTextContrast"); if (accessibility.preserveSystemFocusRing !== undefined && typeof accessibility.preserveSystemFocusRing !== "boolean") throw new ThemeCompilerError("must be a boolean", "theme.sharedCore.accessibility.preserveSystemFocusRing"); if (accessibility.transparencyFallback !== undefined) choice(accessibility.transparencyFallback, ["opaque", "increased-scrim"], "theme.sharedCore.accessibility.transparencyFallback"); + validateDeclaredTextContrast(tokens.colors, accessibility.minimumTextContrast, "theme.sharedCore.tokens.colors"); + if (core.appearanceVariants !== undefined) { + const variants = allowedObject(core.appearanceVariants, ["light", "dark"], "theme.sharedCore.appearanceVariants"); + requireKeys(variants, ["light", "dark"], "theme.sharedCore.appearanceVariants"); + for (const appearance of ["light", "dark"]) { + const variant = allowedObject(variants[appearance], ["colors"], `theme.sharedCore.appearanceVariants.${appearance}`); + requireKeys(variant, ["colors"], `theme.sharedCore.appearanceVariants.${appearance}`); + validateColors(variant.colors, `theme.sharedCore.appearanceVariants.${appearance}.colors`, COLOR_KEYS); + validateDeclaredTextContrast( + variant.colors, + accessibility.minimumTextContrast, + `theme.sharedCore.appearanceVariants.${appearance}.colors`, + ); + } + } if (!Array.isArray(theme.targets) || !theme.targets.length || new Set(theme.targets).size !== theme.targets.length) throw new ThemeCompilerError("must contain unique Adapter ids", "theme.targets"); for (const adapterId of theme.targets) capabilityFor(adapterId, registry); const profiles = theme.targetProfiles === undefined ? {} : object(theme.targetProfiles, "theme.targetProfiles"); @@ -301,21 +416,66 @@ function assertPresentationCapability(capability, presentation) { if (!presentation) return []; const declared = presentationCapability(capability.presentationProfiles?.[presentation.profileId]); if (presentation.profileId !== IMMERSIVE_SCENE_PROFILE_ID || presentation.profileVersion !== IMMERSIVE_SCENE_PROFILE_VERSION || - declared.profileVersion !== presentation.profileVersion || declared.geometryPolicy !== presentation.geometryPolicy) { - throw new ThemeCompilerError("does not support the required immersive-scene-v1 profile revision", `${capability.adapterId}.presentation`); + declared.profileVersion !== presentation.profileVersion || declared.geometryPolicy !== presentation.geometryPolicy || + declared.scope !== "presentation-scene") { + throw new ThemeCompilerError("presentation-mapping-incomplete: does not support the required immersive-scene-v1 profile revision", `${capability.adapterId}.presentation`); } for (const surface of IMMERSIVE_SCENE_SURFACES) { if (declared.surfaces[surface] !== "exact") { - throw new ThemeCompilerError(`does not provide an exact immersive-scene-v1 consumer for ${surface}`, `${capability.adapterId}.presentation.surfaces.${surface}`); + throw new ThemeCompilerError(`presentation-mapping-incomplete: does not provide an exact immersive-scene-v1 consumer for ${surface}`, `${capability.adapterId}.presentation.surfaces.${surface}`); } } - return IMMERSIVE_SCENE_SURFACES.map((surface) => ({ + for (const parameter of IMMERSIVE_SCENE_PARAMETERS) { + if (declared.parameters[parameter] !== "exact") { + throw new ThemeCompilerError(`presentation-mapping-incomplete: does not provide an exact immersive-scene-v1 parameter consumer for ${parameter}`, `${capability.adapterId}.presentation.parameters.${parameter}`); + } + } + for (const assetSlot of IMMERSIVE_SCENE_ASSET_SLOTS) { + if (declared.assetSlots[assetSlot] !== "exact") { + throw new ThemeCompilerError(`presentation-mapping-incomplete: does not provide an exact immersive-scene-v1 asset slot consumer for ${assetSlot}`, `${capability.adapterId}.presentation.assetSlots.${assetSlot}`); + } + } + let boundaries; + try { + boundaries = validatePresentationBoundaries(capability.presentationBoundaries, `${capability.adapterId}.presentationBoundaries`); + } catch (error) { + throw new ThemeCompilerError(`presentation-mapping-incomplete: ${error.message}`, `${capability.adapterId}.presentationBoundaries`); + } + for (const boundary of IMMERSIVE_SCENE_BOUNDARIES) { + if (boundaries[boundary].decision !== "unsupported") { + throw new ThemeCompilerError(`presentation-mapping-incomplete: ${boundary} must be explicitly unsupported or mapped by a later profile revision`, `${capability.adapterId}.presentationBoundaries.${boundary}`); + } + } + return [ + ...IMMERSIVE_SCENE_SURFACES.map((surface) => ({ severity: "info", field: `presentation.surfaces.${surface}`, decision: "exact", code: "immersive-scene-consumer-exact", message: `${capability.adapterId} provides an exact immersive-scene-v1 consumer for ${surface}.`, - })); + })), + ...IMMERSIVE_SCENE_PARAMETERS.map((parameter) => ({ + severity: "info", + field: `presentation.parameters.${parameter}`, + decision: "exact", + code: "immersive-scene-parameter-consumer-exact", + message: `${capability.adapterId} provides an exact immersive-scene-v1 consumer for ${parameter}.`, + })), + ...IMMERSIVE_SCENE_ASSET_SLOTS.map((assetSlot) => ({ + severity: "info", + field: `presentation.assetSlots.${assetSlot}`, + decision: "exact", + code: "immersive-scene-asset-slot-consumer-exact", + message: `${capability.adapterId} provides an exact immersive-scene-v1 consumer for ${assetSlot}.`, + })), + ...IMMERSIVE_SCENE_BOUNDARIES.map((boundary) => ({ + severity: "info", + field: `presentation.boundaries.${boundary}`, + decision: "unsupported", + code: boundaries[boundary].diagnostic, + message: `${capability.adapterId} keeps ${boundary} host-owned outside immersive-scene-v1.`, + })), + ]; } function contextFor(capability, compileContext) { diff --git a/app/packages/shared-core/presentation.mjs b/app/packages/shared-core/presentation.mjs index e0de70b..4fa6709 100644 --- a/app/packages/shared-core/presentation.mjs +++ b/app/packages/shared-core/presentation.mjs @@ -3,18 +3,26 @@ export const IMMERSIVE_SCENE_PROFILE_VERSION = 1; export const IMMERSIVE_SCENE_SURFACES = Object.freeze([ "shell", "navigation", "home", "conversation", "composer", "cards", "overlays", ]); - -const PARAMETER_KEYS = new Set([ +export const IMMERSIVE_SCENE_PARAMETERS = Object.freeze([ "density", "borderTreatment", "textureIntensity", "surfaceOpacity", "navigationTreatment", "composerTreatment", "cardTreatment", ]); -const ASSET_SLOT_KEYS = new Set(["scene.backdrop"]); +export const IMMERSIVE_SCENE_ASSET_SLOTS = Object.freeze(["scene.backdrop"]); +export const IMMERSIVE_SCENE_BOUNDARIES = Object.freeze([ + "nativeControls", "layout", "uncataloguedPortals", "fonts", +]); + +const PARAMETER_KEYS = new Set(IMMERSIVE_SCENE_PARAMETERS); +const ASSET_SLOT_KEYS = new Set(IMMERSIVE_SCENE_ASSET_SLOTS); const FALLBACK_KEYS = new Set(["unsupportedSurface", "reducedMotion"]); const FORBIDDEN_KEYS = new Set([ "css", "javascript", "script", "html", "shader", "selector", "selectors", "command", "commands", "url", "urls", "path", "paths", "width", "height", "display", "grid", "flex", "order", "position", ]); const SAFE_LOCAL_FILE = /^(?!.*[:/\\])[^\u0000-\u001f]+\.(?:png|jpe?g|webp)$/i; +const SAFE_CONSUMER_ID = /^[A-Za-z][A-Za-z0-9.-]{2,119}$/; +const SAFE_DIAGNOSTIC_CODE = /^[a-z][a-z0-9-]{2,159}$/; +const MAPPING_DECISIONS = new Set(["exact", "approximate", "unsupported"]); function fail(label, message) { throw new Error(`${label}: ${message}`); @@ -68,8 +76,8 @@ export function normalizePresentation(value, label = "presentation") { if (!Number.isFinite(parameters.textureIntensity) || parameters.textureIntensity < 0 || parameters.textureIntensity > 1) { fail(`${label}.parameters.textureIntensity`, "must be a number from 0 to 1"); } - if (!Number.isFinite(parameters.surfaceOpacity) || parameters.surfaceOpacity < 0.4 || parameters.surfaceOpacity > 0.88) { - fail(`${label}.parameters.surfaceOpacity`, "must be a number from 0.4 to 0.88"); + if (!Number.isFinite(parameters.surfaceOpacity) || parameters.surfaceOpacity < 0 || parameters.surfaceOpacity > 1) { + fail(`${label}.parameters.surfaceOpacity`, "must be a number from 0 to 1"); } if (parameters.navigationTreatment !== "framed") fail(`${label}.parameters.navigationTreatment`, "must be framed"); if (parameters.composerTreatment !== "anchored") fail(`${label}.parameters.composerTreatment`, "must be anchored"); @@ -87,11 +95,78 @@ export function normalizePresentation(value, label = "presentation") { return structuredClone(presentation); } -export function presentationCapability(profile = {}) { - const surfaces = profile.surfaces ?? {}; +function validateMappingDecision(value, label) { + const mapping = object(value, label); + exactKeys(mapping, new Set(["decision", "consumerId", "diagnostic"]), label); + if (!MAPPING_DECISIONS.has(mapping.decision)) fail(`${label}.decision`, "must be exact, approximate, or unsupported"); + if (typeof mapping.diagnostic !== "string" || !SAFE_DIAGNOSTIC_CODE.test(mapping.diagnostic)) { + fail(`${label}.diagnostic`, "must be a stable diagnostic code"); + } + if (mapping.decision === "unsupported") { + if (mapping.consumerId !== null) fail(`${label}.consumerId`, "must be null for unsupported mappings"); + } else if (typeof mapping.consumerId !== "string" || !SAFE_CONSUMER_ID.test(mapping.consumerId)) { + fail(`${label}.consumerId`, "must be a bounded opaque consumer id for exact or approximate mappings"); + } + return structuredClone(mapping); +} + +function validateMappingScope(value, keys, label) { + const scope = object(value, label); + exactKeys(scope, new Set(keys), label); + const missing = keys.filter((key) => !Object.hasOwn(scope, key)); + if (missing.length) fail(label, `requires mapping decisions for: ${missing.join(", ")}`); + return Object.fromEntries(keys.map((key) => [key, validateMappingDecision(scope[key], `${label}.${key}`)])); +} + +export function validatePresentationProfileCapability(value, label = "presentationProfile") { + const profile = object(value, label); + exactKeys(profile, new Set(["profileVersion", "geometryPolicy", "sceneSemantics"]), label); + if (profile.profileVersion !== IMMERSIVE_SCENE_PROFILE_VERSION) { + fail(`${label}.profileVersion`, `must be ${IMMERSIVE_SCENE_PROFILE_VERSION}`); + } + if (profile.geometryPolicy !== "scene-bounded") fail(`${label}.geometryPolicy`, "must be scene-bounded"); + const semantics = object(profile.sceneSemantics, `${label}.sceneSemantics`); + exactKeys(semantics, new Set(["scope", "surfaces", "parameters", "assetSlots"]), `${label}.sceneSemantics`); + if (semantics.scope !== "presentation-scene") fail(`${label}.sceneSemantics.scope`, "must be presentation-scene"); return { profileVersion: profile.profileVersion, geometryPolicy: profile.geometryPolicy, - surfaces: Object.fromEntries(IMMERSIVE_SCENE_SURFACES.map((surface) => [surface, surfaces[surface] ?? "unsupported"])), + sceneSemantics: { + scope: semantics.scope, + surfaces: validateMappingScope(semantics.surfaces, IMMERSIVE_SCENE_SURFACES, `${label}.sceneSemantics.surfaces`), + parameters: validateMappingScope(semantics.parameters, IMMERSIVE_SCENE_PARAMETERS, `${label}.sceneSemantics.parameters`), + assetSlots: validateMappingScope(semantics.assetSlots, IMMERSIVE_SCENE_ASSET_SLOTS, `${label}.sceneSemantics.assetSlots`), + }, }; } + +export function validatePresentationBoundaries(value, label = "presentationBoundaries") { + return validateMappingScope(value, IMMERSIVE_SCENE_BOUNDARIES, label); +} + +function unsupportedPresentationCapability(profile = {}) { + return { + profileVersion: profile.profileVersion, + geometryPolicy: profile.geometryPolicy, + scope: null, + surfaces: Object.fromEntries(IMMERSIVE_SCENE_SURFACES.map((surface) => [surface, "unsupported"])), + parameters: Object.fromEntries(IMMERSIVE_SCENE_PARAMETERS.map((parameter) => [parameter, "unsupported"])), + assetSlots: Object.fromEntries(IMMERSIVE_SCENE_ASSET_SLOTS.map((slot) => [slot, "unsupported"])), + }; +} + +export function presentationCapability(profile = {}) { + try { + const validated = validatePresentationProfileCapability(profile); + return { + profileVersion: validated.profileVersion, + geometryPolicy: validated.geometryPolicy, + scope: validated.sceneSemantics.scope, + surfaces: Object.fromEntries(Object.entries(validated.sceneSemantics.surfaces).map(([key, value]) => [key, value.decision])), + parameters: Object.fromEntries(Object.entries(validated.sceneSemantics.parameters).map(([key, value]) => [key, value.decision])), + assetSlots: Object.fromEntries(Object.entries(validated.sceneSemantics.assetSlots).map(([key, value]) => [key, value.decision])), + }; + } catch { + return unsupportedPresentationCapability(profile); + } +} diff --git a/app/packages/shared-core/tests/contrast.test.mjs b/app/packages/shared-core/tests/contrast.test.mjs new file mode 100644 index 0000000..c63d1ff --- /dev/null +++ b/app/packages/shared-core/tests/contrast.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { validateThemeFamily } from "../compiler.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const readTheme = async () => JSON.parse(await fs.readFile(path.join(root, "app/packages/test-kit/fixtures/unified-theme.json"), "utf8")); + +const SEMANTIC_COLOR_KEYS = [ + "surfaceBase", "surfaceRaised", "surfaceElevated", "surfaceCode", "text", "textStrong", "textMuted", "placeholder", + "borderSubtle", "borderDefault", "borderStrong", "action", "actionHover", "actionPressed", "actionForeground", + "hoverSurface", "pressedSurface", "selectedSurface", "selectedHoverSurface", "focusRing", "link", "danger", "success", + "warning", "sidebarSurface", "headerSurface", "mainScrimStart", "mainScrimMid", "mainScrimEnd", "composerSurface", +]; + +function completePalette({ surface, text, muted, action, actionHover, actionPressed, actionForeground }) { + return { + ...Object.fromEntries(SEMANTIC_COLOR_KEYS.map((key) => [key, surface])), + text, + textStrong: text, + textMuted: muted, + placeholder: muted, + action, + actionHover, + actionPressed, + actionForeground, + borderStrong: action, + focusRing: action, + link: action, + }; +} + +function appearanceVariants() { + return { + light: { + colors: completePalette({ + surface: "#F7F0E1", text: "#241C15", muted: "#6F5F4B", + action: "#795021", actionHover: "#634019", actionPressed: "#4A2F12", actionForeground: "#FFFFFF", + }), + }, + dark: { + colors: completePalette({ + surface: "#171513", text: "#F3EAD7", muted: "#B5A386", + action: "#8F6431", actionHover: "#795021", actionPressed: "#70482B", actionForeground: "#FFF8E9", + }), + }, + }; +} + +async function contrastSafeTheme() { + const theme = await readTheme(); + Object.assign(theme.sharedCore.tokens.colors, { + action: "#2F7D57", + actionForeground: "#FFFFFF", + }); + return theme; +} + +test("declared minimumTextContrast validates bounded semantic text pairs", async () => { + const theme = await contrastSafeTheme(); + assert.doesNotThrow(() => validateThemeFamily(theme)); +}); + +test("declared minimumTextContrast rejects unreadable action states before projection", async () => { + const theme = await contrastSafeTheme(); + theme.sharedCore.tokens.colors.actionHover = "#6699FF"; + + assert.throws( + () => validateThemeFamily(theme), + /actionForeground\/actionHover: contrast .* below declared minimumTextContrast/, + ); +}); + +test("declared minimumTextContrast rejects muted text against declared semantic surfaces", async () => { + const theme = await contrastSafeTheme(); + Object.assign(theme.sharedCore.tokens.colors, { + surfaceBase: "#FFFFFF", + text: "#666666", + textMuted: "#777777", + }); + delete theme.sharedCore.tokens.colors.surfaceRaised; + delete theme.sharedCore.tokens.colors.composerSurface; + + assert.throws( + () => validateThemeFamily(theme), + /textMuted\/surfaceBase: contrast .* below declared minimumTextContrast/, + ); +}); + +test("appearanceVariants requires a complete, contrast-safe light and dark semantic palette", async () => { + const theme = await contrastSafeTheme(); + theme.sharedCore.appearanceVariants = appearanceVariants(); + + assert.doesNotThrow(() => validateThemeFamily(theme)); +}); + +test("appearanceVariants rejects unknown keys and unreadable per-appearance colors", async () => { + const unknown = await contrastSafeTheme(); + unknown.sharedCore.appearanceVariants = appearanceVariants(); + unknown.sharedCore.appearanceVariants.dark.script = "alert(1)"; + assert.throws(() => validateThemeFamily(unknown), /appearanceVariants\.dark: contains unsupported fields: script/); + + const unreadable = await contrastSafeTheme(); + unreadable.sharedCore.appearanceVariants = appearanceVariants(); + unreadable.sharedCore.appearanceVariants.light.colors.actionHover = "#C8B99B"; + assert.throws( + () => validateThemeFamily(unreadable), + /appearanceVariants\.light\.colors\.actionForeground\/actionHover: contrast .* below declared minimumTextContrast/, + ); +}); diff --git a/app/packages/shared-core/tests/presentation.test.mjs b/app/packages/shared-core/tests/presentation.test.mjs index dd08569..5f23cf3 100644 --- a/app/packages/shared-core/tests/presentation.test.mjs +++ b/app/packages/shared-core/tests/presentation.test.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { DEFAULT_ADAPTER_REGISTRY } from "../../adapter-sdk/adapter-registry.mjs"; import { compileThemeFamily, validateThemeFamily } from "../compiler.mjs"; +import { validatePresentationBoundaries, validatePresentationProfileCapability } from "../presentation.mjs"; import { normalizeSkinTheme as normalizeCodexSkin } from "../../../../adapters/mac-codex/scripts/skin-theme.mjs"; import { normalizeSkinTheme as normalizeDoubaoSkin } from "../../../../adapters/mac-doubao/scripts/skin-theme.mjs"; import { normalizeSkinTheme as normalizeWorkBuddySkin } from "../../../../adapters/mac-workbuddy/scripts/skin-theme.mjs"; @@ -34,12 +35,45 @@ function immersivePresentation(image = "background.webp") { }; } -test("immersive-scene-v1 remains an optional Unified Theme extension and reaches every target", async () => { +function exactSceneSemantics(presentation) { + const exact = (consumerId) => ({ decision: "exact", consumerId, diagnostic: "scene-consumer-exact" }); + return { + scope: "presentation-scene", + surfaces: Object.fromEntries(presentation.surfaces.map((surface) => [surface, exact(`scene.surface.${surface}`)])), + parameters: Object.fromEntries(Object.keys(presentation.parameters).map((parameter) => [parameter, exact(`scene.parameter.${parameter}`)])), + assetSlots: Object.fromEntries(Object.keys(presentation.assetSlots).map((slot) => [slot, exact(`scene.asset.${slot.replace(".", "-")}`)])), + }; +} + +function hostOwnedBoundaries() { + return Object.fromEntries([ + "nativeControls", "layout", "uncataloguedPortals", "fonts", + ].map((boundary) => [boundary, { + decision: "unsupported", + consumerId: null, + diagnostic: `scene-${boundary.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-host-owned`, + }])); +} + +function registryWithExactSceneSemantics(presentation) { + const registry = structuredClone(DEFAULT_ADAPTER_REGISTRY); + for (const capability of registry.capabilities) { + const profile = capability.presentationProfiles["immersive-scene-v1"]; + delete profile.surfaces; + delete profile.scope; + profile.sceneSemantics = exactSceneSemantics(presentation); + capability.presentationBoundaries = hostOwnedBoundaries(); + } + return registry; +} + +test("immersive-scene-v1 reaches every target only when each Adapter declares complete exact scene semantics", async () => { const theme = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); const context = await readJson("app/packages/test-kit/fixtures/compile-context.json"); theme.presentation = immersivePresentation(theme.sharedCore.background.image); + const registry = registryWithExactSceneSemantics(theme.presentation); validateThemeFamily(theme); - const compiled = await compileThemeFamily(theme, context); + const compiled = await compileThemeFamily(theme, context, { registry }); for (const [adapterId, target] of Object.entries(compiled.themes)) { assert.equal(target.presentation.profileId, "immersive-scene-v1", adapterId); assert.equal(compiled.applyAvailability[adapterId].allowed, true, adapterId); @@ -50,7 +84,27 @@ test("immersive-scene-v1 remains an optional Unified Theme extension and reaches assert.equal(normalizeWorkBuddySkin(compiled.themes["mac-workbuddy"]).presentation.profileId, "immersive-scene-v1"); }); -test("immersive-scene-v1 rejects executable payloads, incorrect asset bindings, and missing exact consumers", async () => { +test("immersive scene surface opacity accepts the complete 0–100% range across every Adapter normalizer", async () => { + const source = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); + for (const surfaceOpacity of [0, 1]) { + const theme = structuredClone(source); + theme.presentation = immersivePresentation(theme.sharedCore.background.image); + theme.presentation.parameters.surfaceOpacity = surfaceOpacity; + validateThemeFamily(theme); + const registry = registryWithExactSceneSemantics(theme.presentation); + const context = await readJson("app/packages/test-kit/fixtures/compile-context.json"); + const compilation = await compileThemeFamily(theme, context, { registry }); + assert.equal(normalizeCodexSkin(compilation.themes["mac-codex"]).presentation.parameters.surfaceOpacity, surfaceOpacity); + assert.equal(normalizeDoubaoSkin(compilation.themes["mac-doubao"]).presentation.parameters.surfaceOpacity, surfaceOpacity); + assert.equal(normalizeWorkBuddySkin(compilation.themes["mac-workbuddy"]).presentation.parameters.surfaceOpacity, surfaceOpacity); + } + const invalid = structuredClone(source); + invalid.presentation = immersivePresentation(invalid.sharedCore.background.image); + invalid.presentation.parameters.surfaceOpacity = 1.01; + assert.throws(() => validateThemeFamily(invalid), /surfaceOpacity.*0 to 1/); +}); + +test("immersive-scene-v1 rejects executable payloads, incorrect asset bindings, and missing profile declarations", async () => { const theme = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); const context = await readJson("app/packages/test-kit/fixtures/compile-context.json"); theme.presentation = immersivePresentation(theme.sharedCore.background.image); @@ -63,7 +117,126 @@ test("immersive-scene-v1 rejects executable payloads, incorrect asset bindings, wrongAsset.presentation.assetSlots["scene.backdrop"] = "other.webp"; assert.throws(() => validateThemeFamily(wrongAsset), /scene.backdrop must bind/); - const registry = structuredClone(DEFAULT_ADAPTER_REGISTRY); + const registry = registryWithExactSceneSemantics(theme.presentation); registry.capabilities.find((entry) => entry.adapterId === "mac-doubao").presentationProfiles = {}; - await assert.rejects(() => compileThemeFamily(theme, context, { registry }), /does not support the required immersive-scene-v1/); + await assert.rejects( + () => compileThemeFamily(theme, context, { registry, targetAdapterIds: ["mac-doubao"] }), + /does not support the required immersive-scene-v1/, + ); +}); + +test("exact-required presentation fails closed when a required scene parameter has no exact consumer", async () => { + const theme = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); + const context = await readJson("app/packages/test-kit/fixtures/compile-context.json"); + theme.presentation = immersivePresentation(theme.sharedCore.background.image); + + const registry = registryWithExactSceneSemantics(theme.presentation); + const capability = registry.capabilities.find((entry) => entry.adapterId === "mac-doubao"); + capability.presentationProfiles["immersive-scene-v1"].sceneSemantics.parameters.cardTreatment = { + decision: "unsupported", + consumerId: null, + diagnostic: "scene-parameter-not-consumed", + }; + + await assert.rejects( + () => compileThemeFamily(theme, context, { registry, targetAdapterIds: ["mac-doubao"] }), + /presentation-mapping-incomplete.*parameter consumer for cardTreatment/, + ); +}); + +test("immersive-scene-v1 makes host-owned boundaries explicit in normalized capability diagnostics", async () => { + const theme = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); + const context = await readJson("app/packages/test-kit/fixtures/compile-context.json"); + theme.presentation = immersivePresentation(theme.sharedCore.background.image); + + for (const adapterId of ["mac-doubao", "mac-workbuddy"]) { + const capability = DEFAULT_ADAPTER_REGISTRY.capabilities.find((entry) => entry.adapterId === adapterId); + assert.doesNotThrow(() => validatePresentationBoundaries(capability.presentationBoundaries, `${adapterId}.presentationBoundaries`)); + const compilation = await compileThemeFamily(theme, context, { targetAdapterIds: [adapterId] }); + const boundaries = compilation.diagnostics[adapterId] + .filter((entry) => entry.field.startsWith("presentation.boundaries.")); + assert.equal(boundaries.length, 4, adapterId); + assert.ok(boundaries.every((entry) => entry.decision === "unsupported"), adapterId); + } +}); + +test("immersive-scene-v1 fails closed when an Adapter omits a declared host boundary", async () => { + const theme = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); + const context = await readJson("app/packages/test-kit/fixtures/compile-context.json"); + theme.presentation = immersivePresentation(theme.sharedCore.background.image); + const registry = registryWithExactSceneSemantics(theme.presentation); + delete registry.capabilities.find((entry) => entry.adapterId === "mac-doubao").presentationBoundaries.fonts; + + await assert.rejects( + () => compileThemeFamily(theme, context, { registry, targetAdapterIds: ["mac-doubao"] }), + /presentation-mapping-incomplete.*presentationBoundaries/, + ); +}); + +test("legacy themes without a presentation extension keep compiling against current Adapter capabilities", async () => { + const theme = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); + const context = await readJson("app/packages/test-kit/fixtures/compile-context.json"); + + const compilation = await compileThemeFamily(theme, context, { targetAdapterIds: ["mac-doubao"] }); + assert.equal(compilation.applyAvailability["mac-doubao"].allowed, true); + assert.equal(compilation.themes["mac-doubao"].presentation, undefined); +}); + +test("current mac-codex capability publishes complete immersive-scene semantics and explicit host boundaries", async () => { + const theme = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); + const context = await readJson("app/packages/test-kit/fixtures/compile-context.json"); + theme.presentation = immersivePresentation(theme.sharedCore.background.image); + + const capability = DEFAULT_ADAPTER_REGISTRY.capabilities.find((entry) => entry.adapterId === "mac-codex"); + assert.equal(validatePresentationProfileCapability(capability.presentationProfiles["immersive-scene-v1"]) + .sceneSemantics.scope, "presentation-scene"); + assert.doesNotThrow(() => validatePresentationBoundaries(capability.presentationBoundaries)); + + const compilation = await compileThemeFamily(theme, context, { targetAdapterIds: ["mac-codex"] }); + assert.equal(compilation.applyAvailability["mac-codex"].allowed, true); + const diagnostics = compilation.diagnostics["mac-codex"].filter((entry) => entry.field.startsWith("presentation.")); + assert.equal(diagnostics.length, 19); + assert.equal(diagnostics.filter((entry) => entry.decision === "exact").length, 15); + assert.equal(diagnostics.filter((entry) => entry.decision === "unsupported").length, 4); +}); + +test("presentation capability closes its semantic scope and mapping decisions", async () => { + const theme = await readJson("app/packages/test-kit/fixtures/unified-theme.json"); + const profile = { + profileVersion: 1, + geometryPolicy: "scene-bounded", + sceneSemantics: exactSceneSemantics(immersivePresentation(theme.sharedCore.background.image)), + }; + assert.equal(validatePresentationProfileCapability(profile).sceneSemantics.scope, "presentation-scene"); + + const unknownScopeField = structuredClone(profile); + unknownScopeField.sceneSemantics.parameters.animationScript = { + decision: "exact", + consumerId: "scene.parameter.animationScript", + diagnostic: "scene-parameter-exact", + }; + assert.throws(() => validatePresentationProfileCapability(unknownScopeField), /unsupported fields/); + + const invalidMapping = structuredClone(profile); + invalidMapping.sceneSemantics.assetSlots["scene.backdrop"] = { + decision: "exact", + consumerId: null, + diagnostic: "scene-asset-exact", + }; + assert.throws(() => validatePresentationProfileCapability(invalidMapping), /bounded opaque consumer id/); +}); + +test("public Adapter capability schema requires the same closed presentation-scene mapping contract", async () => { + const schema = await readJson("app/packages/contracts/adapter-capability.schema.json"); + const profile = schema.$defs.presentationProfile; + const semantics = profile.properties.sceneSemantics; + const mapping = schema.$defs.mappingDecision; + + assert.deepEqual(profile.required, ["profileVersion", "geometryPolicy", "sceneSemantics"]); + assert.equal(profile.additionalProperties, false); + assert.deepEqual(semantics.required, ["scope", "surfaces", "parameters", "assetSlots"]); + assert.equal(semantics.additionalProperties, false); + assert.equal(semantics.properties.scope.const, "presentation-scene"); + assert.deepEqual(mapping.properties.decision.enum, ["exact", "approximate", "unsupported"]); + assert.equal(mapping.additionalProperties, false); }); diff --git a/app/packages/test-kit/fixtures/unified-theme.json b/app/packages/test-kit/fixtures/unified-theme.json index f878055..86dadf5 100644 --- a/app/packages/test-kit/fixtures/unified-theme.json +++ b/app/packages/test-kit/fixtures/unified-theme.json @@ -11,7 +11,7 @@ "surfaceRaised": "rgba(24, 31, 44, 0.86)", "text": "#E9EEF7", "textMuted": "#AAB5C5", - "action": "#6699FF", + "action": "#2F7D57", "actionForeground": "#FFFFFF", "focusRing": "#8EB1FF" }, diff --git a/app/packages/test-kit/golden/example.mac-doubao.theme.json b/app/packages/test-kit/golden/example.mac-doubao.theme.json index 58ff2b3..3a9572d 100644 --- a/app/packages/test-kit/golden/example.mac-doubao.theme.json +++ b/app/packages/test-kit/golden/example.mac-doubao.theme.json @@ -17,6 +17,44 @@ "sidebarSurface": "#121925", "headerSurface": "#141C28" }, + "appearanceVariants": { + "light": { + "colors": { + "text": "#203041", + "muted": "#5D7285" + }, + "semanticColors": { + "surfaceBase": "#F4F7FA", + "surfaceRaised": "#FAFCFE", + "action": "#266446", + "actionForeground": "#FFFFFF", + "focusRing": "#266446", + "sidebarSurface": "#E8EEF4", + "headerSurface": "#FBFDFF", + "mainScrimStart": "rgba(244, 247, 250, .95)", + "mainScrimMid": "rgba(244, 247, 250, .62)", + "mainScrimEnd": "rgba(244, 247, 250, .26)" + } + }, + "dark": { + "colors": { + "text": "#E9EEF7", + "muted": "#AAB5C5" + }, + "semanticColors": { + "surfaceBase": "#10151F", + "surfaceRaised": "#18202D", + "action": "#2F7D57", + "actionForeground": "#FFFFFF", + "focusRing": "#7BCFA8", + "sidebarSurface": "#121925", + "headerSurface": "#141C28", + "mainScrimStart": "rgba(16, 21, 31, .86)", + "mainScrimMid": "rgba(16, 21, 31, .54)", + "mainScrimEnd": "rgba(16, 21, 31, .22)" + } + } + }, "appearance": { "paletteStrategy": "system", "backdropBlurPx": 16, diff --git a/app/packages/test-kit/golden/mac-codex.theme.json b/app/packages/test-kit/golden/mac-codex.theme.json index 24c6e18..1fc3fed 100644 --- a/app/packages/test-kit/golden/mac-codex.theme.json +++ b/app/packages/test-kit/golden/mac-codex.theme.json @@ -8,5 +8,5 @@ "interactiveBackground": { "intensity": 0.35, "quality": "auto", "radiusPx": 24, "scrimOpacity": 0.16, "type": "ripple" }, "kind": "skin.theme", "name": "Neutral Fixture", - "semanticColors": { "action": "#6699FF", "actionForeground": "#FFFFFF", "focusRing": "#8EB1FF", "surfaceBase": "#10151F", "surfaceRaised": "rgba(24, 31, 44, 0.86)" } + "semanticColors": { "action": "#2F7D57", "actionForeground": "#FFFFFF", "focusRing": "#8EB1FF", "surfaceBase": "#10151F", "surfaceRaised": "rgba(24, 31, 44, 0.86)" } } diff --git a/app/packages/test-kit/golden/mac-doubao.theme.json b/app/packages/test-kit/golden/mac-doubao.theme.json index 35f5071..c1c1125 100644 --- a/app/packages/test-kit/golden/mac-doubao.theme.json +++ b/app/packages/test-kit/golden/mac-doubao.theme.json @@ -11,7 +11,7 @@ "semanticColors": { "surfaceBase": "#10151F", "surfaceRaised": "rgba(24, 31, 44, 0.86)", - "action": "#6699FF", + "action": "#2F7D57", "actionForeground": "#FFFFFF", "focusRing": "#8EB1FF", "sidebarSurface": "rgba(24, 31, 44, 0.86)" diff --git a/app/packages/test-kit/golden/mac-workbuddy.theme.json b/app/packages/test-kit/golden/mac-workbuddy.theme.json index db66762..09b97ca 100644 --- a/app/packages/test-kit/golden/mac-workbuddy.theme.json +++ b/app/packages/test-kit/golden/mac-workbuddy.theme.json @@ -7,5 +7,5 @@ "interactiveBackground": { "intensity": 0.35, "quality": "auto", "radiusPx": 24, "scrimOpacity": 0.16, "type": "ripple" }, "kind": "skin.theme", "name": "Neutral Fixture", - "semanticColors": { "action": "#6699FF", "actionForeground": "#FFFFFF", "focusRing": "#8EB1FF", "surfaceBase": "#10151F", "surfaceRaised": "rgba(24, 31, 44, 0.86)" } + "semanticColors": { "action": "#2F7D57", "actionForeground": "#FFFFFF", "focusRing": "#8EB1FF", "surfaceBase": "#10151F", "surfaceRaised": "rgba(24, 31, 44, 0.86)" } } diff --git a/app/registry/adapter-versions.json b/app/registry/adapter-versions.json index 344cf66..8506004 100644 --- a/app/registry/adapter-versions.json +++ b/app/registry/adapter-versions.json @@ -8,15 +8,15 @@ "adapterId": "mac-codex", "current": { "adapterVersion": "26.715.71837", - "adapterReleaseRevision": 2 + "adapterReleaseRevision": 3 }, "releases": [ { "adapterVersion": "26.715.71837", - "adapterReleaseRevision": 2, - "assetIdentity": "mac-codex-26.715.71837-r2-macos-arm64", + "adapterReleaseRevision": 3, + "assetIdentity": "mac-codex-26.715.71837-r3-macos-arm64", "contracts": { - "minimumManagerVersion": "0.2.1", + "minimumManagerVersion": "0.2.2", "capabilityVersion": "2.0.0", "unifiedThemeSchemaVersion": 1, "adapterPackageSchemaVersion": 1 @@ -29,15 +29,15 @@ "adapterId": "mac-doubao", "current": { "adapterVersion": "2.19.9", - "adapterReleaseRevision": 2 + "adapterReleaseRevision": 4 }, "releases": [ { "adapterVersion": "2.19.9", - "adapterReleaseRevision": 2, - "assetIdentity": "mac-doubao-2.19.9-r2-macos-arm64", + "adapterReleaseRevision": 4, + "assetIdentity": "mac-doubao-2.19.9-r4-macos-arm64", "contracts": { - "minimumManagerVersion": "0.2.1", + "minimumManagerVersion": "0.2.2", "capabilityVersion": "1.3.0", "unifiedThemeSchemaVersion": 1, "adapterPackageSchemaVersion": 1 @@ -50,15 +50,15 @@ "adapterId": "mac-workbuddy", "current": { "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2 + "adapterReleaseRevision": 4 }, "releases": [ { "adapterVersion": "5.2.6", - "adapterReleaseRevision": 2, - "assetIdentity": "mac-workbuddy-5.2.6-r2-macos-arm64", + "adapterReleaseRevision": 4, + "assetIdentity": "mac-workbuddy-5.2.6-r4-macos-arm64", "contracts": { - "minimumManagerVersion": "0.2.1", + "minimumManagerVersion": "0.2.2", "capabilityVersion": "1.0.0", "unifiedThemeSchemaVersion": 1, "adapterPackageSchemaVersion": 1 diff --git a/app/scripts/prepare-runtime-resources.mjs b/app/scripts/prepare-runtime-resources.mjs index 411f78b..a8f9f8a 100644 --- a/app/scripts/prepare-runtime-resources.mjs +++ b/app/scripts/prepare-runtime-resources.mjs @@ -17,7 +17,10 @@ const RUNTIME_PACKAGE_FILES = Object.freeze({ "adapter-sdk": ["adapter-registry.mjs", "workspace-root.mjs", "package.json"], "theme-core": ["cli.mjs", "compiler.mjs", "presentation.mjs", "runtime-overrides.mjs", "package.json"], }); -const TRANSITION_PROFILE = "transition-baseline"; +const BASELINE_PROFILES = Object.freeze({ + "transition-baseline": "transition-baseline.json", + "release-bundled-latest": "release-baseline.json", +}); function contained(root, candidate, label) { const relative = path.relative(root, candidate); @@ -108,34 +111,34 @@ async function buildReleaseEngine(workspaceRoot, destination, descriptor) { await execute(process.execPath, [builder, destination], { cwd: path.dirname(builder), maxBuffer: 8 * 1024 * 1024 }); } -function validateTransitionBaseline(value, managerVersion) { - exactKeys(value, ["kind", "schemaVersion", "managerVersion", "profile", "adapters"], "transition baseline"); - if (value.kind !== "cc-theme.manager-runtime-profile" || value.schemaVersion !== 1) throw new Error("Transition baseline kind or schemaVersion is invalid"); - if (value.managerVersion !== managerVersion || value.profile !== TRANSITION_PROFILE) throw new Error("Transition baseline Manager identity is invalid"); - if (!Array.isArray(value.adapters) || value.adapters.length !== 3) throw new Error("Transition baseline must contain exactly three Adapters"); +function validateRuntimeBaseline(value, managerVersion, profile) { + exactKeys(value, ["kind", "schemaVersion", "managerVersion", "profile", "adapters"], "runtime baseline"); + if (value.kind !== "cc-theme.manager-runtime-profile" || value.schemaVersion !== 1) throw new Error("Runtime baseline kind or schemaVersion is invalid"); + if (value.managerVersion !== managerVersion || value.profile !== profile) throw new Error("Runtime baseline Manager identity is invalid"); + if (!Array.isArray(value.adapters) || value.adapters.length !== 3) throw new Error("Runtime baseline must contain exactly three Adapters"); const expectedIds = ["mac-codex", "mac-doubao", "mac-workbuddy"]; if (JSON.stringify(value.adapters.map(({ adapterId }) => adapterId)) !== JSON.stringify(expectedIds)) { - throw new Error("Transition baseline Adapter IDs or order are invalid"); + throw new Error("Runtime baseline Adapter IDs or order are invalid"); } for (const adapter of value.adapters) { - exactKeys(adapter, ["adapterId", "adapterVersion", "adapterReleaseRevision", "assetIdentity", "source"], `${adapter.adapterId} transition Adapter`); - exactKeys(adapter.source, ["kind", "capabilityVersion", "releaseTag", "downloadUrl", "bytes", "archiveSha256", "manifestSha256"], `${adapter.adapterId} transition source`); - if (adapter.source.kind !== "github-release-asset") throw new Error(`${adapter.adapterId} transition source kind is invalid`); + exactKeys(adapter, ["adapterId", "adapterVersion", "adapterReleaseRevision", "assetIdentity", "source"], `${adapter.adapterId} runtime Adapter`); + exactKeys(adapter.source, ["kind", "capabilityVersion", "releaseTag", "downloadUrl", "bytes", "archiveSha256", "manifestSha256"], `${adapter.adapterId} runtime source`); + if (adapter.source.kind !== "github-release-asset") throw new Error(`${adapter.adapterId} runtime source kind is invalid`); if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(adapter.source.capabilityVersion)) { - throw new Error(`${adapter.adapterId} transition capabilityVersion is invalid`); + throw new Error(`${adapter.adapterId} runtime capabilityVersion is invalid`); } const expectedIdentity = `${adapter.adapterId}-${adapter.adapterVersion}-r${adapter.adapterReleaseRevision}-macos-arm64`; - if (adapter.assetIdentity !== expectedIdentity) throw new Error(`${adapter.adapterId} transition asset identity is invalid`); + if (adapter.assetIdentity !== expectedIdentity) throw new Error(`${adapter.adapterId} runtime asset identity is invalid`); const url = new URL(adapter.source.downloadUrl); const expectedPath = `/quanzhankeji/cc-theme/releases/download/${adapter.source.releaseTag}/${adapter.assetIdentity}.ccadapter`; if (url.protocol !== "https:" || url.hostname !== "github.com" || url.pathname !== expectedPath || url.search || url.hash || url.username || url.password) { - throw new Error(`${adapter.adapterId} transition URL is not an exact official Release asset`); + throw new Error(`${adapter.adapterId} runtime URL is not an exact official Release asset`); } if (!Number.isSafeInteger(adapter.source.bytes) || adapter.source.bytes < 1 || adapter.source.bytes > 64 * 1024 * 1024) { - throw new Error(`${adapter.adapterId} transition byte length is invalid`); + throw new Error(`${adapter.adapterId} runtime byte length is invalid`); } if (!/^[0-9a-f]{64}$/.test(adapter.source.archiveSha256) || !/^[0-9a-f]{64}$/.test(adapter.source.manifestSha256)) { - throw new Error(`${adapter.adapterId} transition digest is invalid`); + throw new Error(`${adapter.adapterId} runtime digest is invalid`); } } return value; @@ -167,8 +170,8 @@ export async function downloadTransitionPackage(cacheRoot, descriptor, managerVe try { await download(descriptor.source.downloadUrl, temporary); const stat = await fs.lstat(temporary); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== descriptor.source.bytes) throw new Error(`${descriptor.adapterId} downloaded byte length differs from the transition baseline`); - if (await sha256File(temporary) !== descriptor.source.archiveSha256) throw new Error(`${descriptor.adapterId} downloaded archive SHA-256 differs from the transition baseline`); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== descriptor.source.bytes) throw new Error(`${descriptor.adapterId} downloaded byte length differs from the runtime baseline`); + if (await sha256File(temporary) !== descriptor.source.archiveSha256) throw new Error(`${descriptor.adapterId} downloaded archive SHA-256 differs from the runtime baseline`); await fs.rename(temporary, archive); } finally { await fs.rm(temporary, { force: true }); @@ -186,13 +189,13 @@ export async function downloadTransitionPackage(cacheRoot, descriptor, managerVe }, }); if (verified.bytes !== descriptor.source.bytes || verified.manifestSha256 !== descriptor.source.manifestSha256) { - throw new Error(`${descriptor.adapterId} verified package provenance differs from the transition baseline`); + throw new Error(`${descriptor.adapterId} verified package provenance differs from the runtime baseline`); } if ( verified.manifest.adapterVersion !== descriptor.adapterVersion || verified.manifest.adapterReleaseRevision !== descriptor.adapterReleaseRevision || verified.manifest.assetIdentity !== descriptor.assetIdentity - ) throw new Error(`${descriptor.adapterId} verified package identity differs from the transition baseline`); + ) throw new Error(`${descriptor.adapterId} verified package identity differs from the runtime baseline`); return { archive, verified }; } @@ -253,13 +256,15 @@ export async function prepareRuntimeResources({ profile = process.env.CC_THEME_R await cleanTransientRuntimeResources(layout.managerRoot); const config = await readJson(path.join(layout.managerRoot, "config", "adapter-engines.json")); const managerPackage = await readJson(path.join(layout.managerRoot, "package.json")); - const transition = profile === TRANSITION_PROFILE - ? validateTransitionBaseline( - await readJson(path.join(layout.managerRoot, "config", "transition-baseline.json")), + const baselineFile = BASELINE_PROFILES[profile]; + const baseline = baselineFile + ? validateRuntimeBaseline( + await readJson(path.join(layout.managerRoot, "config", baselineFile)), managerPackage.version, + profile, ) : undefined; - if (!transition && profile !== "source-build") throw new Error(`Unknown runtime profile: ${profile}`); + if (!baseline && profile !== "source-build") throw new Error(`Unknown runtime profile: ${profile}`); const registryFile = path.join(layout.registryRoot, "adapter-capabilities.json"); const sourceRegistry = await readJson(registryFile); if (config.kind !== "cc-theme.manager-adapter-engine-sources" || config.schemaVersion !== 1 || !Array.isArray(config.adapters)) { @@ -302,11 +307,11 @@ export async function prepareRuntimeResources({ profile = process.env.CC_THEME_R const attestedAdapters = []; for (const descriptor of config.adapters) { const destination = path.join(stageRoot, "adapters", descriptor.bundleDirectory); - if (transition) { - const baseline = transition.adapters.find(({ adapterId }) => adapterId === descriptor.adapterId); + if (baseline) { + const releaseDescriptor = baseline.adapters.find(({ adapterId }) => adapterId === descriptor.adapterId); const { archive, verified } = await downloadTransitionPackage( - path.join(layout.managerRoot, ".runtime-cache", "transition-baseline"), - baseline, + path.join(layout.managerRoot, ".runtime-cache", profile), + releaseDescriptor, managerPackage.version, ); await extractVerifiedPackage(destination, archive); @@ -315,7 +320,7 @@ export async function prepareRuntimeResources({ profile = process.env.CC_THEME_R adapterVersion: verified.manifest.adapterVersion, adapterReleaseRevision: verified.manifest.adapterReleaseRevision, assetIdentity: verified.manifest.assetIdentity, - source: baseline.source, + source: releaseDescriptor.source, }); } else { if (descriptor.source.kind === "release-archive") await extractReleaseArchive(layout.workspaceRoot, destination, descriptor); diff --git a/app/scripts/verify-runtime-resources.mjs b/app/scripts/verify-runtime-resources.mjs index 4b982cf..740a27c 100644 --- a/app/scripts/verify-runtime-resources.mjs +++ b/app/scripts/verify-runtime-resources.mjs @@ -92,17 +92,21 @@ export async function verifyRuntimeResources(resourceRoot, baselineFile, manager const rootStat = await fs.lstat(root); if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) fail("resource root must be a non-symlink directory"); const [baseline, attestation, manifest] = await Promise.all([ - readJson(path.resolve(baselineFile), "transition baseline"), + readJson(path.resolve(baselineFile), "runtime baseline"), readJson(path.join(root, "runtime-attestation.json"), "runtime attestation"), readJson(path.join(root, "artifact-manifest.json"), "artifact manifest"), ]); - exactKeys(baseline, ["kind", "schemaVersion", "managerVersion", "profile", "adapters"], "transition baseline"); - if (baseline.managerVersion !== managerVersion || baseline.profile !== "transition-baseline") fail("transition baseline does not match the requested Manager"); - if (JSON.stringify(attestation) !== JSON.stringify(expectedAttestation(baseline))) fail("runtime attestation differs from the exact transition baseline"); + exactKeys(baseline, ["kind", "schemaVersion", "managerVersion", "profile", "adapters"], "runtime baseline"); + if ( + baseline.kind !== "cc-theme.manager-runtime-profile" || baseline.schemaVersion !== 1 || + typeof baseline.profile !== "string" || baseline.profile === "source-build" || + baseline.managerVersion !== managerVersion + ) fail("runtime baseline does not match the requested Manager"); + if (JSON.stringify(attestation) !== JSON.stringify(expectedAttestation(baseline))) fail("runtime attestation differs from the exact runtime baseline"); exactKeys(manifest, ["kind", "schemaVersion", "managerVersion", "profile", "attestationSha256", "entries"], "artifact manifest"); if ( manifest.kind !== "cc-theme.manager-runtime-resources" || manifest.schemaVersion !== 2 || - manifest.managerVersion !== managerVersion || manifest.profile !== "transition-baseline" + manifest.managerVersion !== managerVersion || manifest.profile !== baseline.profile ) fail("artifact manifest identity is invalid"); const attestationSha256 = await sha256File(path.join(root, "runtime-attestation.json")); if (manifest.attestationSha256 !== attestationSha256) fail("artifact manifest does not bind the runtime attestation"); @@ -125,7 +129,7 @@ export async function verifyRuntimeResources(resourceRoot, baselineFile, manager } const adapterEntries = await fs.readdir(path.join(root, "adapters"), { withFileTypes: true }); if (JSON.stringify(adapterEntries.map(({ name }) => name).sort()) !== JSON.stringify([...EXPECTED_ADAPTER_IDS])) { - fail("packaged runtime does not contain exactly the three transition Adapters"); + fail("packaged runtime does not contain exactly the three official Adapters"); } for (const entry of adapterEntries) { if (!entry.isDirectory() || entry.isSymbolicLink()) fail(`Adapter runtime is not a regular directory: ${entry.name}`); @@ -148,7 +152,7 @@ export async function verifyRuntimeResources(resourceRoot, baselineFile, manager if ( release.adapterId !== expected.adapterId || release.adapterVersion !== expected.adapterVersion || release.adapterReleaseRevision !== expected.adapterReleaseRevision || release.assetIdentity !== expected.assetIdentity - ) fail(`${expected.adapterId} staged Engine identity differs from the transition baseline`); + ) fail(`${expected.adapterId} staged Engine identity differs from the runtime baseline`); } return { managerVersion, profile: manifest.profile, attestationSha256, entries: manifest.entries.length }; } diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index d139c86..3f716c0 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "cc-theme" -version = "0.2.1" +version = "0.2.2" dependencies = [ "base64 0.22.1", "chrono", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 6a03e84..4592ee0 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-theme" -version = "0.2.1" +version = "0.2.2" description = "Unified macOS theme tool for Codex, Doubao, and WorkBuddy" authors = ["CC Theme contributors"] license = "MIT" diff --git a/app/src-tauri/src/adapter_installer.rs b/app/src-tauri/src/adapter_installer.rs index 28b3bbf..508321a 100644 --- a/app/src-tauri/src/adapter_installer.rs +++ b/app/src-tauri/src/adapter_installer.rs @@ -152,6 +152,43 @@ pub fn active_adapter_root(adapter_id: &str) -> Option { active_adapter_root_from(&crate::registry::manager_adapter_library_root(), adapter_id) } +/// A locally installed Engine normally takes precedence over the bundled +/// fallback. A freshly installed Manager may, however, contain a newer +/// verified Engine than an older local pointer left by a previous build. In +/// that case prefer the signed bundle without mutating the local store; the +/// older local package remains available as a rollback source. +pub fn active_adapter_is_not_older_than(adapter_id: &str, bundled_root: &Path) -> bool { + let Some(active_root) = active_adapter_root(adapter_id) else { + return false; + }; + let Some(active) = release_identity_from(&active_root) else { + return false; + }; + let Some(bundled) = release_identity_from(bundled_root) else { + return false; + }; + release_identity_at_least(&active, &bundled) +} + +fn release_identity_from(root: &Path) -> Option<(semver::Version, u64)> { + let release = read_small_json(&root.join("contracts/adapter-release-manifest.json")).ok()?; + let version = release.get("adapterVersion")?.as_str()?; + let revision = release.get("adapterReleaseRevision")?.as_u64()?; + if !valid_semver(version) || revision == 0 { + return None; + } + semver::Version::parse(version) + .ok() + .map(|version| (version, revision)) +} + +fn release_identity_at_least( + active: &(semver::Version, u64), + bundled: &(semver::Version, u64), +) -> bool { + active.0 > bundled.0 || (active.0 == bundled.0 && active.1 >= bundled.1) +} + fn active_adapter_root_from(library_root: &Path, adapter_id: &str) -> Option { if !matches!(adapter_id, "mac-codex" | "mac-doubao" | "mac-workbuddy") { return None; @@ -1529,6 +1566,24 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn release_precedence_never_selects_a_stale_local_engine_over_a_newer_bundle() { + let release = + |version: &str, revision| (semver::Version::parse(version).unwrap(), revision); + assert!(!release_identity_at_least( + &release("26.715.71837", 1), + &release("26.715.71837", 3), + )); + assert!(release_identity_at_least( + &release("26.715.71837", 3), + &release("26.715.71837", 3), + )); + assert!(release_identity_at_least( + &release("26.715.71838", 1), + &release("26.715.71837", 99), + )); + } + #[test] fn a_catalog_package_mismatch_is_rejected_before_activation() { let root = scratch("catalog-mismatch"); diff --git a/app/src-tauri/src/compiler.rs b/app/src-tauri/src/compiler.rs index fddc2a2..44b9eba 100644 --- a/app/src-tauri/src/compiler.rs +++ b/app/src-tauri/src/compiler.rs @@ -17,8 +17,9 @@ use crate::{ process::{run_with_timeout, ProcessError}, registry::{self, ClientDefinition}, themes::{ - cached_compiled_theme, seed_compiled_theme_into_library, valid_theme_id, - CompiledThemeCacheKey, + cached_compiled_theme, effective_theme_source, local_theme_override_media, + seed_compiled_theme_into_library, stage_effective_theme_assets, valid_theme_id, + CompiledThemeCacheKey, LocalThemeOverrideMedia, }, }; @@ -121,12 +122,12 @@ pub fn compile_and_seed_theme( let theme_root = trusted_directory(&theme_root, "中央主题库")?; let family = trusted_direct_child(&theme_root, theme_id, "Theme Family")?; verify_family_manifest(&family, theme_id)?; - let source = family.join("unified-theme.json"); - if !source.exists() { + if !family.join("unified-theme.json").exists() { return Ok(None); } - trusted_json_file(&source, "Unified Theme")?; + let (effective_source, effective_source_bytes) = effective_theme_source(&family, theme_id)?; let assets = trusted_direct_child(&family, "assets", "Theme Family assets")?; + let local_media = local_theme_override_media(theme_id)?; let compiler_root = compiler_root()?; let cli = compiler_root.join("cli.mjs"); @@ -145,11 +146,13 @@ pub fn compile_and_seed_theme( definition.id.as_str(), theme_id, &family, + &effective_source_bytes, &compile_context, &adapter_root, &compiler_root, &adapter_sdk_root, capability_registry.as_deref(), + local_media.as_ref(), )?; if let Some(cached) = cached_compiled_theme(definition, &cache_key) { return Ok(Some(cached)); @@ -158,6 +161,8 @@ pub fn compile_and_seed_theme( let workspace = CompileWorkspace::create()?; let context_file = workspace.root.join("compile-context.json"); write_private_json(&context_file, &compile_context)?; + let source_file = workspace.root.join("unified-theme.json"); + write_private_json(&source_file, &effective_source)?; let output_root = workspace.root.join("output"); fs::create_dir(&output_root).map_err(|_| "无法创建编译输出目录".to_string())?; set_private_directory(&output_root)?; @@ -165,7 +170,7 @@ pub fn compile_and_seed_theme( let mut command = Command::new(node); command .arg(cli) - .arg(&source) + .arg(&source_file) .arg(&context_file) .arg(&output_root) .arg(definition.id.as_str()) @@ -198,20 +203,35 @@ pub fn compile_and_seed_theme( let target_directory = target_theme .parent() .ok_or_else(|| "Adapter 编译结果目录无效".to_string())?; - seed_compiled_theme_into_library(definition, target_directory, &assets, theme_id, &cache_key) - .map(Some) - .map_err(Into::into) + let effective_assets = if local_media.is_some() { + let staged_assets = workspace.root.join("assets"); + stage_effective_theme_assets(&assets, &staged_assets, local_media.as_ref())?; + staged_assets + } else { + assets + }; + seed_compiled_theme_into_library( + definition, + target_directory, + &effective_assets, + theme_id, + &cache_key, + ) + .map(Some) + .map_err(Into::into) } fn compile_cache_key_from( adapter_id: &str, theme_id: &str, family: &Path, + effective_source: &[u8], compile_context: &Value, adapter_root: &Path, compiler_root: &Path, adapter_sdk_root: &Path, capability_registry: Option<&Path>, + local_media: Option<&LocalThemeOverrideMedia>, ) -> Result { let mut digest = Sha256::new(); digest.update(b"cc-theme-compiled-target-cache-v1\0"); @@ -220,6 +240,21 @@ fn compile_cache_key_from( digest.update(theme_id.as_bytes()); digest.update(b"\0family\0"); update_digest_with_file(&mut digest, &family.join("family.json"), 2 * 1024 * 1024)?; + if effective_source.len() > 2 * 1024 * 1024 { + return Err("主题本地覆盖后的源文件超过安全限制".to_string()); + } + digest.update(b"\0effective-source\0"); + digest.update((effective_source.len() as u64).to_le_bytes()); + digest.update(effective_source); + if let Some(media) = local_media { + digest.update(b"\0local-background\0"); + digest.update(media.file_name.as_bytes()); + digest.update(b"\0"); + digest.update(media.content_type.as_bytes()); + digest.update(b"\0"); + digest.update(media.bytes.to_le_bytes()); + digest.update(media.sha256.as_bytes()); + } digest.update(b"\0context\0"); digest.update( serde_json::to_vec(compile_context) @@ -346,6 +381,31 @@ fn classify_compiler_failure( }), }; } + if lower.contains("contrast") + && (lower.contains("minimumtextcontrast") || lower.contains("minimumlargetextcontrast")) + { + return ThemeCompileFailure { + code: "theme-compile-accessibility-contrast".to_string(), + message: "主题颜色对比度未达到声明的无障碍标准,请调整文字或按钮颜色后重试".to_string(), + details: json!({ + "stage": "theme-validation", + "adapterId": adapter_id, + "reasonCode": "theme-compile-accessibility-contrast" + }), + }; + } + if lower.contains("presentation-mapping-incomplete") { + return ThemeCompileFailure { + code: "adapter-presentation-mapping-incomplete".to_string(), + message: "Adapter 的主题映射尚不完整,请更新对应解释器后重试".to_string(), + details: json!({ + "stage": "adapter-projector", + "adapterId": adapter_id, + "reasonCode": "adapter-presentation-mapping-incomplete", + "mappingScope": "presentation" + }), + }; + } ThemeCompileFailure { code: "adapter-projector-failed".to_string(), message: "Theme Family 未通过 Adapter 编译,请运行诊断后重试".to_string(), @@ -884,6 +944,43 @@ mod tests { } } + #[test] + fn incomplete_presentation_mapping_returns_a_bounded_adapter_diagnostic() { + let stderr = br#"untrusted prefix /Users/private/theme TOKEN=secret +{"ok":false,"error":"mac-doubao.presentation.parameters.textureIntensity: presentation-mapping-incomplete: does not provide an exact immersive-scene-v1 parameter consumer for textureIntensity"} +"#; + let failure = classify_compiler_failure("mac-doubao", b"", stderr); + assert_eq!(failure.code, "adapter-presentation-mapping-incomplete"); + assert_eq!( + failure.message, + "Adapter 的主题映射尚不完整,请更新对应解释器后重试" + ); + assert_eq!( + failure.details["reasonCode"], + "adapter-presentation-mapping-incomplete" + ); + let public = serde_json::to_string(&failure.details).unwrap(); + assert!(public.len() < 512); + assert!(!public.contains("/Users/private")); + assert!(!public.contains("TOKEN=secret")); + } + + #[test] + fn contrast_failure_is_not_misreported_as_an_adapter_error() { + let stderr = br#"untrusted prefix /Users/private/theme TOKEN=secret +{"ok":false,"error":"theme.sharedCore.tokens.colors.actionForeground/action: contrast 4.09:1 is below declared minimumTextContrast 4.5:1"} +"#; + let failure = classify_compiler_failure("mac-workbuddy", b"", stderr); + assert_eq!(failure.code, "theme-compile-accessibility-contrast"); + assert_eq!( + failure.message, + "主题颜色对比度未达到声明的无障碍标准,请调整文字或按钮颜色后重试" + ); + let public = serde_json::to_string(&failure.details).unwrap(); + assert!(!public.contains("/Users/private")); + assert!(!public.contains("TOKEN=secret")); + } + #[test] fn hardened_runtime_jit_failure_is_not_misreported_as_an_adapter_error() { let stderr = br#" @@ -1071,22 +1168,26 @@ private stack frame /Users/example/theme TOKEN=secret "mac-workbuddy", "fixture", &family, + br#"{\"id\":\"fixture\",\"surfaceOpacity\":0.72}"#, &first_context, &adapter, &compiler, &sdk, Some(®istry), + None, ) .unwrap(); let repeated = compile_cache_key_from( "mac-workbuddy", "fixture", &family, + br#"{\"id\":\"fixture\",\"surfaceOpacity\":0.72}"#, &first_context, &adapter, &compiler, &sdk, Some(®istry), + None, ) .unwrap(); assert_eq!(first, repeated); @@ -1095,11 +1196,13 @@ private stack frame /Users/example/theme TOKEN=secret "mac-workbuddy", "fixture", &family, + br#"{\"id\":\"fixture\",\"surfaceOpacity\":0.72}"#, &json!({"adapters":{"mac-workbuddy":{"probeStatus":"failed"}}}), &adapter, &compiler, &sdk, Some(®istry), + None, ) .unwrap(); assert_ne!(first.fingerprint, context_changed.fingerprint); @@ -1109,14 +1212,31 @@ private stack frame /Users/example/theme TOKEN=secret "mac-workbuddy", "fixture", &family, + br#"{\"id\":\"fixture\",\"surfaceOpacity\":0.72}"#, &first_context, &adapter, &compiler, &sdk, Some(®istry), + None, ) .unwrap(); assert_ne!(first.fingerprint, engine_changed.fingerprint); + + let local_opacity_changed = compile_cache_key_from( + "mac-workbuddy", + "fixture", + &family, + br#"{\"id\":\"fixture\",\"surfaceOpacity\":0.84}"#, + &first_context, + &adapter, + &compiler, + &sdk, + Some(®istry), + None, + ) + .unwrap(); + assert_ne!(first.fingerprint, local_opacity_changed.fingerprint); fs::remove_dir_all(root).unwrap(); } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c3524ea..89083d9 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -334,6 +334,80 @@ async fn delete_local_theme(app: AppHandle, theme_id: String) -> OperationResult ) } +#[tauri::command(rename_all = "camelCase")] +async fn save_theme_surface_opacity(theme_id: String, surface_opacity: f64) -> OperationResult { + tauri::async_runtime::spawn_blocking(move || { + match themes::save_theme_surface_opacity(&theme_id, surface_opacity) { + Ok(saved_opacity) => OperationResult::success( + "theme-local-override-saved", + "主内容背景透明度已保存到本地主题,将在下次应用时生效", + json!({ "themeId": theme_id, "surfaceOpacity": saved_opacity }), + ), + Err(code) => { + OperationResult::failed(code, "无法保存主内容背景透明度;原始主题包未被修改") + } + } + }) + .await + .unwrap_or_else(|_| { + OperationResult::failed( + "theme-local-override-save-failed", + "保存主内容背景透明度时任务异常结束", + ) + }) +} + +#[tauri::command(rename_all = "camelCase")] +async fn save_theme_workbench_draft( + draft: themes::ThemeWorkbenchDraftInput, + media_bytes: Option>, +) -> OperationResult { + let theme_id = draft.base.theme_id.clone(); + tauri::async_runtime::spawn_blocking(move || { + match themes::save_theme_workbench_draft(draft, media_bytes) { + Ok(()) => OperationResult::success( + "theme-workbench-draft-saved", + "主题编辑已安全保存到本地,将在下次应用主题时生效", + json!({ "themeId": theme_id }), + ), + Err(code) => OperationResult::failed( + code, + "无法保存主题编辑;原始主题包和现有本地设置均未被改写", + ), + } + }) + .await + .unwrap_or_else(|_| { + OperationResult::failed( + "theme-workbench-draft-save-failed", + "保存主题编辑时任务异常结束", + ) + }) +} + +#[tauri::command(rename_all = "camelCase")] +async fn reset_theme_workbench_draft(theme_id: String) -> OperationResult { + tauri::async_runtime::spawn_blocking(move || { + match themes::reset_theme_workbench_draft(&theme_id) { + Ok(removed) => OperationResult::success( + "theme-workbench-draft-reset", + "已恢复导入主题的原始设置;本地编辑副本已移除", + json!({ "themeId": theme_id, "removed": removed }), + ), + Err(code) => { + OperationResult::failed(code, "无法恢复原始主题;现有本地编辑副本未被改写") + } + } + }) + .await + .unwrap_or_else(|_| { + OperationResult::failed( + "theme-workbench-draft-reset-failed", + "恢复原始主题时任务异常结束", + ) + }) +} + pub fn run() { tauri::Builder::default() .register_uri_scheme_protocol("cc-theme-preview", |_context, request| { @@ -431,6 +505,9 @@ pub fn run() { check_adapter_updates, download_latest_adapter, delete_local_theme, + save_theme_surface_opacity, + save_theme_workbench_draft, + reset_theme_workbench_draft, set_manager_locale, minimize_to_menu_bar, quit_manager diff --git a/app/src-tauri/src/models.rs b/app/src-tauri/src/models.rs index 9bfd647..70ad3ac 100644 --- a/app/src-tauri/src/models.rs +++ b/app/src-tauri/src/models.rs @@ -104,6 +104,26 @@ pub struct ThemeCompatibility { pub note: String, } +/// A compact, display-only projection of a Theme Family's complete Light/Dark +/// semantic palettes. The editor uses this to render an honest preview while +/// the full palette remains inside the imported Theme Package. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemeAppearancePreview { + pub light: [String; 3], + pub dark: [String; 3], +} + +/// The three semantic stops used for the main-content veil in a presentation +/// profile. This is display metadata only: the authoritative values remain in +/// the imported Theme Package and are still resolved by each Adapter. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemePresentationScrimPreview { + pub light: [String; 3], + pub dark: [String; 3], +} + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "kebab-case")] pub enum CompatibilityState { @@ -121,7 +141,18 @@ pub struct ThemeFamily { pub default_locale: String, pub localizations: std::collections::BTreeMap, pub colors: [String; 3], + pub appearance_variants: Option, + /// The current local value of immersive-scene-v1's main content surface + /// opacity. It is optional because ordinary Theme Families do not expose + /// that presentation control. + pub presentation_surface_opacity: Option, + /// Semantic main-content veil stops for an honest static workbench + /// preview. It is present only for the bounded immersive-scene profile. + pub presentation_scrim: Option, pub preview_url: Option, + /// A Manager-owned editable copy exists locally. The imported Theme + /// Package remains the resettable baseline. + pub has_local_draft: bool, pub installed: bool, pub updated_at: String, pub compatibility: std::collections::BTreeMap, diff --git a/app/src-tauri/src/registry.rs b/app/src-tauri/src/registry.rs index 2bf162a..e544e76 100644 --- a/app/src-tauri/src/registry.rs +++ b/app/src-tauri/src/registry.rs @@ -141,9 +141,23 @@ pub fn adapter_root(definition: &ClientDefinition) -> PathBuf { if std::env::var_os("CC_THEME_ENABLE_LOCAL_ADAPTERS").as_deref() == Some(std::ffi::OsStr::new("1")) { + let bundled = std::env::var_os("CC_THEME_BUNDLED_ADAPTERS_ROOT") + .filter(|p| !p.is_empty()) + .map(PathBuf::from) + .map(|root| root.join(definition.adapter_dirname)); if let Some(active) = crate::adapter_installer::active_adapter_root(definition.id.as_str()) { - return active; + if bundled.as_ref().is_none_or(|bundled| { + crate::adapter_installer::active_adapter_is_not_older_than( + definition.id.as_str(), + bundled, + ) + }) { + return active; + } + } + if let Some(bundled) = bundled.filter(|path| path.is_dir()) { + return bundled; } } if let Some(resources) = @@ -184,6 +198,15 @@ pub fn manager_theme_library_root() -> PathBuf { }) } +/// Local, Manager-owned adjustments live beside the imported Theme Family +/// rather than inside it. This preserves the package's signed/hash-verified +/// source and lets a user remove either the theme or its local data atomically. +pub fn manager_theme_overrides_root() -> PathBuf { + home_dir() + .join("Library/Application Support/CC Theme") + .join("theme-overrides") +} + pub fn manager_adapter_library_root() -> PathBuf { home_dir().join("Library/Application Support/CC Theme/adapters") } diff --git a/app/src-tauri/src/themes.rs b/app/src-tauri/src/themes.rs index 67953d6..ecad61a 100644 --- a/app/src-tauri/src/themes.rs +++ b/app/src-tauri/src/themes.rs @@ -16,7 +16,8 @@ use zip::ZipArchive; use crate::{ capabilities, compiler, models::{ - ClientId, CompatibilityState, LocalizedThemeDisplay, ThemeCompatibility, ThemeFamily, + ClientId, CompatibilityState, LocalizedThemeDisplay, ThemeAppearancePreview, + ThemeCompatibility, ThemeFamily, ThemePresentationScrimPreview, }, registry, }; @@ -28,6 +29,19 @@ const MAX_THEME_DEPTH: usize = 8; const MAX_THEME_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024; const MAX_THEME_PREVIEW_BYTES: u64 = 16 * 1024 * 1024; const COMPILED_THEME_CACHE_FILE: &str = ".cc-theme-compiled.json"; +const LOCAL_THEME_OVERRIDE_KIND: &str = "cc-theme.local-theme-override"; +/// Schema 3 stores a materialized, closed local Theme Family source next to +/// the small edit record. The original imported package remains the +/// immutable baseline; the materialized source is the exact document that is +/// later passed into the compiler. +const LOCAL_THEME_OVERRIDE_SCHEMA_VERSION: u64 = 3; +const LOCAL_THEME_OVERRIDE_LEGACY_SCHEMA_VERSION: u64 = 2; +const LOCAL_THEME_OVERRIDE_FILE: &str = "override.json"; +const LOCAL_THEME_EFFECTIVE_SOURCE_FILE: &str = "local-unified-theme.json"; +const LOCAL_THEME_BACKGROUND_FILE_STEM: &str = "cc-theme-local-background"; +const MAX_LOCAL_THEME_BACKGROUND_BYTES: usize = 16 * 1024 * 1024; +const IMMERSIVE_SCENE_PROFILE_ID: &str = "immersive-scene-v1"; +const IMMERSIVE_SCENE_PROFILE_VERSION: u64 = 1; static SEED_SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone, PartialEq, Eq)] @@ -56,10 +70,125 @@ pub struct ThemeMetadata { pub default_locale: String, pub localizations: BTreeMap, pub colors: [String; 3], + pub appearance_variants: Option, + pub presentation_surface_opacity: Option, + pub presentation_scrim: Option, pub preview_url: Option, pub modified: SystemTime, } +/// A closed, Manager-owned local adjustment. It never changes the imported +/// package bytes and cannot carry CSS, selectors, host paths, or scripts. +/// +/// Version 1 stored only `presentation.surfaceOpacity`; version 2 additionally +/// stores the two semantic text colours and an optional verified media copy. +/// Version 3 additionally binds the edit to its imported baseline and stores a +/// materialized, schema-validated local Theme Family source. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalThemeOverride { + kind: String, + schema_version: u64, + theme_id: String, + presentation: LocalThemeSurfaceOpacityPresentation, + #[serde(default)] + colors: Option, + #[serde(default)] + background: Option, + #[serde(default)] + baseline: Option, + #[serde(default)] + local_source: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalThemeDraftBaseline { + unified_theme_sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalThemeDraftSource { + file_name: String, + bytes: u64, + sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalThemeSurfaceOpacityPresentation { + profile_id: String, + profile_version: u64, + surface_opacity: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalThemeTextColors { + light_text: String, + dark_text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalThemeBackgroundOverride { + file_name: String, + media_type: String, + content_type: String, + bytes: u64, + sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ThemeWorkbenchDraftInput { + kind: String, + revision: u64, + pub base: ThemeWorkbenchDraftBase, + preview_adapter_id: String, + patch: ThemeWorkbenchDraftPatch, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ThemeWorkbenchDraftBase { + pub theme_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemeWorkbenchDraftPatch { + text_colors: LocalThemeTextColors, + surface_opacity: f64, + background_asset: ThemeWorkbenchDraftBackground, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemeWorkbenchDraftBackground { + source: String, + media_type: Option, + file_name: Option, + mime_type: Option, + requires_static_fallback: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct LocalThemeOverrideMedia { + pub file_name: String, + pub content_type: String, + pub bytes: u64, + pub sha256: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +struct LoadedThemeOverride { + record: LocalThemeOverride, + directory: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ThemePackageManifest { @@ -121,17 +250,76 @@ struct UnifiedThemeSource { name: String, version: String, shared_core: SharedCoreSource, + #[serde(default)] + presentation: Option, targets: Vec, #[serde(default)] target_profiles: BTreeMap, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemePresentationSource { + profile_id: String, + profile_version: u64, + strictness: String, + geometry_policy: String, + surfaces: Vec, + parameters: ThemePresentationParameters, + asset_slots: ThemePresentationAssetSlots, + fallback_policy: ThemePresentationFallbackPolicy, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemePresentationParameters { + density: String, + border_treatment: String, + texture_intensity: f64, + surface_opacity: f64, + navigation_treatment: String, + composer_treatment: String, + card_treatment: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ThemePresentationAssetSlots { + #[serde(rename = "scene.backdrop")] + scene_backdrop: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemePresentationFallbackPolicy { + unsupported_surface: String, + reduced_motion: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct SharedCoreSource { tokens: ThemeTokensSource, background: ThemeBackgroundSource, accessibility: ThemeAccessibilitySource, + #[serde(default)] + appearance_variants: Option, +} + +/// A high-fidelity family may define one complete semantic palette for each +/// host appearance. This stays declarative: it deliberately has no CSS, +/// selector, script, geometry or asset-path fields. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemeAppearanceVariantsSource { + light: ThemeAppearanceVariantSource, + dark: ThemeAppearanceVariantSource, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ThemeAppearanceVariantSource { + colors: BTreeMap, } #[derive(Debug, Deserialize)] @@ -243,6 +431,980 @@ pub fn import_theme_package(package: &Path) -> Result { import_theme_package_to(package, ®istry::manager_theme_library_root()) } +/// Backward-compatible entry point for callers that only expose the opacity +/// control. The Workbench itself saves the complete closed draft below. +pub fn save_theme_surface_opacity(theme_id: &str, surface_opacity: f64) -> Result { + save_theme_surface_opacity_to( + ®istry::manager_theme_library_root(), + ®istry::manager_theme_overrides_root(), + theme_id, + surface_opacity, + ) +} + +fn save_theme_surface_opacity_to( + theme_root: &Path, + overrides_root: &Path, + theme_id: &str, + surface_opacity: f64, +) -> Result { + if !valid_theme_id(theme_id) + || !surface_opacity.is_finite() + || !(0.0..=1.0).contains(&surface_opacity) + { + return Err("theme-local-override-invalid".to_string()); + } + let theme_root = trusted_theme_root(theme_root)?; + let family = trusted_theme_child(&theme_root, theme_id)?; + compiler::verify_family_manifest(&family, theme_id) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + let (source, source_bytes) = read_unified_theme_source_with_bytes(&family)?; + let _ = immersive_scene_surface_opacity(&source, theme_id)?; + let existing = load_theme_override_from(overrides_root, theme_id)?; + ensure_local_draft_matches_baseline(existing.as_ref(), &source_bytes)?; + let existing_media = existing + .as_ref() + .and_then( + |loaded| match (&loaded.record.background, &loaded.directory) { + (Some(background), Some(directory)) => { + load_override_media_from(directory, background).ok() + } + _ => None, + }, + ) + .map(|media| fs::read(media.path)) + .transpose() + .map_err(|_| "theme-local-override-background-invalid".to_string())?; + let mut record = existing + .map(|loaded| loaded.record) + .unwrap_or(LocalThemeOverride { + kind: LOCAL_THEME_OVERRIDE_KIND.to_string(), + schema_version: LOCAL_THEME_OVERRIDE_LEGACY_SCHEMA_VERSION, + theme_id: theme_id.to_string(), + presentation: LocalThemeSurfaceOpacityPresentation { + profile_id: IMMERSIVE_SCENE_PROFILE_ID.to_string(), + profile_version: IMMERSIVE_SCENE_PROFILE_VERSION, + surface_opacity, + }, + colors: None, + background: None, + baseline: None, + local_source: None, + }); + // Build from the trusted baseline, then atomically persist the exact local + // source that the compiler will use. Any older record is upgraded on its + // next successful save without changing the imported package. + record.schema_version = LOCAL_THEME_OVERRIDE_LEGACY_SCHEMA_VERSION; + record.baseline = None; + record.local_source = None; + record.presentation.surface_opacity = surface_opacity; + let (record, materialized_source) = + materialize_local_theme_draft(&source, &source_bytes, record)?; + write_theme_override_atomically( + overrides_root, + &record, + existing_media.as_deref(), + &materialized_source, + )?; + Ok(surface_opacity) +} + +/// Saves every editable Workbench value as Manager-owned local data. The +/// original package and its signed/hash-verified manifest remain untouched. +pub fn save_theme_workbench_draft( + draft: ThemeWorkbenchDraftInput, + media_bytes: Option>, +) -> Result<(), String> { + save_theme_workbench_draft_to( + ®istry::manager_theme_library_root(), + ®istry::manager_theme_overrides_root(), + draft, + media_bytes, + ) +} + +/// Removes only the Manager-owned editable copy. The verified imported Theme +/// Package, its assets, and every Adapter runtime state are left untouched. +/// The next compile therefore returns to the original package baseline. +pub fn reset_theme_workbench_draft(theme_id: &str) -> Result { + reset_theme_workbench_draft_from(®istry::manager_theme_overrides_root(), theme_id) +} + +fn reset_theme_workbench_draft_from(overrides_root: &Path, theme_id: &str) -> Result { + if !valid_theme_id(theme_id) { + return Err("theme-workbench-draft-invalid".to_string()); + } + let metadata = match fs::symlink_metadata(overrides_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(_) => return Err("theme-local-override-storage-invalid".to_string()), + }; + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + return Err("theme-local-override-storage-invalid".to_string()); + } + let root = overrides_root + .canonicalize() + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + let draft = root.join(theme_id); + let legacy = root.join(format!("{theme_id}.json")); + let mut removed = false; + + if draft.symlink_metadata().is_ok() { + let trusted = trusted_theme_child(&root, theme_id) + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + let tombstone = root.join(format!( + ".{}-reset-{}-{}", + theme_id, + std::process::id(), + SEED_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::rename(&trusted, &tombstone) + .map_err(|_| "theme-local-override-commit-failed".to_string())?; + if fs::remove_dir_all(&tombstone).is_err() { + let _ = fs::rename(&tombstone, &trusted); + return Err("theme-local-override-cleanup-failed".to_string()); + } + removed = true; + } + if legacy.symlink_metadata().is_ok() { + let legacy_metadata = fs::symlink_metadata(&legacy) + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + if !legacy_metadata.file_type().is_file() || legacy_metadata.file_type().is_symlink() { + return Err("theme-local-override-storage-invalid".to_string()); + } + fs::remove_file(legacy).map_err(|_| "theme-local-override-cleanup-failed".to_string())?; + removed = true; + } + Ok(removed) +} + +fn save_theme_workbench_draft_to( + theme_root: &Path, + overrides_root: &Path, + draft: ThemeWorkbenchDraftInput, + media_bytes: Option>, +) -> Result<(), String> { + if draft.kind != "cc-theme.theme-workbench-draft" + || draft.revision != 1 + || !valid_theme_id(&draft.base.theme_id) + || !matches!( + draft.preview_adapter_id.as_str(), + "mac-codex" | "mac-workbuddy" + ) + { + return Err("theme-workbench-draft-invalid".to_string()); + } + let theme_root = trusted_theme_root(theme_root)?; + let family = trusted_theme_child(&theme_root, &draft.base.theme_id)?; + compiler::verify_family_manifest(&family, &draft.base.theme_id) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + let (source, source_bytes) = read_unified_theme_source_with_bytes(&family)?; + let existing = load_theme_override_from(overrides_root, &draft.base.theme_id)?; + ensure_local_draft_matches_baseline(existing.as_ref(), &source_bytes)?; + let existing_media = existing + .as_ref() + .and_then( + |loaded| match (&loaded.record.background, &loaded.directory) { + (Some(background), Some(directory)) => { + load_override_media_from(directory, background).ok() + } + _ => None, + }, + ) + .map(|media| fs::read(media.path)) + .transpose() + .map_err(|_| "theme-local-override-background-invalid".to_string())?; + let (record, preserve_existing_media) = workbench_override_record( + &source, + &draft, + existing.as_ref().map(|loaded| &loaded.record), + media_bytes.as_deref(), + )?; + let bytes = if preserve_existing_media { + existing_media.as_deref() + } else { + media_bytes.as_deref() + }; + let (record, materialized_source) = + materialize_local_theme_draft(&source, &source_bytes, record)?; + write_theme_override_atomically(overrides_root, &record, bytes, &materialized_source) +} + +fn workbench_override_record( + source: &Value, + draft: &ThemeWorkbenchDraftInput, + existing: Option<&LocalThemeOverride>, + media_bytes: Option<&[u8]>, +) -> Result<(LocalThemeOverride, bool), String> { + let theme_id = &draft.base.theme_id; + let _ = immersive_scene_surface_opacity(source, theme_id) + .map_err(|_| "theme-local-override-presentation-unsupported".to_string())?; + let colors = &draft.patch.text_colors; + if !valid_hex_color(&colors.light_text) || !valid_hex_color(&colors.dark_text) { + return Err("theme-workbench-text-colour-invalid".to_string()); + } + let source_model: UnifiedThemeSource = serde_json::from_value(source.clone()) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + if source_model.shared_core.appearance_variants.is_none() + && colors.light_text != colors.dark_text + { + return Err("theme-workbench-text-colour-appearance-unsupported".to_string()); + } + if !draft.patch.surface_opacity.is_finite() + || !(0.0..=1.0).contains(&draft.patch.surface_opacity) + { + return Err("theme-local-override-invalid".to_string()); + } + let (background, preserve_existing_media) = workbench_background_override( + &source_model, + &draft.patch.background_asset, + existing.and_then(|record| record.background.clone()), + media_bytes, + )?; + let record = LocalThemeOverride { + kind: LOCAL_THEME_OVERRIDE_KIND.to_string(), + schema_version: LOCAL_THEME_OVERRIDE_LEGACY_SCHEMA_VERSION, + theme_id: theme_id.to_string(), + presentation: LocalThemeSurfaceOpacityPresentation { + profile_id: IMMERSIVE_SCENE_PROFILE_ID.to_string(), + profile_version: IMMERSIVE_SCENE_PROFILE_VERSION, + surface_opacity: draft.patch.surface_opacity, + }, + colors: Some(LocalThemeTextColors { + light_text: colors.light_text.to_uppercase(), + dark_text: colors.dark_text.to_uppercase(), + }), + background, + baseline: None, + local_source: None, + }; + validate_local_theme_override(&record, theme_id)?; + Ok((record, preserve_existing_media)) +} + +fn ensure_local_draft_matches_baseline( + existing: Option<&LoadedThemeOverride>, + baseline_bytes: &[u8], +) -> Result<(), String> { + let Some(existing) = existing else { + return Ok(()); + }; + if existing.record.schema_version != LOCAL_THEME_OVERRIDE_SCHEMA_VERSION { + return Ok(()); + } + let baseline = existing + .record + .baseline + .as_ref() + .ok_or_else(|| "theme-local-override-invalid".to_string())?; + if format!("{:x}", Sha256::digest(baseline_bytes)) != baseline.unified_theme_sha256 { + return Err("theme-local-override-baseline-changed".to_string()); + } + Ok(()) +} + +fn workbench_background_override( + source: &UnifiedThemeSource, + request: &ThemeWorkbenchDraftBackground, + existing: Option, + media_bytes: Option<&[u8]>, +) -> Result<(Option, bool), String> { + match request.source.as_str() { + "inherit" => { + if request.media_type.is_some() + || request.file_name.is_some() + || request.mime_type.is_some() + || request.requires_static_fallback.is_some() + || media_bytes.is_some() + { + return Err("theme-workbench-background-invalid".to_string()); + } + Ok((existing, true)) + } + "theme" => { + if request.media_type.is_some() + || request.file_name.is_some() + || request.mime_type.is_some() + || request.requires_static_fallback.is_some() + || media_bytes.is_some() + { + return Err("theme-workbench-background-invalid".to_string()); + } + Ok((None, false)) + } + "uploaded" => { + let media_type = request + .media_type + .as_deref() + .filter(|value| matches!(*value, "image" | "video")) + .ok_or_else(|| "theme-workbench-background-invalid".to_string())?; + let content_type = request + .mime_type + .as_deref() + .filter(|value| { + matches!( + *value, + "image/png" | "image/jpeg" | "image/webp" | "video/mp4" + ) + }) + .ok_or_else(|| "theme-workbench-background-invalid".to_string())?; + let _requested_file_name = request + .file_name + .as_deref() + .filter(|value| { + !value.is_empty() && value.len() <= 160 && !value.contains(['/', '\\', '\0']) + }) + .ok_or_else(|| "theme-workbench-background-invalid".to_string())?; + if (media_type == "image") != content_type.starts_with("image/") + || (media_type == "video") != (content_type == "video/mp4") + || (media_type == "video" && request.requires_static_fallback != Some(true)) + || !matches!( + source.shared_core.background, + ThemeBackgroundSource::Media { .. } + ) + { + return Err("theme-workbench-background-unsupported".to_string()); + } + let bytes = + media_bytes.ok_or_else(|| "theme-workbench-background-missing".to_string())?; + if bytes.is_empty() || bytes.len() > MAX_LOCAL_THEME_BACKGROUND_BYTES { + return Err("theme-workbench-background-size-invalid".to_string()); + } + if !asset_magic_matches(&bytes[..bytes.len().min(12)], content_type) { + return Err("theme-workbench-background-magic-invalid".to_string()); + } + let extension = local_background_extension(content_type) + .ok_or_else(|| "theme-workbench-background-invalid".to_string())?; + Ok(( + Some(LocalThemeBackgroundOverride { + file_name: format!("{LOCAL_THEME_BACKGROUND_FILE_STEM}.{extension}"), + media_type: media_type.to_string(), + content_type: content_type.to_string(), + bytes: bytes.len() as u64, + sha256: format!("{:x}", Sha256::digest(bytes)), + }), + false, + )) + } + _ => Err("theme-workbench-background-invalid".to_string()), + } +} + +fn local_background_extension(content_type: &str) -> Option<&'static str> { + match content_type { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/webp" => Some("webp"), + "video/mp4" => Some("mp4"), + _ => None, + } +} + +fn valid_hex_color(value: &str) -> bool { + value.len() == 7 + && value.starts_with('#') + && value.as_bytes()[1..] + .iter() + .all(|byte| byte.is_ascii_hexdigit()) +} + +/// Converts a closed edit record into a self-contained local Theme Family +/// source. This is intentionally derived from the verified imported source +/// instead of accepting arbitrary JSON from the UI: the UI can only alter the +/// bounded draft fields above, while the compiler later consumes the exact +/// persisted document. +fn materialize_local_theme_draft( + baseline_source: &Value, + baseline_bytes: &[u8], + mut record: LocalThemeOverride, +) -> Result<(LocalThemeOverride, Vec), String> { + record.schema_version = LOCAL_THEME_OVERRIDE_LEGACY_SCHEMA_VERSION; + record.baseline = None; + record.local_source = None; + + let mut effective_source = baseline_source.clone(); + apply_local_theme_override(&mut effective_source, &record, &record.theme_id)?; + let effective_bytes = serde_json::to_vec(&effective_source) + .map_err(|_| "theme-local-override-encode-failed".to_string())?; + if effective_bytes.len() > 2 * 1024 * 1024 { + return Err("theme-local-override-source-invalid".to_string()); + } + + record.schema_version = LOCAL_THEME_OVERRIDE_SCHEMA_VERSION; + record.baseline = Some(LocalThemeDraftBaseline { + unified_theme_sha256: format!("{:x}", Sha256::digest(baseline_bytes)), + }); + record.local_source = Some(LocalThemeDraftSource { + file_name: LOCAL_THEME_EFFECTIVE_SOURCE_FILE.to_string(), + bytes: effective_bytes.len() as u64, + sha256: format!("{:x}", Sha256::digest(&effective_bytes)), + }); + validate_local_theme_override(&record, &record.theme_id)?; + Ok((record, effective_bytes)) +} + +fn write_theme_override_atomically( + overrides_root: &Path, + record: &LocalThemeOverride, + media_bytes: Option<&[u8]>, + materialized_source: &[u8], +) -> Result<(), String> { + validate_local_theme_override(record, &record.theme_id)?; + let local_source = record + .local_source + .as_ref() + .ok_or_else(|| "theme-local-override-source-invalid".to_string())?; + if materialized_source.len() as u64 != local_source.bytes + || format!("{:x}", Sha256::digest(materialized_source)) != local_source.sha256 + { + return Err("theme-local-override-source-invalid".to_string()); + } + let expects_media = record.background.is_some(); + if expects_media != media_bytes.is_some() { + return Err("theme-workbench-background-missing".to_string()); + } + prepare_saved_root(overrides_root) + .map_err(|_| "theme-local-override-storage-unavailable".to_string())?; + let root = trusted_theme_root(overrides_root)?; + let destination = root.join(&record.theme_id); + if destination.symlink_metadata().is_ok() { + let _ = trusted_theme_child(&root, &record.theme_id) + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + } + let container = root.join(format!( + ".{}-{}-{}", + record.theme_id, + std::process::id(), + SEED_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let stage = container.join(&record.theme_id); + let result = (|| { + fs::create_dir(&container).map_err(|_| "theme-local-override-write-failed".to_string())?; + set_directory_permissions(&container)?; + fs::create_dir(&stage).map_err(|_| "theme-local-override-write-failed".to_string())?; + set_directory_permissions(&stage)?; + let record_path = stage.join(LOCAL_THEME_OVERRIDE_FILE); + let record_bytes = serde_json::to_vec(record) + .map_err(|_| "theme-local-override-encode-failed".to_string())?; + write_private_file(&record_path, &record_bytes)?; + write_private_file(&stage.join(&local_source.file_name), materialized_source)?; + if let (Some(background), Some(bytes)) = (&record.background, media_bytes) { + let path = stage.join(&background.file_name); + write_private_file(&path, bytes)?; + } + let previous = container.join("previous"); + let had_previous = destination.symlink_metadata().is_ok(); + if had_previous { + fs::rename(&destination, &previous) + .map_err(|_| "theme-local-override-commit-failed".to_string())?; + } + if fs::rename(&stage, &destination).is_err() { + if had_previous { + let _ = fs::rename(&previous, &destination); + } + return Err("theme-local-override-commit-failed".to_string()); + } + if had_previous { + fs::remove_dir_all(&previous) + .map_err(|_| "theme-local-override-cleanup-failed".to_string())?; + } + let legacy = root.join(format!("{}.json", record.theme_id)); + if legacy.symlink_metadata().is_ok() { + let metadata = fs::symlink_metadata(&legacy) + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err("theme-local-override-storage-invalid".to_string()); + } + fs::remove_file(legacy) + .map_err(|_| "theme-local-override-cleanup-failed".to_string())?; + } + fs::remove_dir(&container) + .map_err(|_| "theme-local-override-cleanup-failed".to_string())?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&container); + } + result +} + +fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|_| "theme-local-override-write-failed".to_string())?; + set_file_permissions(path)?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| "theme-local-override-write-failed".to_string()) +} + +/// Resolves the verified source plus closed Manager-owned local adjustments +/// for compiler input. The imported package stays read-only and hash-verified. +pub(crate) fn effective_theme_source( + family: &Path, + theme_id: &str, +) -> Result<(Value, Vec), String> { + effective_theme_source_from(family, ®istry::manager_theme_overrides_root(), theme_id) +} + +fn effective_theme_source_from( + family: &Path, + overrides_root: &Path, + theme_id: &str, +) -> Result<(Value, Vec), String> { + let (mut source, source_bytes) = read_unified_theme_source_with_bytes(family)?; + if let Some(loaded) = load_theme_override_from(overrides_root, theme_id)? { + if loaded.record.schema_version == LOCAL_THEME_OVERRIDE_SCHEMA_VERSION { + let baseline = loaded + .record + .baseline + .as_ref() + .ok_or_else(|| "theme-local-override-invalid".to_string())?; + if format!("{:x}", Sha256::digest(&source_bytes)) != baseline.unified_theme_sha256 { + return Err("theme-local-override-baseline-changed".to_string()); + } + let local_source = loaded + .record + .local_source + .as_ref() + .ok_or_else(|| "theme-local-override-invalid".to_string())?; + let directory = loaded + .directory + .as_ref() + .ok_or_else(|| "theme-local-override-storage-invalid".to_string())?; + let (materialized, materialized_bytes) = + read_materialized_local_theme_source(directory, local_source, theme_id)?; + return Ok((materialized, materialized_bytes)); + } + apply_local_theme_override(&mut source, &loaded.record, theme_id)?; + } + let bytes = serde_json::to_vec(&source) + .map_err(|_| "theme-local-override-encode-failed".to_string())?; + Ok((source, bytes)) +} + +fn apply_local_theme_override( + source: &mut Value, + record: &LocalThemeOverride, + theme_id: &str, +) -> Result<(), String> { + validate_local_theme_override(record, theme_id)?; + let _ = immersive_scene_surface_opacity(source, theme_id)?; + source + .pointer_mut("/presentation/parameters") + .and_then(Value::as_object_mut) + .ok_or_else(|| "theme-local-override-source-invalid".to_string())? + .insert( + "surfaceOpacity".to_string(), + Value::from(record.presentation.surface_opacity), + ); + if let Some(colors) = &record.colors { + let tokens = source + .pointer_mut("/sharedCore/tokens/colors") + .and_then(Value::as_object_mut) + .ok_or_else(|| "theme-local-override-source-invalid".to_string())?; + tokens.insert("text".to_string(), Value::String(colors.dark_text.clone())); + if let Some(variants) = source + .pointer_mut("/sharedCore/appearanceVariants") + .and_then(Value::as_object_mut) + { + for (appearance, value) in [("light", &colors.light_text), ("dark", &colors.dark_text)] + { + variants + .get_mut(appearance) + .and_then(|variant| variant.get_mut("colors")) + .and_then(Value::as_object_mut) + .ok_or_else(|| "theme-local-override-source-invalid".to_string())? + .insert("text".to_string(), Value::String(value.clone())); + } + } else if colors.light_text != colors.dark_text { + return Err("theme-workbench-text-colour-appearance-unsupported".to_string()); + } + } + if let Some(background) = &record.background { + let source_background = source + .pointer_mut("/sharedCore/background") + .and_then(Value::as_object_mut) + .ok_or_else(|| "theme-local-override-source-invalid".to_string())?; + if source_background.get("mode").and_then(Value::as_str) != Some("media") { + return Err("theme-workbench-background-unsupported".to_string()); + } + match background.media_type.as_str() { + "image" => { + source_background.insert( + "image".to_string(), + Value::String(background.file_name.clone()), + ); + source + .pointer_mut("/presentation/assetSlots") + .and_then(Value::as_object_mut) + .ok_or_else(|| "theme-local-override-source-invalid".to_string())? + .insert( + "scene.backdrop".to_string(), + Value::String(background.file_name.clone()), + ); + } + "video" => { + source_background.insert( + "video".to_string(), + Value::String(background.file_name.clone()), + ); + } + _ => return Err("theme-local-override-invalid".to_string()), + } + } + validate_effective_local_source(source) +} + +fn validate_effective_local_source(source: &Value) -> Result<(), String> { + let parsed: UnifiedThemeSource = serde_json::from_value(source.clone()) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + validate_theme_tokens(&parsed.shared_core.tokens) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + validate_theme_accessibility(&parsed.shared_core.accessibility) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + if let Some(variants) = &parsed.shared_core.appearance_variants { + validate_theme_appearance_variants(variants) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + } + validate_theme_background(&parsed.shared_core.background) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + if let Some(presentation) = &parsed.presentation { + validate_theme_presentation(presentation, &parsed.shared_core.background) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + } + Ok(()) +} + +fn read_unified_theme_source_with_bytes(family: &Path) -> Result<(Value, Vec), String> { + let source = family.join("unified-theme.json"); + let metadata = fs::symlink_metadata(&source) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.len() < 2 + || metadata.len() > 2 * 1024 * 1024 + { + return Err("theme-local-override-source-invalid".to_string()); + } + let bytes = fs::read(source).map_err(|_| "theme-local-override-source-invalid".to_string())?; + let value = serde_json::from_slice(&bytes) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + Ok((value, bytes)) +} + +fn read_materialized_local_theme_source( + directory: &Path, + source: &LocalThemeDraftSource, + theme_id: &str, +) -> Result<(Value, Vec), String> { + if source.file_name != LOCAL_THEME_EFFECTIVE_SOURCE_FILE + || source.bytes < 2 + || source.bytes > 2 * 1024 * 1024 + || !valid_sha256(&source.sha256) + { + return Err("theme-local-override-invalid".to_string()); + } + let candidate = directory.join(&source.file_name); + let metadata = fs::symlink_metadata(&candidate) + .map_err(|_| "theme-local-override-source-missing".to_string())?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.len() != source.bytes + { + return Err("theme-local-override-source-invalid".to_string()); + } + let canonical = candidate + .canonicalize() + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + if canonical.parent() != Some(directory) { + return Err("theme-local-override-source-invalid".to_string()); + } + let bytes = + fs::read(canonical).map_err(|_| "theme-local-override-source-invalid".to_string())?; + if format!("{:x}", Sha256::digest(&bytes)) != source.sha256 { + return Err("theme-local-override-source-invalid".to_string()); + } + let value: Value = serde_json::from_slice(&bytes) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + if value.get("id").and_then(Value::as_str) != Some(theme_id) { + return Err("theme-local-override-source-invalid".to_string()); + } + validate_effective_local_source(&value)?; + Ok((value, bytes)) +} + +fn trusted_theme_root(root: &Path) -> Result { + let metadata = fs::symlink_metadata(root) + .map_err(|_| "theme-local-override-storage-unavailable".to_string())?; + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + return Err("theme-local-override-storage-invalid".to_string()); + } + root.canonicalize() + .map_err(|_| "theme-local-override-storage-invalid".to_string()) +} + +fn trusted_theme_child(root: &Path, theme_id: &str) -> Result { + let family = root.join(theme_id); + let metadata = fs::symlink_metadata(&family) + .map_err(|_| "theme-local-override-source-missing".to_string())?; + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + return Err("theme-local-override-source-invalid".to_string()); + } + let family = family + .canonicalize() + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + if family.parent() != Some(root) { + return Err("theme-local-override-source-invalid".to_string()); + } + Ok(family) +} + +fn load_theme_override_from( + overrides_root: &Path, + theme_id: &str, +) -> Result, String> { + if !valid_theme_id(theme_id) { + return Err("theme-local-override-invalid".to_string()); + } + let root_metadata = match fs::symlink_metadata(overrides_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("theme-local-override-storage-invalid".to_string()), + }; + if !root_metadata.file_type().is_dir() || root_metadata.file_type().is_symlink() { + return Err("theme-local-override-storage-invalid".to_string()); + } + let root = overrides_root + .canonicalize() + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + let directory = root.join(theme_id); + if directory.symlink_metadata().is_ok() { + let metadata = fs::symlink_metadata(&directory) + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + return Err("theme-local-override-storage-invalid".to_string()); + } + let directory = directory + .canonicalize() + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + if directory.parent() != Some(root.as_path()) { + return Err("theme-local-override-storage-invalid".to_string()); + } + let record = read_local_theme_override_file( + &directory.join(LOCAL_THEME_OVERRIDE_FILE), + &directory, + theme_id, + )?; + validate_local_theme_override(&record, theme_id)?; + if let Some(local_source) = &record.local_source { + let _ = read_materialized_local_theme_source(&directory, local_source, theme_id)?; + } + if let Some(background) = &record.background { + let _ = load_override_media_from(&directory, background)?; + } + return Ok(Some(LoadedThemeOverride { + record, + directory: Some(directory), + })); + } + + // Legacy schema v1 lived directly below the overrides root. It stays + // readable so existing opacity preferences survive this local upgrade. + let legacy = root.join(format!("{theme_id}.json")); + if legacy.symlink_metadata().is_ok() { + let record = read_local_theme_override_file(&legacy, &root, theme_id)?; + validate_local_theme_override(&record, theme_id)?; + return Ok(Some(LoadedThemeOverride { + record, + directory: None, + })); + } + Ok(None) +} + +fn read_local_theme_override_file( + file: &Path, + expected_parent: &Path, + theme_id: &str, +) -> Result { + let metadata = fs::symlink_metadata(file) + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.len() > 16 * 1024 + { + return Err("theme-local-override-storage-invalid".to_string()); + } + let canonical = file + .canonicalize() + .map_err(|_| "theme-local-override-storage-invalid".to_string())?; + if canonical.parent() != Some(expected_parent) { + return Err("theme-local-override-storage-invalid".to_string()); + } + let record: LocalThemeOverride = serde_json::from_slice( + &fs::read(canonical).map_err(|_| "theme-local-override-storage-invalid".to_string())?, + ) + .map_err(|_| "theme-local-override-invalid".to_string())?; + if record.theme_id != theme_id { + return Err("theme-local-override-invalid".to_string()); + } + Ok(record) +} + +fn validate_local_theme_override( + record: &LocalThemeOverride, + theme_id: &str, +) -> Result<(), String> { + if record.kind != LOCAL_THEME_OVERRIDE_KIND + || !matches!( + record.schema_version, + 1 | LOCAL_THEME_OVERRIDE_LEGACY_SCHEMA_VERSION | LOCAL_THEME_OVERRIDE_SCHEMA_VERSION + ) + || record.theme_id != theme_id + || record.presentation.profile_id != IMMERSIVE_SCENE_PROFILE_ID + || record.presentation.profile_version != IMMERSIVE_SCENE_PROFILE_VERSION + || !record.presentation.surface_opacity.is_finite() + || !(0.0..=1.0).contains(&record.presentation.surface_opacity) + || (record.schema_version == 1 + && (record.colors.is_some() + || record.background.is_some() + || record.baseline.is_some() + || record.local_source.is_some())) + { + return Err("theme-local-override-invalid".to_string()); + } + match record.schema_version { + LOCAL_THEME_OVERRIDE_SCHEMA_VERSION => { + let baseline = record + .baseline + .as_ref() + .ok_or_else(|| "theme-local-override-invalid".to_string())?; + let local_source = record + .local_source + .as_ref() + .ok_or_else(|| "theme-local-override-invalid".to_string())?; + if !valid_sha256(&baseline.unified_theme_sha256) + || local_source.file_name != LOCAL_THEME_EFFECTIVE_SOURCE_FILE + || local_source.bytes < 2 + || local_source.bytes > 2 * 1024 * 1024 + || !valid_sha256(&local_source.sha256) + { + return Err("theme-local-override-invalid".to_string()); + } + } + _ if record.baseline.is_some() || record.local_source.is_some() => { + return Err("theme-local-override-invalid".to_string()); + } + _ => {} + } + if let Some(colors) = &record.colors { + if !valid_hex_color(&colors.light_text) || !valid_hex_color(&colors.dark_text) { + return Err("theme-local-override-invalid".to_string()); + } + } + if let Some(background) = &record.background { + if !safe_asset_basename(&background.file_name) + || background.file_name + != format!( + "{LOCAL_THEME_BACKGROUND_FILE_STEM}.{}", + local_background_extension(&background.content_type).unwrap_or_default() + ) + || !matches!(background.media_type.as_str(), "image" | "video") + || (background.media_type == "image") != background.content_type.starts_with("image/") + || (background.media_type == "video") != (background.content_type == "video/mp4") + || background.bytes == 0 + || background.bytes > MAX_LOCAL_THEME_BACKGROUND_BYTES as u64 + || !valid_sha256(&background.sha256) + { + return Err("theme-local-override-invalid".to_string()); + } + } + Ok(()) +} + +fn load_override_media_from( + directory: &Path, + background: &LocalThemeBackgroundOverride, +) -> Result { + let candidate = directory.join(&background.file_name); + let metadata = fs::symlink_metadata(&candidate) + .map_err(|_| "theme-local-override-background-missing".to_string())?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.len() != background.bytes + || metadata.len() > MAX_LOCAL_THEME_BACKGROUND_BYTES as u64 + { + return Err("theme-local-override-background-invalid".to_string()); + } + let canonical = candidate + .canonicalize() + .map_err(|_| "theme-local-override-background-invalid".to_string())?; + if canonical.parent() != Some(directory) { + return Err("theme-local-override-background-invalid".to_string()); + } + let bytes = + fs::read(&canonical).map_err(|_| "theme-local-override-background-invalid".to_string())?; + if !asset_magic_matches(&bytes[..bytes.len().min(12)], &background.content_type) + || format!("{:x}", Sha256::digest(&bytes)) != background.sha256 + { + return Err("theme-local-override-background-invalid".to_string()); + } + Ok(LocalThemeOverrideMedia { + file_name: background.file_name.clone(), + content_type: background.content_type.clone(), + bytes: background.bytes, + sha256: background.sha256.clone(), + path: canonical, + }) +} + +pub(crate) fn local_theme_override_media( + theme_id: &str, +) -> Result, String> { + let Some(loaded) = + load_theme_override_from(®istry::manager_theme_overrides_root(), theme_id)? + else { + return Ok(None); + }; + match (&loaded.record.background, loaded.directory) { + (Some(background), Some(directory)) => { + load_override_media_from(&directory, background).map(Some) + } + (Some(_), None) => Err("theme-local-override-background-invalid".to_string()), + (None, _) => Ok(None), + } +} + +pub(crate) fn stage_effective_theme_assets( + package_assets: &Path, + destination: &Path, + local_media: Option<&LocalThemeOverrideMedia>, +) -> Result<(), String> { + fs::create_dir(destination).map_err(|_| "theme-local-override-stage-failed".to_string())?; + set_directory_permissions(destination)?; + let mut limits = CopyLimits::default(); + copy_theme_tree(package_assets, destination, 0, &mut limits)?; + if let Some(media) = local_media { + let destination_file = destination.join(&media.file_name); + if destination_file.symlink_metadata().is_ok() { + return Err("theme-local-override-background-conflict".to_string()); + } + let bytes = fs::read(&media.path) + .map_err(|_| "theme-local-override-background-invalid".to_string())?; + if bytes.len() as u64 != media.bytes + || format!("{:x}", Sha256::digest(&bytes)) != media.sha256 + || !asset_magic_matches(&bytes[..bytes.len().min(12)], &media.content_type) + { + return Err("theme-local-override-background-invalid".to_string()); + } + write_private_file(&destination_file, &bytes)?; + } + Ok(()) +} + pub fn delete_theme_family(theme_id: &str) -> Result { if !valid_theme_id(theme_id) { return Err("theme-delete-id-invalid".to_string()); @@ -258,6 +1420,7 @@ pub fn delete_theme_family(theme_id: &str) -> Result { let codex_root = home.join("Library/Application Support/CCTheme"); let workbuddy_root = home.join("Library/Application Support/mac-workbuddy"); let residual_files = vec![ + registry::manager_theme_overrides_root().join(format!("{theme_id}.json")), codex_root .join("style-overrides") .join(format!("{theme_id}.json")), @@ -268,7 +1431,7 @@ pub fn delete_theme_family(theme_id: &str) -> Result { .join("transactions") .join(format!("{theme_id}.base.json")), ]; - let residual_directories = Vec::new(); + let residual_directories = vec![registry::manager_theme_overrides_root().join(theme_id)]; let transaction_locks = vec![ codex_root .join("style-overrides") @@ -528,11 +1691,23 @@ fn validate_package_source_and_media( family: &Path, manifest: &ThemePackageManifest, ) -> Result<(), String> { - let source: UnifiedThemeSource = serde_json::from_slice( - &fs::read(family.join("unified-theme.json")) - .map_err(|_| "theme-package-source-unreadable".to_string())?, - ) - .map_err(|_| "theme-package-source-invalid".to_string())?; + let source_bytes = fs::read(family.join("unified-theme.json")) + .map_err(|_| "theme-package-source-unreadable".to_string())?; + let source_value: Value = serde_json::from_slice(&source_bytes) + .map_err(|_| "theme-package-source-invalid".to_string())?; + if let Some(presentation) = source_value.get("presentation") { + serde_json::from_value::(presentation.clone()) + .map_err(|_| "theme-package-presentation-invalid".to_string())?; + } + if let Some(appearance_variants) = source_value + .get("sharedCore") + .and_then(|shared_core| shared_core.get("appearanceVariants")) + { + serde_json::from_value::(appearance_variants.clone()) + .map_err(|_| "theme-package-appearance-variants-invalid".to_string())?; + } + let source: UnifiedThemeSource = serde_json::from_value(source_value) + .map_err(|_| "theme-package-source-invalid".to_string())?; let required_assets = validate_unified_theme_source(&source, manifest)?; for asset in &manifest.assets { let mut file = fs::File::open(family.join(&asset.path)) @@ -584,7 +1759,67 @@ fn validate_unified_theme_source<'a>( validate_theme_tokens(&source.shared_core.tokens)?; validate_theme_accessibility(&source.shared_core.accessibility)?; - validate_theme_background(&source.shared_core.background) + if let Some(variants) = &source.shared_core.appearance_variants { + validate_theme_appearance_variants(variants)?; + } + let assets = validate_theme_background(&source.shared_core.background)?; + if let Some(presentation) = &source.presentation { + validate_theme_presentation(presentation, &source.shared_core.background)?; + } + Ok(assets) +} + +fn validate_theme_presentation( + value: &ThemePresentationSource, + background: &ThemeBackgroundSource, +) -> Result<(), String> { + const SURFACES: [&str; 7] = [ + "shell", + "navigation", + "home", + "conversation", + "composer", + "cards", + "overlays", + ]; + let parameters = &value.parameters; + let declared_surfaces = value + .surfaces + .iter() + .map(String::as_str) + .collect::>(); + let expected_surfaces = SURFACES.into_iter().collect::>(); + let background_image = match background { + ThemeBackgroundSource::Media { image, .. } + | ThemeBackgroundSource::Ripple { image, .. } + | ThemeBackgroundSource::Directional { image, .. } => image, + }; + if value.profile_id != "immersive-scene-v1" + || value.profile_version != 1 + || value.strictness != "exact-required" + || value.geometry_policy != "scene-bounded" + || value.surfaces.len() != SURFACES.len() + || declared_surfaces != expected_surfaces + || parameters.density != "comfortable" + || parameters.border_treatment != "etched" + || !parameters.texture_intensity.is_finite() + || !(0.0..=1.0).contains(¶meters.texture_intensity) + || !parameters.surface_opacity.is_finite() + || !(0.0..=1.0).contains(¶meters.surface_opacity) + || parameters.navigation_treatment != "framed" + || parameters.composer_treatment != "anchored" + || parameters.card_treatment != "elevated" + || !valid_local_asset( + &value.asset_slots.scene_backdrop, + &["png", "jpg", "jpeg", "webp"], + ) + || value.asset_slots.scene_backdrop != *background_image + || value.fallback_policy.unsupported_surface != "block" + || value.fallback_policy.reduced_motion != "static" + { + return Err("theme-package-presentation-invalid".to_string()); + } + Ok(()) } fn validate_theme_tokens(tokens: &ThemeTokensSource) -> Result<(), String> { @@ -667,7 +1902,57 @@ fn validate_theme_tokens(tokens: &ThemeTokensSource) -> Result<(), String> { || !optional_position(&appearance.background_position) || !optional_position(&appearance.home_hero_position) { - return Err("theme-package-source-contract-invalid".to_string()); + return Err("theme-package-source-contract-invalid".to_string()); + } + } + Ok(()) +} + +fn validate_theme_appearance_variants( + variants: &ThemeAppearanceVariantsSource, +) -> Result<(), String> { + const COMPLETE_COLORS: [&str; 30] = [ + "surfaceBase", + "surfaceRaised", + "surfaceElevated", + "surfaceCode", + "text", + "textStrong", + "textMuted", + "placeholder", + "borderSubtle", + "borderDefault", + "borderStrong", + "action", + "actionHover", + "actionPressed", + "actionForeground", + "hoverSurface", + "pressedSurface", + "selectedSurface", + "selectedHoverSurface", + "focusRing", + "link", + "danger", + "success", + "warning", + "sidebarSurface", + "headerSurface", + "mainScrimStart", + "mainScrimMid", + "mainScrimEnd", + "composerSurface", + ]; + for colors in [&variants.light.colors, &variants.dark.colors] { + if colors.len() != COMPLETE_COLORS.len() + || COMPLETE_COLORS + .iter() + .any(|name| !colors.contains_key(*name)) + || colors.iter().any(|(name, value)| { + !COMPLETE_COLORS.contains(&name.as_str()) || !valid_color(value) + }) + { + return Err("theme-package-appearance-variants-invalid".to_string()); } } Ok(()) @@ -903,6 +2188,9 @@ fn read_theme_metadata_file(file: &Path, expected_id: &str) -> Option( } pub fn load_theme_preview(theme_id: &str) -> Option<(String, Vec)> { + if let Ok(Some(media)) = local_theme_override_media(theme_id) { + if media.content_type.starts_with("image/") { + let bytes = fs::read(media.path).ok()?; + return Some((media.content_type, bytes)); + } + } load_theme_preview_from(®istry::manager_theme_library_root(), theme_id) } @@ -1830,6 +3130,14 @@ fn set_file_permissions(_path: &Path) -> Result<(), String> { Ok(()) } +fn theme_color_triplet(get: impl Fn(&[&str]) -> Option) -> [String; 3] { + [ + get(&["background", "surfaceBase"]).unwrap_or_else(|| "#10151F".to_string()), + get(&["accent", "action"]).unwrap_or_else(|| "#6699FF".to_string()), + get(&["text"]).unwrap_or_else(|| "#E9EEF7".to_string()), + ] +} + fn theme_colors(value: &Value) -> [String; 3] { let colors = value.get("colors").or_else(|| { value @@ -1837,19 +3145,86 @@ fn theme_colors(value: &Value) -> [String; 3] { .and_then(|core| core.get("tokens")) .and_then(|tokens| tokens.get("colors")) }); - let get = |keys: &[&str]| { + theme_color_triplet(|keys| { keys.iter().find_map(|key| { colors .and_then(|colors| colors.get(*key)) .and_then(Value::as_str) .map(str::to_owned) }) + }) +} + +fn theme_appearance_preview(value: &Value) -> Option { + let variants = value + .get("sharedCore") + .and_then(|core| core.get("appearanceVariants"))?; + let variants: ThemeAppearanceVariantsSource = serde_json::from_value(variants.clone()).ok()?; + let preview = |colors: &BTreeMap| { + theme_color_triplet(|keys| keys.iter().find_map(|key| colors.get(*key).cloned())) }; - [ - get(&["background", "surfaceBase"]).unwrap_or_else(|| "#10151F".to_string()), - get(&["accent", "action"]).unwrap_or_else(|| "#6699FF".to_string()), - get(&["text"]).unwrap_or_else(|| "#E9EEF7".to_string()), - ] + Some(ThemeAppearancePreview { + light: preview(&variants.light.colors), + dark: preview(&variants.dark.colors), + }) +} + +/// Returns the one presentation value the Manager is allowed to override +/// locally. Keeping this tied to the closed immersive-scene profile prevents a +/// generic editor control from becoming a way to inject arbitrary target data. +fn immersive_scene_surface_opacity(value: &Value, expected_id: &str) -> Result { + let source: UnifiedThemeSource = serde_json::from_value(value.clone()) + .map_err(|_| "theme-local-override-source-invalid".to_string())?; + if source.id != expected_id { + return Err("theme-local-override-source-invalid".to_string()); + } + let presentation = source + .presentation + .as_ref() + .ok_or_else(|| "theme-local-override-presentation-unsupported".to_string())?; + validate_theme_presentation(presentation, &source.shared_core.background) + .map_err(|_| "theme-local-override-presentation-unsupported".to_string())?; + Ok(presentation.parameters.surface_opacity) +} + +fn theme_presentation_surface_opacity(value: &Value) -> Option { + let id = value.get("id")?.as_str()?; + immersive_scene_surface_opacity(value, id).ok() +} + +fn scrim_triplet(colors: &serde_json::Map) -> Option<[String; 3]> { + Some([ + colors.get("mainScrimStart")?.as_str()?.to_string(), + colors.get("mainScrimMid")?.as_str()?.to_string(), + colors.get("mainScrimEnd")?.as_str()?.to_string(), + ]) +} + +/// Keep the preview's veil compositing tied to the same three Shared Core +/// semantics that an immersive Adapter receives. This intentionally does not +/// expose any host selector, geometry, or runtime stylesheet to the Manager. +fn theme_presentation_scrim(value: &Value) -> Option { + theme_presentation_surface_opacity(value)?; + let base = value + .get("sharedCore")? + .get("tokens")? + .get("colors")? + .as_object()?; + let fallback = scrim_triplet(base)?; + let variant = |appearance: &str| { + value + .get("sharedCore") + .and_then(|core| core.get("appearanceVariants")) + .and_then(|variants| variants.get(appearance)) + .and_then(|variant| variant.get("colors")) + .and_then(Value::as_object) + .and_then(scrim_triplet) + .unwrap_or_else(|| fallback.clone()) + }; + Some(ThemePresentationScrimPreview { + light: variant("light"), + dark: variant("dark"), + }) } pub fn list_theme_families() -> Vec { @@ -1920,6 +3295,36 @@ fn list_theme_families_from_roots<'a>( }) .collect(); let updated_at: DateTime = builder.metadata.modified.into(); + let local_override = load_theme_override_from( + ®istry::manager_theme_overrides_root(), + &builder.metadata.id, + ) + .ok() + .flatten(); + let mut colors = builder.metadata.colors; + let mut appearance_variants = builder.metadata.appearance_variants; + let mut presentation_surface_opacity = builder.metadata.presentation_surface_opacity; + let mut preview_url = builder.metadata.preview_url; + let has_local_draft = local_override.is_some(); + if let Some(local_override) = local_override { + presentation_surface_opacity = + Some(local_override.record.presentation.surface_opacity); + if let Some(local_colors) = local_override.record.colors { + colors[2] = local_colors.dark_text.clone(); + if let Some(variants) = appearance_variants.as_mut() { + variants.light[2] = local_colors.light_text; + variants.dark[2] = local_colors.dark_text; + } + } + if let Some(background) = local_override.record.background { + if background.content_type.starts_with("image/") { + if let Some(url) = preview_url.as_mut() { + url.push_str("&localOverride="); + url.push_str(&background.sha256); + } + } + } + } ThemeFamily { id: builder.metadata.id, name: builder.metadata.name, @@ -1927,8 +3332,12 @@ fn list_theme_families_from_roots<'a>( description: builder.metadata.description, default_locale: builder.metadata.default_locale, localizations: builder.metadata.localizations, - colors: builder.metadata.colors, - preview_url: builder.metadata.preview_url, + colors, + appearance_variants, + presentation_surface_opacity, + presentation_scrim: builder.metadata.presentation_scrim, + preview_url, + has_local_draft, installed: builder.installed || !builder.clients.is_empty(), updated_at: updated_at.to_rfc3339(), compatibility, @@ -2149,6 +3558,73 @@ mod tests { manifest_entry.1 = serde_json::to_vec(&manifest).unwrap(); } + fn immersive_scene_presentation(image: &str) -> Value { + serde_json::json!({ + "profileId": "immersive-scene-v1", + "profileVersion": 1, + "strictness": "exact-required", + "geometryPolicy": "scene-bounded", + "surfaces": ["shell", "navigation", "home", "conversation", "composer", "cards", "overlays"], + "parameters": { + "density": "comfortable", + "borderTreatment": "etched", + "textureIntensity": 0.36, + "surfaceOpacity": 0.72, + "navigationTreatment": "framed", + "composerTreatment": "anchored", + "cardTreatment": "elevated" + }, + "assetSlots": { "scene.backdrop": image }, + "fallbackPolicy": { "unsupportedSurface": "block", "reducedMotion": "static" } + }) + } + + fn complete_appearance_variants() -> Value { + const COLOR_KEYS: [&str; 30] = [ + "surfaceBase", + "surfaceRaised", + "surfaceElevated", + "surfaceCode", + "text", + "textStrong", + "textMuted", + "placeholder", + "borderSubtle", + "borderDefault", + "borderStrong", + "action", + "actionHover", + "actionPressed", + "actionForeground", + "hoverSurface", + "pressedSurface", + "selectedSurface", + "selectedHoverSurface", + "focusRing", + "link", + "danger", + "success", + "warning", + "sidebarSurface", + "headerSurface", + "mainScrimStart", + "mainScrimMid", + "mainScrimEnd", + "composerSurface", + ]; + let palette = |value: &str| { + let colors = COLOR_KEYS + .iter() + .map(|key| ((*key).to_string(), Value::String(value.to_string()))) + .collect::>(); + serde_json::json!({ "colors": colors }) + }; + serde_json::json!({ + "light": palette("#241C15"), + "dark": palette("#F3EAD7") + }) + } + #[test] fn theme_ids_reject_traversal_and_shell_metacharacters() { assert!(valid_theme_id("fixture-v2_1")); @@ -2224,6 +3700,475 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn cctheme_import_accepts_a_closed_immersive_scene_presentation() { + let root = scratch("gothic-void-crusade-presentation"); + fs::create_dir_all(&root).unwrap(); + let package = root.join("gothic-void-crusade.cctheme"); + let library = root.join("library"); + let mut entries = package_entries("gothic-void-crusade"); + mutate_package_source(&mut entries, |source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + source["sharedCore"]["tokens"]["colors"]["mainScrimStart"] = + Value::String("#10151F".into()); + source["sharedCore"]["tokens"]["colors"]["mainScrimMid"] = + Value::String("#10151F".into()); + source["sharedCore"]["tokens"]["colors"]["mainScrimEnd"] = + Value::String("#10151F".into()); + source["sharedCore"]["appearanceVariants"] = complete_appearance_variants(); + }); + write_package(&package, entries); + + assert_eq!( + import_theme_package_to(&package, &library).unwrap(), + "gothic-void-crusade" + ); + assert!(library + .join("gothic-void-crusade/unified-theme.json") + .is_file()); + let family = list_theme_families_from(Some(&library)) + .into_iter() + .find(|theme| theme.id == "gothic-void-crusade") + .expect("the imported family is listed"); + assert_eq!( + family + .presentation_scrim + .expect("scene presentation exposes its semantic scrim"), + ThemePresentationScrimPreview { + light: ["#241C15".into(), "#241C15".into(), "#241C15".into()], + dark: ["#F3EAD7".into(), "#F3EAD7".into(), "#F3EAD7".into()], + } + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn local_surface_opacity_override_preserves_the_package_and_changes_compiler_input() { + let root = scratch("local-surface-opacity"); + fs::create_dir_all(&root).unwrap(); + let package = root.join("gothic-void-crusade.cctheme"); + let library = root.join("library"); + let overrides = root.join("overrides"); + let mut entries = package_entries("gothic-void-crusade"); + mutate_package_source(&mut entries, |source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + }); + write_package(&package, entries); + import_theme_package_to(&package, &library).unwrap(); + + let family = library.join("gothic-void-crusade"); + let original = fs::read(family.join("unified-theme.json")).unwrap(); + assert_eq!( + save_theme_surface_opacity_to(&library, &overrides, "gothic-void-crusade", 1.0) + .unwrap(), + 1.0 + ); + assert_eq!( + fs::read(family.join("unified-theme.json")).unwrap(), + original + ); + + let (effective, _) = + effective_theme_source_from(&family, &overrides, "gothic-void-crusade").unwrap(); + assert_eq!( + effective.pointer("/presentation/parameters/surfaceOpacity"), + Some(&Value::from(1.0)) + ); + + assert_eq!( + save_theme_surface_opacity_to(&library, &overrides, "gothic-void-crusade", 0.0) + .unwrap(), + 0.0 + ); + let (effective, _) = + effective_theme_source_from(&family, &overrides, "gothic-void-crusade").unwrap(); + assert_eq!( + effective.pointer("/presentation/parameters/surfaceOpacity"), + Some(&Value::from(0.0)) + ); + + let override_file = overrides + .join("gothic-void-crusade") + .join(LOCAL_THEME_OVERRIDE_FILE); + let mut invalid: Value = + serde_json::from_slice(&fs::read(&override_file).unwrap()).unwrap(); + invalid["presentation"]["selector"] = Value::String(".host-ui".into()); + fs::write(&override_file, serde_json::to_vec(&invalid).unwrap()).unwrap(); + assert_eq!( + effective_theme_source_from(&family, &overrides, "gothic-void-crusade").unwrap_err(), + "theme-local-override-invalid" + ); + + let legacy_package = root.join("legacy.cctheme"); + write_package(&legacy_package, package_entries("legacy-theme")); + import_theme_package_to(&legacy_package, &library).unwrap(); + assert_eq!( + save_theme_surface_opacity_to(&library, &overrides, "legacy-theme", 0.72).unwrap_err(), + "theme-local-override-presentation-unsupported" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn local_workbench_draft_persists_closed_colours_opacity_and_verified_media() { + let root = scratch("local-workbench-draft"); + fs::create_dir_all(&root).unwrap(); + let package = root.join("gothic-void-crusade.cctheme"); + let library = root.join("library"); + let overrides = root.join("overrides"); + let mut entries = package_entries("gothic-void-crusade"); + mutate_package_source(&mut entries, |source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + source["sharedCore"]["appearanceVariants"] = complete_appearance_variants(); + }); + write_package(&package, entries); + import_theme_package_to(&package, &library).unwrap(); + let family = library.join("gothic-void-crusade"); + let original = fs::read(family.join("unified-theme.json")).unwrap(); + let media = fs::read(family.join("assets/background.webp")).unwrap(); + let draft = ThemeWorkbenchDraftInput { + kind: "cc-theme.theme-workbench-draft".to_string(), + revision: 1, + base: ThemeWorkbenchDraftBase { + theme_id: "gothic-void-crusade".to_string(), + }, + preview_adapter_id: "mac-codex".to_string(), + patch: ThemeWorkbenchDraftPatch { + text_colors: LocalThemeTextColors { + light_text: "#203041".to_string(), + dark_text: "#F3EAD7".to_string(), + }, + surface_opacity: 0.31, + background_asset: ThemeWorkbenchDraftBackground { + source: "uploaded".to_string(), + media_type: Some("image".to_string()), + file_name: Some("chosen-background.webp".to_string()), + mime_type: Some("image/webp".to_string()), + requires_static_fallback: None, + }, + }, + }; + save_theme_workbench_draft_to(&library, &overrides, draft, Some(media.clone())).unwrap(); + + assert_eq!( + fs::read(family.join("unified-theme.json")).unwrap(), + original + ); + let override_directory = overrides.join("gothic-void-crusade"); + let local_source = override_directory.join(LOCAL_THEME_EFFECTIVE_SOURCE_FILE); + assert!(local_source.is_file()); + let saved_record: LocalThemeOverride = serde_json::from_slice( + &fs::read(override_directory.join(LOCAL_THEME_OVERRIDE_FILE)).unwrap(), + ) + .unwrap(); + assert_eq!( + saved_record.schema_version, + LOCAL_THEME_OVERRIDE_SCHEMA_VERSION + ); + assert!(saved_record.baseline.is_some()); + assert_eq!( + saved_record + .local_source + .as_ref() + .map(|source| source.file_name.as_str()), + Some(LOCAL_THEME_EFFECTIVE_SOURCE_FILE) + ); + assert_eq!( + fs::read(override_directory.join("cc-theme-local-background.webp")).unwrap(), + media + ); + let (effective, _) = + effective_theme_source_from(&family, &overrides, "gothic-void-crusade").unwrap(); + assert_eq!( + effective.pointer("/presentation/parameters/surfaceOpacity"), + Some(&Value::from(0.31)) + ); + assert_eq!( + effective.pointer("/sharedCore/appearanceVariants/light/colors/text"), + Some(&Value::String("#203041".to_string())) + ); + assert_eq!( + effective.pointer("/sharedCore/appearanceVariants/dark/colors/text"), + Some(&Value::String("#F3EAD7".to_string())) + ); + assert_eq!( + effective.pointer("/sharedCore/background/image"), + Some(&Value::String("cc-theme-local-background.webp".to_string())) + ); + + let preserve_existing_background = ThemeWorkbenchDraftInput { + kind: "cc-theme.theme-workbench-draft".to_string(), + revision: 1, + base: ThemeWorkbenchDraftBase { + theme_id: "gothic-void-crusade".to_string(), + }, + preview_adapter_id: "mac-workbuddy".to_string(), + patch: ThemeWorkbenchDraftPatch { + text_colors: LocalThemeTextColors { + light_text: "#203041".to_string(), + dark_text: "#F3EAD7".to_string(), + }, + surface_opacity: 0.42, + background_asset: ThemeWorkbenchDraftBackground { + source: "inherit".to_string(), + media_type: None, + file_name: None, + mime_type: None, + requires_static_fallback: None, + }, + }, + }; + save_theme_workbench_draft_to(&library, &overrides, preserve_existing_background, None) + .unwrap(); + assert_eq!( + fs::read(override_directory.join("cc-theme-local-background.webp")).unwrap(), + media + ); + + let malformed: Result = + serde_json::from_value(serde_json::json!({ + "kind": "cc-theme.theme-workbench-draft", + "revision": 1, + "base": { "themeId": "gothic-void-crusade" }, + "previewAdapterId": "mac-codex", + "patch": { + "textColors": { "lightText": "#203041", "darkText": "#F3EAD7" }, + "surfaceOpacity": 0.31, + "backgroundAsset": { "source": "inherit", "mediaType": null }, + "selector": ".host-ui" + } + })); + assert!(malformed.is_err(), "unknown draft fields must stay closed"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn local_workbench_draft_without_appearance_variants_uses_one_shared_text_token() { + let root = scratch("local-workbench-single-palette"); + fs::create_dir_all(&root).unwrap(); + let package = root.join("gothic-void-crusade.cctheme"); + let library = root.join("library"); + let overrides = root.join("overrides"); + let mut entries = package_entries("gothic-void-crusade"); + mutate_package_source(&mut entries, |source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + source["sharedCore"] + .as_object_mut() + .unwrap() + .remove("appearanceVariants"); + }); + write_package(&package, entries); + import_theme_package_to(&package, &library).unwrap(); + let draft = ThemeWorkbenchDraftInput { + kind: "cc-theme.theme-workbench-draft".to_string(), + revision: 1, + base: ThemeWorkbenchDraftBase { + theme_id: "gothic-void-crusade".to_string(), + }, + preview_adapter_id: "mac-codex".to_string(), + patch: ThemeWorkbenchDraftPatch { + text_colors: LocalThemeTextColors { + light_text: "#E9EEF7".to_string(), + dark_text: "#E9EEF7".to_string(), + }, + surface_opacity: 0.0, + background_asset: ThemeWorkbenchDraftBackground { + source: "inherit".to_string(), + media_type: None, + file_name: None, + mime_type: None, + requires_static_fallback: None, + }, + }, + }; + save_theme_workbench_draft_to(&library, &overrides, draft, None).unwrap(); + let family = library.join("gothic-void-crusade"); + let (effective, _) = + effective_theme_source_from(&family, &overrides, "gothic-void-crusade").unwrap(); + assert_eq!( + effective.pointer("/sharedCore/tokens/colors/text"), + Some(&Value::String("#E9EEF7".to_string())) + ); + assert_eq!( + effective.pointer("/presentation/parameters/surfaceOpacity"), + Some(&Value::from(0.0)) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn resetting_a_local_workbench_draft_restores_the_immutable_imported_baseline() { + let root = scratch("reset-local-workbench-draft"); + fs::create_dir_all(&root).unwrap(); + let package = root.join("gothic-void-crusade.cctheme"); + let library = root.join("library"); + let overrides = root.join("overrides"); + let mut entries = package_entries("gothic-void-crusade"); + mutate_package_source(&mut entries, |source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + }); + write_package(&package, entries); + import_theme_package_to(&package, &library).unwrap(); + let family = library.join("gothic-void-crusade"); + let imported = fs::read(family.join("unified-theme.json")).unwrap(); + save_theme_surface_opacity_to(&library, &overrides, "gothic-void-crusade", 0.0).unwrap(); + assert!(overrides + .join("gothic-void-crusade") + .join(LOCAL_THEME_EFFECTIVE_SOURCE_FILE) + .is_file()); + assert!(reset_theme_workbench_draft_from(&overrides, "gothic-void-crusade").unwrap()); + assert!(!overrides.join("gothic-void-crusade").exists()); + assert_eq!( + fs::read(family.join("unified-theme.json")).unwrap(), + imported + ); + let (effective, _) = + effective_theme_source_from(&family, &overrides, "gothic-void-crusade").unwrap(); + assert_eq!( + effective.pointer("/presentation/parameters/surfaceOpacity"), + Some(&Value::from(0.72)) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn cctheme_import_accepts_closed_light_and_dark_appearance_variants() { + let root = scratch("appearance-variants"); + fs::create_dir_all(&root).unwrap(); + let theme_id = "appearance-variants-fixture"; + let package = root.join(format!("{theme_id}.cctheme")); + let library = root.join("library"); + let mut entries = package_entries(theme_id); + mutate_package_source(&mut entries, |source| { + source["sharedCore"]["appearanceVariants"] = complete_appearance_variants(); + }); + write_package(&package, entries); + + assert_eq!( + import_theme_package_to(&package, &library).unwrap(), + theme_id + ); + let listed = list_theme_families_from(Some(&library)); + let preview = listed + .iter() + .find(|theme| theme.id == theme_id) + .and_then(|theme| theme.appearance_variants.as_ref()) + .expect("the imported family exposes its display-only appearance summary"); + assert_eq!(preview.light, ["#241C15", "#241C15", "#241C15"]); + assert_eq!(preview.dark, ["#F3EAD7", "#F3EAD7", "#F3EAD7"]); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn cctheme_import_rejects_unknown_or_partial_appearance_variants() { + let root = scratch("appearance-variants-invalid"); + fs::create_dir_all(&root).unwrap(); + let mutations: Vec<(&str, Box)> = vec![ + ( + "unknown-variant-field", + Box::new(|source| { + source["sharedCore"]["appearanceVariants"] = complete_appearance_variants(); + source["sharedCore"]["appearanceVariants"]["light"]["css"] = + Value::String("body { display: none; }".into()); + }), + ), + ( + "partial-palette", + Box::new(|source| { + source["sharedCore"]["appearanceVariants"] = complete_appearance_variants(); + source["sharedCore"]["appearanceVariants"]["dark"]["colors"] + .as_object_mut() + .unwrap() + .remove("link"); + }), + ), + ]; + for (index, (name, mutate)) in mutations.into_iter().enumerate() { + let package = root.join(format!("{index}-{name}.cctheme")); + let library = root.join(format!("library-{index}")); + let mut entries = package_entries("gothic-void-crusade"); + mutate_package_source(&mut entries, mutate); + write_package(&package, entries); + + assert_eq!( + import_theme_package_to(&package, &library).unwrap_err(), + "theme-package-appearance-variants-invalid", + "{name} must fail closed" + ); + assert!(!library.join("gothic-void-crusade").exists()); + } + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn cctheme_import_rejects_unsafe_or_invalid_immersive_scene_presentation() { + let root = scratch("immersive-scene-presentation-invalid"); + fs::create_dir_all(&root).unwrap(); + let mutations: Vec<(&str, Box)> = vec![ + ( + "unknown-field", + Box::new(|source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + source["presentation"]["selector"] = Value::String(".host-ui".into()); + }), + ), + ( + "invalid-policy", + Box::new(|source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + source["presentation"]["geometryPolicy"] = + Value::String("replace-layout".into()); + }), + ), + ( + "invalid-asset-slot", + Box::new(|source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + source["presentation"]["assetSlots"]["scene.backdrop"] = + Value::String("../outside.png".into()); + }), + ), + ( + "unknown-asset-slot", + Box::new(|source| { + source["presentation"] = immersive_scene_presentation("background.webp"); + source["presentation"]["assetSlots"]["scene.overlay"] = + Value::String("background.webp".into()); + }), + ), + ]; + for (index, (name, mutate)) in mutations.into_iter().enumerate() { + let package = root.join(format!("{index}-{name}.cctheme")); + let library = root.join(format!("library-{index}")); + let mut entries = package_entries("gothic-void-crusade"); + mutate_package_source(&mut entries, mutate); + write_package(&package, entries); + + assert_eq!( + import_theme_package_to(&package, &library).unwrap_err(), + "theme-package-presentation-invalid", + "{name} must fail closed" + ); + assert!(!library.join("gothic-void-crusade").exists()); + } + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn cctheme_import_keeps_legacy_themes_without_presentation_compatible() { + let root = scratch("legacy-no-presentation"); + fs::create_dir_all(&root).unwrap(); + let package = root.join("legacy.cctheme"); + let library = root.join("library"); + write_package(&package, package_entries("legacy-theme")); + + assert_eq!( + import_theme_package_to(&package, &library).unwrap(), + "legacy-theme" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn compiled_adapter_snapshots_never_replace_central_package_display_metadata() { let root = scratch("central-display-authority"); diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 89bc5d5..28b7a1f 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CC Theme", - "version": "0.2.1", + "version": "0.2.2", "identifier": "com.quanzhankeji.cc-theme", "build": { "beforeDevCommand": "npm run prepare:resources && npm run dev", diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 832f59d..dae5cdd 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -63,6 +63,9 @@ function makeApi(overrides: Partial = {}): DesktopApi { }, })), deleteLocalTheme: vi.fn(() => Promise.resolve({ status: "success" as const, code: "theme-deleted", message: "本地主题及其关联数据已删除", details: { removedEntries: 3 } })), + saveThemeSurfaceOpacity: vi.fn((themeId, surfaceOpacity) => Promise.resolve({ status: "success" as const, code: "theme-local-override-saved", message: "主内容背景透明度已保存到本地主题", details: { themeId, surfaceOpacity } })), + saveThemeWorkbenchDraft: vi.fn((draft) => Promise.resolve({ status: "success" as const, code: "theme-workbench-draft-saved", message: "主题编辑已安全保存到本地", details: { themeId: draft.base.themeId } })), + resetThemeWorkbenchDraft: vi.fn((themeId) => Promise.resolve({ status: "success" as const, code: "theme-workbench-draft-reset", message: "已恢复导入主题的原始设置", details: { themeId, removed: true } })), runDiagnostics: vi.fn(() => Promise.resolve({ status: "success" as const, code: "ok", message: "诊断完成", details: structuredClone(DEMO_DIAGNOSTICS) })), ...overrides, }; @@ -277,6 +280,27 @@ describe("CC Theme desktop dashboard", () => { expect(screen.queryByText("English theme description")).not.toBeInTheDocument(); }); + it("为 presentation 合同失败显示精确的主题导入诊断", async () => { + const api = makeApi({ + importThemePackage: vi.fn(() => Promise.resolve({ + status: "failed" as const, + code: "theme-package-presentation-invalid", + message: "主题 presentation 合同无效", + })), + }); + const packagePicker: ThemePackagePicker = { + chooseThemePackage: vi.fn(() => Promise.resolve("/Users/example/GothicVoidCrusade.cctheme")), + }; + const user = userEvent.setup(); + render(); + + await screen.findByRole("heading", { name: "主题与应用" }); + await user.click(screen.getByRole("button", { name: "导入本地主题" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("主题的沉浸式展示档案不符合支持的安全规范"); + expect(screen.queryByText("主题包未通过安全校验,格式不正确,或同名主题已经安装")).not.toBeInTheDocument(); + }); + it("主题封面发生一次瞬时加载错误时重试,连续失败后才回退占位图", async () => { const dashboard = cloneDemoDashboard(); dashboard.themes = [{ @@ -700,6 +724,34 @@ describe("CC Theme desktop dashboard", () => { expect(alert).not.toHaveTextContent("detectedClientBuild"); }); + it("Adapter 场景映射不完整时显示可行动的说明和稳定诊断码", async () => { + const dashboard = cloneDemoDashboard(); + dashboard.clients.find((client) => client.id === "mac-workbuddy")!.runState = "stopped"; + const api = makeApi({ + getDashboardState: vi.fn(() => Promise.resolve(dashboard)), + applyTheme: vi.fn(() => Promise.resolve({ + status: "failed" as const, + code: "adapter-presentation-mapping-incomplete", + message: "Adapter 的主题映射尚不完整,请更新对应解释器后重试", + details: { + stage: "adapter-projector", + adapterId: "mac-workbuddy", + mappingScope: "presentation", + }, + })), + }); + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("button", { name: /纸月 Paper Moon/ })); + await user.click(within(screen.getByTestId("client-mac-workbuddy")).getByRole("button", { name: "注入主题并启动 WorkBuddy" })); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Adapter 的主题映射尚不完整,请更新对应解释器后重试"); + expect(alert).toHaveTextContent("adapter-presentation-mapping-incomplete"); + expect(alert).not.toHaveTextContent("mappingScope"); + }); + it("动态渲染后端新增的第五个 unavailable capability,并以能力声明阻断操作", async () => { const dashboard = cloneDemoDashboard(); dashboard.capabilities.push({ @@ -986,4 +1038,25 @@ describe("CC Theme desktop dashboard", () => { expect(apply).toHaveTextContent("当前版本尚未验证"); expect(api.applyTheme).not.toHaveBeenCalled(); }); + + it("主题卡片的眼睛入口直接打开工作台,不改变当前选择或运行时主题", async () => { + const dashboard = cloneDemoDashboard(); + const api = makeApi({ getDashboardState: vi.fn(() => Promise.resolve(dashboard)) }); + const user = userEvent.setup(); + render(); + + const card = await screen.findByTestId("theme-paper-moon"); + const selection = card.querySelector(".theme-card__select"); + expect(selection).toHaveAttribute("aria-pressed", "false"); + const preview = within(card).getByRole("button", { name: "预览和编辑主题" }); + expect(preview).toHaveAttribute("title", "预览和编辑主题"); + + await user.click(preview); + + expect(await screen.findByRole("region", { name: "主题工作台" })).toHaveTextContent("纸月 Paper Moon"); + expect(selection).toHaveAttribute("aria-pressed", "false"); + expect(api.applyTheme).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "返回" })); + expect(screen.queryByRole("region", { name: "主题工作台" })).not.toBeInTheDocument(); + }); }); diff --git a/app/src/App.tsx b/app/src/App.tsx index dbc0ed9..d038029 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -17,6 +17,7 @@ import { import { managerWindowApi, type ManagerWindowApi } from "./window-api"; import { themePackagePicker, type ThemePackagePicker } from "./theme-package-picker"; import { themePackageDropTarget, type ThemePackageDropTarget } from "./theme-package-drop"; +import { ThemeWorkbench, type ThemeWorkbenchDraft, type ThemeWorkbenchMedia } from "./theme-workbench"; import type { AdapterCapability, AdapterCatalogStatus, @@ -111,6 +112,13 @@ function statusText(client: ClientState, t: Translate): string { return t("status.unknown"); } +function themeImportFailureMessage(code: string, t: Translate): string { + if (code === "theme-package-presentation-invalid") { + return t("themes.importPresentationInvalid"); + } + return t("themes.importFailed"); +} + function shortMark(name: string): string { const words = name.match(/[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)|[\p{L}\p{N}]+/gu) ?? []; if (words.length > 1) return words.slice(0, 2).map((word) => word[0]).join(""); @@ -371,7 +379,7 @@ function ClientCard({ ); } -function ThemeCard({ theme, selected, locale, disabled, t, onSelect, onDelete }: { theme: ThemeFamily; selected: boolean; locale: ManagerLocale; disabled: boolean; t: Translate; onSelect: () => void; onDelete: () => void }) { +function ThemeCard({ theme, selected, locale, disabled, t, onSelect, onPreview, onDelete }: { theme: ThemeFamily; selected: boolean; locale: ManagerLocale; disabled: boolean; t: Translate; onSelect: () => void; onPreview: () => void; onDelete: () => void }) { const display = themeDisplay(theme, locale); const [previewAttempt, setPreviewAttempt] = useState(0); const [previewFailed, setPreviewFailed] = useState(false); @@ -412,6 +420,9 @@ function ThemeCard({ theme, selected, locale, disabled, t, onSelect, onDelete }: + @@ -436,6 +447,7 @@ export default function App({ api = desktopApi, windowApi = managerWindowApi, pa const [checkingAdapterId, setCheckingAdapterId] = useState(null); const [dragActive, setDragActive] = useState(false); const [themeToDelete, setThemeToDelete] = useState(null); + const [workbenchTheme, setWorkbenchTheme] = useState(null); const [deletingThemeId, setDeletingThemeId] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -451,6 +463,21 @@ export default function App({ api = desktopApi, windowApi = managerWindowApi, pa const deleteDialog = useRef(null); const closeChoiceBusy = useRef(false); const t = useMemo(() => createTranslator(locale), [locale]); + + const saveWorkbenchDraft = useCallback(async (draft: ThemeWorkbenchDraft, media: ThemeWorkbenchMedia | null) => { + const result = await api.saveThemeWorkbenchDraft(draft, media?.bytes ?? null); + if (result.status === "failed") throw new Error(`${result.code}: ${result.message}`); + const fresh = await api.getDashboardState(); + setDashboard((current) => ({ ...fresh, activities: current?.activities ?? fresh.activities })); + setWorkbenchTheme(fresh.themes.find((theme) => theme.id === draft.base.themeId) ?? null); + }, [api]); + const restoreWorkbenchDraft = useCallback(async (themeId: string) => { + const result = await api.resetThemeWorkbenchDraft(themeId); + if (result.status === "failed") throw new Error(`${result.code}: ${result.message}`); + const fresh = await api.getDashboardState(); + setDashboard((current) => ({ ...fresh, activities: current?.activities ?? fresh.activities })); + setWorkbenchTheme(fresh.themes.find((theme) => theme.id === themeId) ?? null); + }, [api]); droppedPathsHandler.current = (paths) => { void importDroppedTheme(paths); }; const refreshRuntimeStates = useCallback(async (mayCommit: () => boolean = () => true) => { @@ -748,7 +775,9 @@ export default function App({ api = desktopApi, windowApi = managerWindowApi, pa else result = await api.refreshClient(client.id); if (result.status === "failed") { const message = localizeRuntimeText(result.message || result.code, locale); - const showDiagnosticCode = result.code.startsWith("adapter-compile-") || result.code.startsWith("theme-compile-"); + const showDiagnosticCode = result.code.startsWith("adapter-compile-") + || result.code.startsWith("adapter-presentation-") + || result.code.startsWith("theme-compile-"); throw new Error(showDiagnosticCode ? `${message}(${result.code})` : message); } @@ -831,7 +860,7 @@ export default function App({ api = desktopApi, windowApi = managerWindowApi, pa setError(null); try { const result = await api.importThemePackage(packagePath); - if (result.status === "failed") throw new Error(t("themes.importFailed")); + if (result.status === "failed") throw new Error(themeImportFailureMessage(result.code, t)); const fresh = await api.getDashboardState(); setDashboard((current) => ({ ...fresh, activities: current?.activities ?? fresh.activities })); const importedThemeId = result.details?.themeId; @@ -1004,6 +1033,8 @@ export default function App({ api = desktopApi, windowApi = managerWindowApi, pa )} + {workbenchTheme && setWorkbenchTheme(null)} onSaveDraft={saveWorkbenchDraft} onRestoreOriginal={restoreWorkbenchDraft} />} +
@@ -1025,7 +1056,7 @@ export default function App({ api = desktopApi, windowApi = managerWindowApi, pa
{t("themes.eyebrow")}

{t("themes.title")}

{dashboard.themes.length === 0 &&

{t("themes.empty")}

} - {dashboard.themes.map((theme) => chooseTheme(theme.id)} onDelete={() => setThemeToDelete(theme)} />)} + {dashboard.themes.map((theme) => chooseTheme(theme.id)} onPreview={() => setWorkbenchTheme(theme)} onDelete={() => setThemeToDelete(theme)} />)}
diff --git a/app/src/api.ts b/app/src/api.ts index ce62818..397a2b6 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -10,6 +10,7 @@ import type { DiagnosticReport, OperationResult, } from "./types"; +import type { ThemeWorkbenchDraft } from "./theme-workbench"; const DEMO_LATENCY_MS = 260; @@ -127,6 +128,33 @@ export const desktopApi = { })); }, + saveThemeSurfaceOpacity(themeId: string, surfaceOpacity: number): Promise> { + return call("save_theme_surface_opacity", { themeId, surfaceOpacity }, () => ({ + status: "success", + code: "theme-local-override-saved", + message: "主内容背景透明度已保存到本地主题", + details: { themeId, surfaceOpacity }, + })); + }, + + saveThemeWorkbenchDraft(draft: ThemeWorkbenchDraft, mediaBytes: Uint8Array | null): Promise> { + return call("save_theme_workbench_draft", { draft, mediaBytes }, () => ({ + status: "success", + code: "theme-workbench-draft-saved", + message: "主题编辑已安全保存到本地,将在下次应用主题时生效", + details: { themeId: draft.base.themeId }, + })); + }, + + resetThemeWorkbenchDraft(themeId: string): Promise> { + return call("reset_theme_workbench_draft", { themeId }, () => ({ + status: "success", + code: "theme-workbench-draft-reset", + message: "已恢复导入主题的原始设置;本地编辑副本已移除", + details: { themeId, removed: true }, + })); + }, + runDiagnostics(clientId?: ClientId): Promise> { return call("run_diagnostics", { clientId: clientId ?? null }, () => ({ status: "success", diff --git a/app/src/demo-data.ts b/app/src/demo-data.ts index 8ea781a..f4f352a 100644 --- a/app/src/demo-data.ts +++ b/app/src/demo-data.ts @@ -100,6 +100,11 @@ export const DEMO_DASHBOARD: DashboardState = { author: "CC Theme Studio", description: "温柔的暖白画布、墨色文字与低饱和靛蓝,适合长时间阅读。", colors: ["#f0eadb", "#6366a8", "#25263a"], + appearanceVariants: { + light: ["#F4F7FA", "#266446", "#203041"], + dark: ["#10151F", "#2F7D57", "#E9EEF7"], + }, + presentationSurfaceOpacity: 0.72, installed: true, updatedAt: "2026-07-18T09:30:00.000Z", compatibility: { diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 7a88075..ab48485 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -131,6 +131,7 @@ const zhCN = { "themes.imported": "主题导入完成", "themes.importedMessage": "主题包已完成校验并安装到本地主题库", "themes.importFailed": "主题包未通过安全校验,格式不正确,或同名主题已经安装", + "themes.importPresentationInvalid": "主题的沉浸式展示档案不符合支持的安全规范", "themes.importFailedTitle": "主题导入失败", "themes.dropTitle": "松开以导入主题", "themes.dropHint": "仅支持单个 .cctheme 文件", @@ -282,6 +283,7 @@ const enUS: Record = { "themes.imported": "Theme imported", "themes.importedMessage": "The package was verified and installed in the local theme library", "themes.importFailed": "The package failed security checks, has an invalid format, or a theme with the same ID is already installed", + "themes.importPresentationInvalid": "The theme’s immersive presentation profile does not meet the supported safety contract", "themes.importFailedTitle": "Theme import failed", "themes.dropTitle": "Drop to import theme", "themes.dropHint": "One .cctheme file at a time", @@ -361,6 +363,7 @@ const englishRuntimeText = new Map([ ["主题包导入异常结束", "Theme package import ended unexpectedly"], ["无法连接本地注入端口", "Could not connect to the local theme service"], ["Adapter 编译上下文不兼容,请更新对应解释器后重试", "The Adapter compile context is incompatible. Update the corresponding adapter and try again."], + ["Adapter 的主题映射尚不完整,请更新对应解释器后重试", "The Adapter’s theme mapping is incomplete. Update the Adapter and try again."], ["内置主题编译运行时不可用,请更新 CC Theme 后重试", "The bundled theme compiler runtime is unavailable. Update CC Theme and try again."], ["客户端界面未在限定时间内完成主题初始化,Adapter 已安全回滚", "The client UI did not finish theme initialization in time. The Adapter rolled back safely."], ["本地管理器已就绪", "Local manager ready"], diff --git a/app/src/icons.tsx b/app/src/icons.tsx index ed6144e..b068982 100644 --- a/app/src/icons.tsx +++ b/app/src/icons.tsx @@ -9,6 +9,7 @@ export type IconName = | "launch" | "moon" | "pause" + | "preview" | "refresh" | "restore" | "sparkle" @@ -25,6 +26,7 @@ const paths: Record = { launch: <>, moon: <>, pause: <>, + preview: <>, refresh: <>, restore: <>, sparkle: <>, diff --git a/app/src/styles.css b/app/src/styles.css index 233d108..78f17e8 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -176,10 +176,11 @@ svg { width: 18px; height: 18px; flex: 0 0 auto; } .theme-card__footer { position: relative; display: flex; min-height: 42px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 9px 8px 11px; background: #fff; } .theme-card__footer > strong { min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .theme-card__controls { position: relative; z-index: 2; display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; pointer-events: none; } -.theme-delete { display: grid; width: 25px; height: 25px; place-items: center; padding: 0; color: #969baa; border: 0; border-radius: 7px; background: transparent; cursor: pointer; pointer-events: auto; } +.theme-delete, .theme-preview { display: grid; width: 25px; height: 25px; place-items: center; padding: 0; color: #969baa; border: 0; border-radius: 7px; background: transparent; cursor: pointer; pointer-events: auto; } .theme-delete:hover:not(:disabled) { color: #c44c59; background: rgba(196,76,89,.09); } -.theme-delete:disabled { opacity: .4; cursor: not-allowed; } -.theme-delete svg { width: 12px; height: 12px; } +.theme-preview:hover:not(:disabled) { color: #7157dc; background: rgba(113,87,220,.1); } +.theme-delete:disabled, .theme-preview:disabled { opacity: .4; cursor: not-allowed; } +.theme-delete svg, .theme-preview svg { width: 12px; height: 12px; } .selected-indicator { display: grid; width: 18px; height: 18px; place-items: center; color: transparent; border: 1.5px solid #c9ccd5; border-radius: 50%; background: #fff; } .selected-indicator--selected { color: #fff; border-color: #7157dc; background: #7157dc; } .selected-indicator svg { width: 11px; height: 11px; } diff --git a/app/src/theme-workbench.css b/app/src/theme-workbench.css new file mode 100644 index 0000000..b1f3627 --- /dev/null +++ b/app/src/theme-workbench.css @@ -0,0 +1,114 @@ +.theme-workbench { position: fixed; inset: 0; z-index: 70; display: grid; grid-template-rows: minmax(0, 1fr); min-width: 1040px; color: #eef0f7; background: #090b12; } +.theme-workbench__eyebrow { color: #aaa4df; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.theme-workbench__drawer-toggle, .theme-workbench__open-drawer { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; color: #a9afbf; border: 1px solid rgba(255,255,255,.1); border-radius: 8px; background: rgba(255,255,255,.045); font-size: 21px; line-height: 1; cursor: pointer; } +.theme-workbench__drawer-toggle:hover, .theme-workbench__open-drawer:hover { color: #fff; background: rgba(255,255,255,.09); } +.theme-workbench__body { display: grid; min-width: 0; min-height: 0; grid-template-columns: 286px minmax(0, 1fr); transition: grid-template-columns .24s ease; } +.theme-workbench--drawer-closed .theme-workbench__body { grid-template-columns: 0 minmax(0, 1fr); } +.theme-workbench__drawer { z-index: 1; display: flex; min-width: 286px; min-height: 0; flex-direction: column; overflow: hidden; border-right: 1px solid rgba(255,255,255,.09); background: #11141d; box-shadow: 12px 0 30px rgba(0,0,0,.18); transition: transform .24s ease, opacity .2s ease; } +.theme-workbench--drawer-closed .theme-workbench__drawer { pointer-events: none; opacity: 0; transform: translateX(-100%); } +.theme-workbench__drawer-head { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 9px; padding: 16px 13px 14px 17px; border-bottom: 1px solid rgba(255,255,255,.065); font-size: 12px; } +.theme-workbench__drawer-theme { display: grid; min-width: 0; gap: 3px; } +.theme-workbench__drawer-theme span { color: #aaa4df; overflow: hidden; font-size: 9px; font-weight: 700; letter-spacing: .1em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.theme-workbench__drawer-theme strong { overflow: hidden; color: #f1f2f8; font-size: 14px; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; } +.theme-workbench__drawer-toggle { width: 25px; height: 25px; border-color: transparent; background: transparent; } +.theme-workbench__section { flex: 0 0 auto; border-bottom: 1px solid rgba(255,255,255,.065); } +.theme-workbench__section summary { display: flex; align-items: center; justify-content: space-between; padding: 13px 17px; color: #ebedf6; font-size: 12px; font-weight: 650; cursor: pointer; list-style: none; } +.theme-workbench__section summary::-webkit-details-marker { display: none; } +.theme-workbench__section summary::after { content: "⌄"; color: #7f8799; font-size: 15px; transform: rotate(0deg); transition: transform .18s; } +.theme-workbench__section:not([open]) summary::after { transform: rotate(-90deg); } +.theme-workbench__section > :not(summary) { margin-right: 17px; margin-left: 17px; } +.theme-workbench__colour-control { position: relative; margin-bottom: 14px; } +.theme-workbench__control-row { display: flex; gap: 8px; } +.theme-workbench__colour-swatch { width: 34px; height: 32px; flex: 0 0 auto; padding: 0; border: 1px solid rgba(255,255,255,.38); border-radius: 7px; box-shadow: inset 0 0 0 2px rgba(0,0,0,.18); cursor: pointer; } +.theme-workbench__colour-swatch:focus-visible { outline: 2px solid #9f88ff; outline-offset: 2px; } +.theme-workbench__hex { min-width: 0; flex: 1; padding: 0 9px; color: #ebeff7; border: 1px solid rgba(255,255,255,.1); border-radius: 7px; outline: 0; background: rgba(255,255,255,.045); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.theme-workbench__hex:focus { border-color: #8e7af5; box-shadow: 0 0 0 2px rgba(142,122,245,.15); } +.theme-workbench__colour-picker { position: absolute; z-index: 10; top: 40px; left: 0; right: 0; padding: 11px; border: 1px solid rgba(255,255,255,.16); border-radius: 10px; background: #1a1e27; box-shadow: 0 18px 36px rgba(0,0,0,.48); } +.theme-workbench__colour-palette { position: relative; display: block; width: 100%; height: 126px; margin: 0 0 10px; padding: 0; overflow: hidden; border: 1px solid rgba(255,255,255,.14); border-radius: 7px; outline: none; background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(var(--palette-hue) 100% 50%)); cursor: crosshair; touch-action: none; } +.theme-workbench__colour-palette:focus-visible { box-shadow: 0 0 0 2px rgba(142,122,245,.8); } +.theme-workbench__colour-palette span { position: absolute; top: var(--palette-y); left: var(--palette-x); width: 12px; height: 12px; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px rgba(0,0,0,.6), 0 1px 3px rgba(0,0,0,.55); transform: translate(-50%, -50%); } +.theme-workbench__hue-control { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 8px; align-items: center; margin: 0 0 14px; color: #8e96a8; font-size: 10px; } +.theme-workbench__hue-control input[type="range"] { width: 100%; margin: 0; padding: 0; accent-color: #9f88ff; background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); border-radius: 99px; } +.theme-workbench__rgb-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin: 0 0 11px; } +.theme-workbench__rgb-controls label { display: grid; gap: 4px; color: #9098aa; font-size: 9px; text-align: center; } +.theme-workbench__rgb-controls input { min-width: 0; height: 28px; padding: 0 4px; color: #e9edf5; border: 1px solid rgba(255,255,255,.14); border-radius: 6px; outline: 0; background: rgba(255,255,255,.055); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; } +.theme-workbench__rgb-controls input:focus { border-color: #9f88ff; } +.theme-workbench__quick-colours { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px; } +.theme-workbench__quick-colours button { width: 100%; aspect-ratio: 1; min-height: 19px; padding: 0; border: 1px solid rgba(255,255,255,.25); border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.16); cursor: pointer; } +.theme-workbench__quick-colours button[aria-pressed="true"] { border-color: #fff; box-shadow: 0 0 0 2px #8e7af5; } +.theme-workbench__media-button { display: flex; min-height: 32px; align-items: center; justify-content: center; margin-bottom: 8px; color: #ece9ff; border: 1px dashed rgba(166,150,248,.55); border-radius: 7px; background: rgba(143,121,244,.1); font-size: 11px; cursor: pointer; } +.theme-workbench__selected-media { margin-top: 0; color: #c9c4f6 !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.theme-workbench__media-error { color: #f09ba4 !important; } +.theme-workbench__inline-button { margin-bottom: 7px; padding: 0; color: #b8aaff; border: 0; background: transparent; font-size: 11px; cursor: pointer; } +.theme-workbench__inline-button:disabled { color: #62697a; cursor: default; } +.theme-workbench__section p { margin-top: 0; margin-bottom: 13px; color: #858da0; font-size: 10px; line-height: 1.55; } +.theme-workbench__range-title { display: flex; justify-content: space-between; margin-bottom: 6px; color: #bbc1cf; font-size: 10px; } +.theme-workbench__range-title span:last-child { color: #737b8c; } +.theme-workbench__section input[type="range"] { -webkit-appearance: none; appearance: none; box-sizing: border-box; width: calc(100% - 22px); height: 30px; margin: 5px 11px 13px; padding: 0 5px; border: 0; outline: 0; background: transparent; cursor: pointer; } +.theme-workbench__section input[type="range"]::-webkit-slider-runnable-track { height: 8px; border: 1px solid rgba(255,255,255,.16); border-radius: 999px; background: linear-gradient(90deg, #a995ff 0 var(--workbench-opacity-progress), rgba(255,255,255,.13) var(--workbench-opacity-progress) 100%); box-shadow: inset 0 1px 2px rgba(0,0,0,.28); } +.theme-workbench__section input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 19px; height: 19px; margin-top: -6.5px; border: 2px solid #f7f5ff; border-radius: 50%; background: linear-gradient(145deg, #fff, #d8d1ff); box-shadow: 0 2px 7px rgba(0,0,0,.48), 0 0 0 1px rgba(90,67,189,.7); } +.theme-workbench__section input[type="range"]:focus-visible::-webkit-slider-runnable-track { box-shadow: 0 0 0 2px rgba(159,136,255,.6), inset 0 1px 2px rgba(0,0,0,.28); } +.theme-workbench__drawer-actions { display: grid; flex: 0 0 auto; gap: 10px; margin-top: auto; padding: 13px 17px 18px; border-top: 1px solid rgba(255,255,255,.065); background: rgba(10,12,18,.42); } +.theme-workbench__save-status { min-height: 30px; margin: 0; color: #969eb0; font-size: 10px; line-height: 1.45; } +.theme-workbench__save-status--saving { color: #c4b9ff; } +.theme-workbench__save-status--saved { color: #8de0ba; } +.theme-workbench__save-status--failed { color: #ffabb5; } +.theme-workbench__drawer-action-row { display: grid; grid-template-columns: .8fr 1.2fr 1.2fr; gap: 7px; } +.theme-workbench__back, .theme-workbench__reset, .theme-workbench__save { min-height: 34px; border-radius: 8px; font-size: 10px; font-weight: 700; cursor: pointer; } +.theme-workbench__back, .theme-workbench__reset { color: #c3c8d5; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.045); } +.theme-workbench__restore-original { min-height: 28px; color: #e8b6b9; border: 1px solid rgba(227,126,132,.28); border-radius: 7px; background: rgba(161,49,61,.12); font-size: 10px; font-weight: 650; cursor: pointer; } +.theme-workbench__save { color: #fff; border: 1px solid rgba(255,255,255,.18); background: linear-gradient(145deg, #7b63ed, #5640c5); box-shadow: inset 0 1px rgba(255,255,255,.18), 0 8px 18px rgba(72,50,186,.2); } +.theme-workbench__back:hover, .theme-workbench__reset:hover { background: rgba(255,255,255,.09); } +.theme-workbench__restore-original:hover:not(:disabled) { background: rgba(183,60,73,.22); } +.theme-workbench__save:hover:not(:disabled) { filter: brightness(1.08); } +.theme-workbench__back:focus-visible, .theme-workbench__reset:focus-visible, .theme-workbench__save:focus-visible, .theme-workbench__restore-original:focus-visible { outline: 2px solid #a995ff; outline-offset: 2px; } +.theme-workbench__reset:disabled, .theme-workbench__save:disabled, .theme-workbench__restore-original:disabled { color: #777e90; border-color: rgba(255,255,255,.075); background: rgba(255,255,255,.045); box-shadow: none; cursor: not-allowed; } +.theme-workbench__open-drawer { position: absolute; z-index: 2; top: 50%; left: 10px; width: 27px; height: 44px; border-color: rgba(255,255,255,.12); border-radius: 8px; background: #171b27; transform: translateY(-50%); } +.theme-workbench__preview-area { position: relative; display: grid; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); gap: 12px; padding: 20px 25px 25px; background-color: #0e100d; background-image: radial-gradient(#30342e 1px, transparent 1px); background-size: 18px 18px; } +.theme-workbench__preview-controls { display: flex; align-items: center; gap: 8px; } +.theme-workbench__appearance-tabs, .theme-workbench__target-tabs { display: inline-flex; width: max-content; max-width: 100%; padding: 3px; border: 1px solid rgba(255,255,255,.12); border-radius: 8px; background: rgba(9,10,14,.82); box-shadow: 0 8px 20px rgba(0,0,0,.18); } +.theme-workbench__appearance-tabs button, .theme-workbench__target-tabs button { min-height: 27px; padding: 0 9px; color: #8f97a8; border: 0; border-radius: 5px; background: transparent; font-size: 10px; font-weight: 650; cursor: pointer; } +.theme-workbench__appearance-tabs button[aria-pressed="true"], .theme-workbench__target-tabs button[aria-pressed="true"] { color: #f1f0f9; background: rgba(151,130,244,.22); box-shadow: 0 1px 4px rgba(0,0,0,.2); } +.theme-workbench__target-tabs button:disabled { color: #5f6675; cursor: not-allowed; } +.codex-preview { position: relative; min-width: 640px; min-height: 430px; overflow: hidden; border: 1px solid var(--workbench-ui-border); border-radius: 13px; background: var(--workbench-theme-base); box-shadow: 0 25px 70px rgba(0,0,0,.34); } +.codex-preview__image, .codex-preview__media { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background-position: center; background-size: cover; } +.codex-preview__content { position: absolute; inset: 0; display: grid; grid-template-columns: 190px minmax(0, 1fr); background: linear-gradient(90deg, color-mix(in srgb, var(--workbench-main-scrim-start) var(--workbench-surface-opacity-percent), transparent), color-mix(in srgb, var(--workbench-main-scrim-mid) var(--workbench-surface-opacity-percent), transparent) 56%, color-mix(in srgb, var(--workbench-main-scrim-end) var(--workbench-surface-opacity-percent), transparent)); -webkit-backdrop-filter: blur(var(--workbench-surface-blur)); backdrop-filter: blur(var(--workbench-surface-blur)); } +.codex-preview__rail { display: flex; min-width: 0; flex-direction: column; gap: 6px; padding: 15px 11px; border-right: 1px solid var(--workbench-ui-border); background: color-mix(in srgb, var(--workbench-ui-rail) 74%, transparent); color: color-mix(in srgb, var(--workbench-text) 86%, var(--workbench-ui-muted)); } +.codex-preview__brand { display: flex; align-items: center; gap: 7px; margin: 2px 5px 12px; color: var(--workbench-text); font-size: 13px; font-weight: 700; } +.codex-preview__brand i, .codex-preview__message--assistant i { display: inline-block; width: 12px; height: 12px; border-radius: 4px; background: var(--workbench-theme-accent); box-shadow: 0 0 14px color-mix(in srgb, var(--workbench-theme-accent) 60%, transparent); } +.codex-preview__rail button { padding: 7px 8px; color: var(--workbench-text); border: 1px solid var(--workbench-ui-border); border-radius: 6px; background: color-mix(in srgb, var(--workbench-ui-surface-strong) 20%, transparent); font-size: 10px; text-align: left; } +.codex-preview__rail small { margin: 16px 6px 3px; color: color-mix(in srgb, var(--workbench-text) 56%, var(--workbench-ui-muted)); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.codex-preview__rail a { padding: 6px 7px; overflow: hidden; color: color-mix(in srgb, var(--workbench-text) 75%, var(--workbench-ui-muted)); border-radius: 6px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.codex-preview__rail .codex-preview__active { color: var(--workbench-text); background: color-mix(in srgb, var(--workbench-theme-accent) 22%, transparent); } +.codex-preview__rail-footer { margin-top: auto; padding: 5px 6px; color: color-mix(in srgb, var(--workbench-text) 55%, var(--workbench-ui-muted)); font: 10px ui-monospace, monospace; } +.codex-preview__main { display: grid; min-width: 0; grid-template-rows: 48px minmax(0, 1fr) auto; color: var(--workbench-text); } +.codex-preview__main > header { display: flex; align-items: center; justify-content: space-between; padding: 0 19px; border-bottom: 1px solid var(--workbench-ui-border); font-size: 11px; font-weight: 650; } +.codex-preview__main > header button { color: var(--workbench-text); border: 0; background: transparent; font-weight: 700; } +.codex-preview__conversation { display: flex; min-height: 0; flex-direction: column; gap: 14px; padding: 24px 10% 16px; overflow: hidden; font-size: 12px; line-height: 1.55; } +.codex-preview__message { max-width: 82%; } +.codex-preview__message--user { align-self: flex-end; padding: 8px 10px; color: var(--workbench-text); border: 1px solid var(--workbench-ui-border); border-radius: 9px 9px 2px 9px; background: color-mix(in srgb, var(--workbench-ui-surface-strong) 24%, transparent); } +.codex-preview__message--assistant { display: flex; align-items: flex-start; gap: 8px; color: color-mix(in srgb, var(--workbench-text) 89%, var(--workbench-ui-muted)); } +.codex-preview__message--assistant i { flex: 0 0 auto; width: 13px; height: 13px; margin-top: 3px; } +.codex-preview pre { max-width: 86%; margin: 0 0 0 21px; padding: 9px 11px; overflow: hidden; color: color-mix(in srgb, var(--workbench-text) 89%, var(--workbench-ui-muted)); border: 1px solid var(--workbench-ui-border); border-radius: 8px; background: color-mix(in srgb, var(--workbench-ui-surface-strong) 64%, transparent); font: 10px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } +.codex-preview__composer { display: flex; min-height: 42px; align-items: center; justify-content: space-between; margin: 0 9% 16px; padding: 0 11px; color: color-mix(in srgb, var(--workbench-text) 62%, var(--workbench-ui-muted)); border: 1px solid var(--workbench-ui-border); border-radius: 8px; background: color-mix(in srgb, var(--workbench-ui-surface-strong) 62%, transparent); font-size: 10px; } +.codex-preview__composer kbd { padding: 2px 4px; color: var(--workbench-text); border: 1px solid var(--workbench-ui-border); border-radius: 4px; font-size: 9px; } +.workbuddy-preview__content { position: absolute; inset: 0; display: grid; grid-template: 45px minmax(0, 1fr) / 205px minmax(0, 1fr); background: linear-gradient(90deg, color-mix(in srgb, var(--workbench-main-scrim-start) var(--workbench-surface-opacity-percent), transparent), color-mix(in srgb, var(--workbench-main-scrim-mid) var(--workbench-surface-opacity-percent), transparent) 57%, color-mix(in srgb, var(--workbench-main-scrim-end) var(--workbench-surface-opacity-percent), transparent)); -webkit-backdrop-filter: blur(var(--workbench-surface-blur)); backdrop-filter: blur(var(--workbench-surface-blur)); color: var(--workbench-text); font-size: 11px; } +.workbuddy-preview__header { display: flex; grid-column: 1 / -1; align-items: center; gap: 9px; padding: 0 16px; border-bottom: 1px solid var(--workbench-ui-border); background: color-mix(in srgb, var(--workbench-ui-surface-strong) 52%, transparent); } +.workbuddy-preview__header strong { margin-right: auto; color: color-mix(in srgb, var(--workbench-text) 85%, var(--workbench-ui-muted)); letter-spacing: -.01em; } +.workbuddy-preview__header span { color: var(--workbench-ui-muted); font-size: 10px; } +.workbuddy-preview__header button { padding: 4px 7px; color: color-mix(in srgb, var(--workbench-text) 72%, var(--workbench-ui-muted)); border: 0; border-radius: 5px; background: transparent; font-size: 10px; } +.workbuddy-preview__rail { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 16px 11px; border-right: 1px solid var(--workbench-ui-border); background: color-mix(in srgb, var(--workbench-ui-rail) 66%, transparent); } +.workbuddy-preview__rail b, .workbuddy-preview__rail small { padding: 0 7px; color: var(--workbench-ui-muted); font-size: 9px; letter-spacing: .06em; text-transform: uppercase; } +.workbuddy-preview__rail small { margin-top: 15px; } +.workbuddy-preview__rail a { padding: 7px; overflow: hidden; color: color-mix(in srgb, var(--workbench-text) 75%, var(--workbench-ui-muted)); border-radius: 6px; text-overflow: ellipsis; white-space: nowrap; } +.workbuddy-preview__rail .workbuddy-preview__active { color: color-mix(in srgb, var(--workbench-text) 80%, var(--workbench-ui-muted)); background: color-mix(in srgb, var(--workbench-theme-accent) 19%, var(--workbench-ui-surface)); font-weight: 650; } +.workbuddy-preview__main { position: relative; display: flex; min-width: 0; flex-direction: column; padding: 32px 11% 17px; color: color-mix(in srgb, var(--workbench-text) 85%, var(--workbench-ui-muted)); } +.workbuddy-preview__main h2 { max-width: 540px; margin: 0 0 18px; color: color-mix(in srgb, var(--workbench-text) 92%, var(--workbench-ui-muted)); font-size: 17px; letter-spacing: -.025em; } +.workbuddy-preview__main p { display: flex; align-items: flex-start; gap: 8px; margin: 0; line-height: 1.55; } +.workbuddy-preview__main p i { width: 13px; height: 13px; margin-top: 2px; border-radius: 50%; background: var(--workbench-theme-accent); } +.workbuddy-preview__card { display: grid; gap: 5px; width: min(340px, 90%); margin: 19px 0 0 21px; padding: 10px; color: color-mix(in srgb, var(--workbench-text) 78%, var(--workbench-ui-muted)); border: 1px solid var(--workbench-ui-border); border-radius: 8px; background: color-mix(in srgb, var(--workbench-ui-surface-strong) 44%, transparent); } +.workbuddy-preview__card strong { color: color-mix(in srgb, var(--workbench-text) 92%, var(--workbench-ui-muted)); font-size: 10px; } +.workbuddy-preview__card span { color: var(--workbench-ui-muted); font-size: 10px; } +.workbuddy-preview__composer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding: 10px; color: color-mix(in srgb, var(--workbench-text) 58%, var(--workbench-ui-muted)); border: 1px solid var(--workbench-ui-border); border-radius: 8px; background: color-mix(in srgb, var(--workbench-ui-surface-strong) 54%, transparent); font-size: 10px; } +.workbuddy-preview__composer kbd { padding: 2px 4px; color: var(--workbench-ui-muted); border: 1px solid var(--workbench-ui-border); border-radius: 4px; } +@media (prefers-reduced-motion: reduce) { .theme-workbench__body, .theme-workbench__drawer, .theme-workbench__section summary::after { transition: none; } } diff --git a/app/src/theme-workbench.test.tsx b/app/src/theme-workbench.test.tsx new file mode 100644 index 0000000..4d2e11f --- /dev/null +++ b/app/src/theme-workbench.test.tsx @@ -0,0 +1,176 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cloneDemoDashboard } from "./demo-data"; +import { ThemeWorkbench, type ThemeWorkbenchDraft } from "./theme-workbench"; + +const theme = cloneDemoDashboard().themes[0]; + +describe("ThemeWorkbench", () => { + beforeEach(() => { + URL.createObjectURL = vi.fn(() => "blob:theme-workbench-test"); + URL.revokeObjectURL = vi.fn(); + }); + + it("tracks the complete draft, persists it only after a change, and confirms the saved state", async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(); + render(); + + expect(screen.getByRole("region", { name: "主题工作台" })).toBeInTheDocument(); + const preview = screen.getByTestId("static-client-preview"); + expect(preview).toHaveStyle({ "--workbench-text": "#E9EEF7" }); + expect(screen.getByRole("button", { name: "保存更改" })).toBeDisabled(); + + const colour = screen.getByRole("button", { name: "主要文字颜色" }); + await user.clear(screen.getByLabelText("主要文字颜色 hex")); + await user.type(screen.getByLabelText("主要文字颜色 hex"), "#FF00AA"); + expect(colour).toHaveAttribute("aria-expanded", "false"); + expect(preview).toHaveStyle({ "--workbench-text": "#FF00AA" }); + expect(screen.getByRole("button", { name: "保存更改" })).toBeEnabled(); + + await user.click(screen.getByRole("button", { name: "保存更改" })); + expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({ + kind: "cc-theme.theme-workbench-draft", + base: { themeId: theme.id }, + patch: expect.objectContaining({ textColors: { lightText: "#203041", darkText: "#FF00AA" }, surfaceOpacity: 0.72 }), + } satisfies Partial), null); + await screen.findByText("已保存到本地;下次应用主题时会使用这些设置。"); + expect(screen.getByRole("button", { name: "保存更改" })).toBeDisabled(); + }); + + it("keeps one shared text token for a Theme Family without Light/Dark variants", async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(() => Promise.resolve()); + const singlePaletteTheme = { ...theme, appearanceVariants: undefined, colors: ["#10151F", "#7B63ED", "#E9EEF7"] as [string, string, string] }; + render(); + + fireEvent.change(screen.getByLabelText("Main content surface opacity"), { target: { value: "0" } }); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({ + patch: expect.objectContaining({ + textColors: { lightText: "#E9EEF7", darkText: "#E9EEF7" }, + surfaceOpacity: 0, + }), + }), null); + expect(await screen.findByText("Saved locally. These settings will be used the next time the theme is applied.")).toBeInTheDocument(); + }); + + it("lets the editor drawer collapse and close without changing the theme", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + + expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Close edit drawer" })); + expect(screen.getByTestId("theme-workbench")).toHaveClass("theme-workbench--drawer-closed"); + await user.click(screen.getByRole("button", { name: "Open edit drawer" })); + expect(screen.getByTestId("theme-workbench")).not.toHaveClass("theme-workbench--drawer-closed"); + await user.click(screen.getByRole("button", { name: "Back" })); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("switches preview targets while retaining the same current draft", async () => { + const user = userEvent.setup(); + render(); + + await user.clear(screen.getByLabelText("Primary text colour hex")); + await user.type(screen.getByLabelText("Primary text colour hex"), "#35C7A5"); + await user.click(screen.getByRole("button", { name: "WorkBuddy" })); + + const preview = screen.getByTestId("static-client-preview"); + expect(preview).toHaveAttribute("data-preview-target", "mac-workbuddy"); + expect(preview).toHaveStyle({ "--workbench-text": "#35C7A5" }); + expect(screen.getByRole("button", { name: "WorkBuddy" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("switches the preview between the Theme Family's complete light and dark palette summaries", async () => { + const user = userEvent.setup(); + render(); + + const preview = screen.getByTestId("static-client-preview"); + expect(preview).toHaveAttribute("data-preview-appearance", "dark"); + expect(preview).toHaveStyle({ "--workbench-theme-base": "#10151F", "--workbench-text": "#E9EEF7" }); + + await user.click(screen.getByRole("button", { name: "Light" })); + expect(preview).toHaveAttribute("data-preview-appearance", "light"); + expect(preview).toHaveStyle({ "--workbench-theme-base": "#F4F7FA", "--workbench-text": "#203041" }); + expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("links main content surface opacity to the preview and persists it with the complete draft", async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(() => Promise.resolve()); + render(); + + const control = screen.getByLabelText("Main content surface opacity"); + expect(control).toHaveAttribute("min", "0"); + expect(control).toHaveAttribute("max", "100"); + fireEvent.change(control, { target: { value: "84" } }); + expect(screen.getByTestId("static-client-preview")).toHaveStyle({ "--workbench-surface-opacity": "0.84" }); + + fireEvent.change(control, { target: { value: "0" } }); + expect(screen.getByTestId("static-client-preview")).toHaveStyle({ + "--workbench-surface-opacity": "0", + "--workbench-surface-opacity-percent": "0%", + "--workbench-surface-blur": "0px", + }); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({ + base: { themeId: theme.id }, + patch: expect.objectContaining({ surfaceOpacity: 0 }), + }), null); + }); + + it("keeps the save action enabled after a failure and explains that no existing draft was changed", async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(() => Promise.reject(new Error("storage unavailable"))); + render(); + + await user.clear(screen.getByLabelText("Primary text colour hex")); + await user.type(screen.getByLabelText("Primary text colour hex"), "#00AAFF"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("The local editable copy could not be written. The original theme and existing local version were not changed.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(); + }); + + it("can remove a saved local editable copy without deleting the imported theme", async () => { + const user = userEvent.setup(); + const onRestoreOriginal = vi.fn(() => Promise.resolve()); + const onClose = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Restore original theme" })); + expect(onRestoreOriginal).toHaveBeenCalledWith(theme.id); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("opens a browser-style colour card and keeps the board, hue, RGB, and hex values in sync", async () => { + const user = userEvent.setup(); + render(); + + await user.clear(screen.getByLabelText("Primary text colour hex")); + await user.type(screen.getByLabelText("Primary text colour hex"), "#FF0000"); + await user.click(screen.getByRole("button", { name: "Primary text colour" })); + expect(screen.getByRole("dialog", { name: "Text colour picker" })).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Hue"), { target: { value: "120" } }); + expect(screen.getByLabelText("Primary text colour hex")).toHaveValue("#00FF00"); + expect(screen.getByRole("button", { name: "Colour palette" })).toBeInTheDocument(); + expect(screen.getByLabelText("Primary text colour R")).toHaveValue(0); + expect(screen.getByLabelText("Primary text colour G")).toHaveValue(255); + expect(screen.getByLabelText("Primary text colour B")).toHaveValue(0); + }); + + it("keeps the preview canvas focused on target tags instead of a static-preview heading", () => { + render(); + + expect(screen.queryByText("Static preview")).not.toBeInTheDocument(); + expect(screen.queryByText("Preview never launches, injects, or changes a real client. It does not claim runtime fidelity.")).not.toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Preview target" })).toHaveTextContent("Codex"); + expect(screen.getByRole("group", { name: "Preview target" })).toHaveTextContent("WorkBuddy"); + }); +}); diff --git a/app/src/theme-workbench.tsx b/app/src/theme-workbench.tsx new file mode 100644 index 0000000..f5b63f8 --- /dev/null +++ b/app/src/theme-workbench.tsx @@ -0,0 +1,618 @@ +import { useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; +import type { ThemeFamily } from "./types"; +import "./theme-workbench.css"; + +export interface ThemeWorkbenchDraft { + kind: "cc-theme.theme-workbench-draft"; + revision: 1; + base: { themeId: string }; + previewAdapterId: PreviewTarget; + patch: { + textColors: { + lightText: string; + darkText: string; + }; + surfaceOpacity: number; + backgroundAsset: { + source: "inherit" | "theme" | "uploaded"; + mediaType: "image" | "video" | null; + fileName?: string; + mimeType?: string; + requiresStaticFallback?: true; + }; + }; +} + +export interface ThemeWorkbenchMedia { + bytes: Uint8Array; +} + +interface LocalBackground { + objectUrl: string; + mediaType: "image" | "video"; + fileName: string; + mimeType: string; + bytes: Uint8Array; +} + +type PreviewTarget = "mac-codex" | "mac-workbuddy"; +type PreviewAppearance = "light" | "dark"; + +export interface ThemeWorkbenchProps { + theme: ThemeFamily; + locale: "zh-CN" | "en-US"; + onClose: () => void; + /** Static simulation targets. Runtime fidelity is owned by an Adapter preview capability. */ + previewTargets?: readonly PreviewTarget[]; + /** Persists only closed local overrides; it never changes the imported package. */ + onSaveDraft?: (draft: ThemeWorkbenchDraft, media: ThemeWorkbenchMedia | null) => void | Promise; + /** Removes the Manager-owned local editable copy and returns to the imported baseline. */ + onRestoreOriginal?: (themeId: string) => void | Promise; +} + +function isHexColor(value: string): boolean { + return /^#[0-9A-Fa-f]{6}$/.test(value); +} + +interface HsvColor { hue: number; saturation: number; value: number; } +interface RgbColor { red: number; green: number; blue: number; } + +const QUICK_COLOURS = ["#E9EEF7", "#AAB5C5", "#10151F", "#2F7D57", "#7B63ED", "#266446", "#D74D5B", "#D49A35"] as const; + +function clamp(value: number, minimum = 0, maximum = 1): number { + return Math.min(maximum, Math.max(minimum, value)); +} + +function hexToHsv(value: string): HsvColor { + const hex = isHexColor(value) ? value.slice(1) : "E9EEF7"; + const red = Number.parseInt(hex.slice(0, 2), 16) / 255; + const green = Number.parseInt(hex.slice(2, 4), 16) / 255; + const blue = Number.parseInt(hex.slice(4, 6), 16) / 255; + const maximum = Math.max(red, green, blue); + const minimum = Math.min(red, green, blue); + const delta = maximum - minimum; + let hue = 0; + if (delta > 0) { + if (maximum === red) hue = 60 * (((green - blue) / delta) % 6); + else if (maximum === green) hue = 60 * ((blue - red) / delta + 2); + else hue = 60 * ((red - green) / delta + 4); + } + return { hue: (hue + 360) % 360, saturation: maximum === 0 ? 0 : delta / maximum, value: maximum }; +} + +function hsvToHex({ hue, saturation, value }: HsvColor): string { + const normalizedHue = ((hue % 360) + 360) % 360; + const chroma = clamp(value) * clamp(saturation); + const match = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1)); + const offset = clamp(value) - chroma; + const [red, green, blue] = normalizedHue < 60 ? [chroma, match, 0] + : normalizedHue < 120 ? [match, chroma, 0] + : normalizedHue < 180 ? [0, chroma, match] + : normalizedHue < 240 ? [0, match, chroma] + : normalizedHue < 300 ? [match, 0, chroma] + : [chroma, 0, match]; + const channel = (component: number) => Math.round((component + offset) * 255).toString(16).padStart(2, "0"); + return `#${channel(red)}${channel(green)}${channel(blue)}`.toUpperCase(); +} + +function hexToRgb(value: string): RgbColor { + const hex = isHexColor(value) ? value.slice(1) : "E9EEF7"; + return { red: Number.parseInt(hex.slice(0, 2), 16), green: Number.parseInt(hex.slice(2, 4), 16), blue: Number.parseInt(hex.slice(4, 6), 16) }; +} + +function rgbToHex({ red, green, blue }: RgbColor): string { + const channel = (value: number) => Math.round(clamp(value, 0, 255)).toString(16).padStart(2, "0"); + return `#${channel(red)}${channel(green)}${channel(blue)}`.toUpperCase(); +} + +function previewPalette(theme: ThemeFamily, appearance: PreviewAppearance): [string, string, string] { + if (theme.appearanceVariants) return theme.appearanceVariants[appearance]; + return appearance === "light" + ? ["#F4F7FA", theme.colors[1], "#203041"] + : theme.colors; +} + +function previewScrim(theme: ThemeFamily, appearance: PreviewAppearance): [string, string, string] { + if (theme.presentationScrim) return theme.presentationScrim[appearance]; + return appearance === "light" + ? ["rgba(247, 249, 252, .88)", "rgba(247, 249, 252, .54)", "rgba(247, 249, 252, .18)"] + : ["rgba(8, 11, 18, .82)", "rgba(11, 15, 24, .48)", "rgba(14, 18, 28, .16)"]; +} + +function copyForDraft(theme: ThemeFamily, textColors: Record, surfaceOpacity: number, background: LocalBackground | null, backgroundSource: "inherit" | "theme" | "uploaded", previewAdapterId: PreviewTarget): ThemeWorkbenchDraft { + return { + kind: "cc-theme.theme-workbench-draft", + revision: 1, + base: { themeId: theme.id }, + previewAdapterId, + patch: { + textColors: { + lightText: textColors.light.toUpperCase(), + darkText: textColors.dark.toUpperCase(), + }, + surfaceOpacity, + backgroundAsset: backgroundSource === "uploaded" && background + ? { source: "uploaded", mediaType: background.mediaType, fileName: background.fileName, mimeType: background.mimeType, ...(background.mediaType === "video" ? { requiresStaticFallback: true as const } : {}) } + : { source: backgroundSource, mediaType: null }, + }, + }; +} + +interface WorkbenchSavedSnapshot { + textColors: Record; + surfaceOpacity: number; + background: LocalBackground | null; + backgroundSource: "inherit" | "theme" | "uploaded"; +} + +function initialTextColors(theme: ThemeFamily): Record { + // A Theme Family without appearanceVariants owns one Shared Core text token. + // The preview can still use a light-friendly fallback palette, but the + // editable value must start as the same semantic token for both appearances. + // Otherwise simply opening the Workbench manufactures an invalid divergent + // Light/Dark edit and makes an opacity-only save fail closed. + if (!theme.appearanceVariants) { + return { light: theme.colors[2], dark: theme.colors[2] }; + } + return { + light: previewPalette(theme, "light")[2], + dark: previewPalette(theme, "dark")[2], + }; +} + +function words(locale: ThemeWorkbenchProps["locale"]) { + return locale === "zh-CN" + ? { + title: "主题工作台", + source: "基于公共主题", + close: "关闭工作台", + openPanel: "展开编辑抽屉", + closePanel: "收起编辑抽屉", + draft: "有未保存的更改", + unchanged: "所有更改均已保存", + saving: "正在保存更改…", + saved: "已保存到本地;下次应用主题时会使用这些设置。", + text: "主要文字颜色", + palette: "颜色板", + hue: "色相", + picker: "文字颜色选择器", + red: "红", + green: "绿", + blue: "蓝", + quickColours: "常用颜色", + background: "背景媒体", + opacity: "主内容背景透明度", + moreOpaque: "更不透明", + chooseMedia: "选择图片或视频", + inheritMedia: "沿用主题背景", + mediaHint: "保存时会校验并复制兼容的本地媒体;原始主题包不会被修改。", + invalidMedia: "仅支持 PNG、JPEG、WebP 图片或 MP4 视频。", + opacityHint: "背景在最底层;此数值会同时缩放主内容层与它的场景遮罩。调到 0% 时不再额外暗化或模糊背景。", + opacityUnavailable: "此主题没有声明可持久化的沉浸式内容层;当前数值只用于预览。", + reset: "放弃未保存更改", + restoreOriginal: "恢复原始主题", + restoringOriginal: "正在恢复原始主题…", + save: "保存更改", + back: "返回", + appearance: "预览外观", + light: "浅色", + dark: "深色", + target: "预览目标", + codex: "Codex", + workbuddy: "WorkBuddy", + newConversation: "新对话", + recent: "最近", + task: "给这个项目梳理一份交付计划", + answer: "我会先拆分目标、风险和验收节点。", + composer: "给 Codex 发送消息…", + saveFailed: "无法保存本次编辑;原始主题和已有本地设置均未被修改。", + saveFailedAppearance: "此主题没有独立的浅色/深色色板,因此主文字颜色会同时用于两种外观。请重新保存。", + saveFailedStorage: "本地编辑副本未能写入;原始主题和已有本地版本均未被修改。", + saveFailedBaseline: "导入主题的基线已变化;请先恢复原始主题,再基于新版本重新编辑。", + selectedMedia: "已选择 {name}", + } + : { + title: "Theme workbench", + source: "Based on public theme", + close: "Close workbench", + openPanel: "Open edit drawer", + closePanel: "Close edit drawer", + draft: "Unsaved changes", + unchanged: "All changes are saved", + saving: "Saving changes…", + saved: "Saved locally. These settings will be used the next time the theme is applied.", + text: "Primary text colour", + palette: "Colour palette", + hue: "Hue", + picker: "Text colour picker", + red: "R", + green: "G", + blue: "B", + quickColours: "Quick colours", + background: "Background media", + opacity: "Main content surface opacity", + moreOpaque: "More opaque", + chooseMedia: "Choose image or video", + inheritMedia: "Use theme background", + mediaHint: "Saving verifies and copies compatible local media. The original theme package is never changed.", + invalidMedia: "Only PNG, JPEG, WebP images, or MP4 video are supported.", + opacityHint: "The background sits underneath. This scales the main content surface and its scene veil; at 0% the preview adds no extra dimming or blur.", + opacityUnavailable: "This theme does not declare a persistent immersive content surface; this value is preview-only.", + reset: "Discard unsaved changes", + restoreOriginal: "Restore original theme", + restoringOriginal: "Restoring original theme…", + save: "Save changes", + back: "Back", + appearance: "Preview appearance", + light: "Light", + dark: "Dark", + target: "Preview target", + codex: "Codex", + workbuddy: "WorkBuddy", + newConversation: "New conversation", + recent: "Recent", + task: "Create a delivery plan for this project", + answer: "I’ll start by separating goals, risks, and acceptance points.", + composer: "Message Codex…", + saveFailed: "Could not save these edits. The original theme and existing local settings were not changed.", + saveFailedAppearance: "This theme has one shared text token, so the primary text colour is applied to both appearances. Please save again.", + saveFailedStorage: "The local editable copy could not be written. The original theme and existing local version were not changed.", + saveFailedBaseline: "The imported theme baseline changed. Restore the original theme before editing the new version.", + selectedMedia: "Selected {name}", + }; +} + +export function ThemeWorkbench({ theme, locale, onClose, onSaveDraft, onRestoreOriginal, previewTargets }: ThemeWorkbenchProps) { + const copy = useMemo(() => words(locale), [locale]); + const fileInput = useRef(null); + const colourControl = useRef(null); + const [drawerOpen, setDrawerOpen] = useState(true); + const [previewAppearance, setPreviewAppearance] = useState("dark"); + const [textColors, setTextColors] = useState>(() => initialTextColors(theme)); + const initialSurfaceOpacity = theme.presentationSurfaceOpacity ?? 0.72; + const [surfaceOpacity, setSurfaceOpacity] = useState(initialSurfaceOpacity); + const [background, setBackground] = useState(null); + const [backgroundSource, setBackgroundSource] = useState<"inherit" | "theme" | "uploaded">("inherit"); + const [savedSnapshot, setSavedSnapshot] = useState(() => ({ + textColors: initialTextColors(theme), + surfaceOpacity: initialSurfaceOpacity, + background: null, + backgroundSource: "inherit", + })); + const [mediaError, setMediaError] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "failed">("idle"); + const [saveError, setSaveError] = useState(null); + const [hasLocalDraft, setHasLocalDraft] = useState(Boolean(theme.hasLocalDraft)); + const [restoringOriginal, setRestoringOriginal] = useState(false); + const supportedPreviewTargets = useMemo(() => previewTargets ?? (["mac-codex", "mac-workbuddy"] as const).filter((target) => theme.compatibility[target]?.status === "ready"), [previewTargets, theme.compatibility]); + const [previewTarget, setPreviewTarget] = useState(supportedPreviewTargets[0] ?? "mac-codex"); + const palette = previewPalette(theme, previewAppearance); + const textColor = textColors[previewAppearance]; + + useEffect(() => { + setPreviewAppearance("dark"); + const nextTextColors = initialTextColors(theme); + const nextSurfaceOpacity = theme.presentationSurfaceOpacity ?? 0.72; + setTextColors(nextTextColors); + setSurfaceOpacity(nextSurfaceOpacity); + setSavedSnapshot({ textColors: nextTextColors, surfaceOpacity: nextSurfaceOpacity, background: null, backgroundSource: "inherit" }); + setBackgroundSource("inherit"); + setBackground((current) => { + if (current) URL.revokeObjectURL(current.objectUrl); + return null; + }); + setSaveState("idle"); + setSaveError(null); + setHasLocalDraft(Boolean(theme.hasLocalDraft)); + setRestoringOriginal(false); + setMediaError(null); + setPickerOpen(false); + setPreviewTarget(supportedPreviewTargets[0] ?? "mac-codex"); + }, [theme.id]); + + useEffect(() => { + if (!pickerOpen) return; + const closeIfOutside = (event: PointerEvent) => { + if (!colourControl.current?.contains(event.target as Node)) setPickerOpen(false); + }; + const closeOnEscape = (event: globalThis.KeyboardEvent) => { + if (event.key === "Escape") setPickerOpen(false); + }; + document.addEventListener("pointerdown", closeIfOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeIfOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [pickerOpen]); + + const safeTextColor = isHexColor(textColor) ? textColor.toUpperCase() : palette[2]; + const safeTextColors: Record = { + light: isHexColor(textColors.light) ? textColors.light.toUpperCase() : previewPalette(theme, "light")[2], + dark: isHexColor(textColors.dark) ? textColors.dark.toUpperCase() : previewPalette(theme, "dark")[2], + }; + const currentHsv = hexToHsv(safeTextColor); + const currentRgb = hexToRgb(safeTextColor); + const canPersistSurfaceOpacity = theme.presentationSurfaceOpacity !== undefined; + const hasInvalidTextColor = !isHexColor(textColors.light) || !isHexColor(textColors.dark); + const hasChanges = textColors.light !== savedSnapshot.textColors.light + || textColors.dark !== savedSnapshot.textColors.dark + || surfaceOpacity !== savedSnapshot.surfaceOpacity + || background !== savedSnapshot.background + || backgroundSource !== savedSnapshot.backgroundSource; + const canSave = Boolean(onSaveDraft && canPersistSurfaceOpacity && hasChanges && !hasInvalidTextColor && saveState !== "saving"); + const draftStatus = saveState === "saving" + ? copy.saving + : saveState === "saved" + ? copy.saved + : saveState === "failed" + ? saveError === "appearance" ? copy.saveFailedAppearance + : saveError === "storage" ? copy.saveFailedStorage + : saveError === "baseline" ? copy.saveFailedBaseline + : copy.saveFailed + : hasChanges ? copy.draft : copy.unchanged; + const backgroundUrl = background?.objectUrl ?? theme.previewUrl ?? null; + const scrim = previewScrim(theme, previewAppearance); + const surfaceOpacityPercent = `${Math.round(surfaceOpacity * 100)}%`; + const previewStyle = { + "--workbench-text": safeTextColor, + "--workbench-surface-opacity": String(surfaceOpacity), + "--workbench-surface-opacity-percent": surfaceOpacityPercent, + "--workbench-surface-blur": `${Math.round(surfaceOpacity * 3)}px`, + "--workbench-main-scrim-start": scrim[0], + "--workbench-main-scrim-mid": scrim[1], + "--workbench-main-scrim-end": scrim[2], + "--workbench-theme-base": palette[0], + "--workbench-theme-accent": palette[1], + "--workbench-ui-surface": previewAppearance === "light" ? "#F7F9FC" : "#0C1019", + "--workbench-ui-surface-strong": previewAppearance === "light" ? "#FFFFFF" : "#0A0D14", + "--workbench-ui-rail": previewAppearance === "light" ? "#EEF2F6" : "#101623", + "--workbench-ui-muted": previewAppearance === "light" ? "#526173" : "#AAB5C5", + "--workbench-ui-border": previewAppearance === "light" ? "rgba(24,32,45,.14)" : "rgba(255,255,255,.13)", + } as CSSProperties; + + function reset() { + setTextColors(savedSnapshot.textColors); + setSurfaceOpacity(savedSnapshot.surfaceOpacity); + setBackground((current) => { + if (current && current !== savedSnapshot.background) URL.revokeObjectURL(current.objectUrl); + return savedSnapshot.background; + }); + setBackgroundSource(savedSnapshot.backgroundSource); + if (fileInput.current && !savedSnapshot.background) fileInput.current.value = ""; + setSaveState("idle"); + setSaveError(null); + setMediaError(null); + } + + async function handleMedia(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + const mediaType = file.type === "video/mp4" ? "video" : file.type === "image/png" || file.type === "image/jpeg" || file.type === "image/webp" ? "image" : null; + if (!mediaType) { + setMediaError(copy.invalidMedia); + event.target.value = ""; + return; + } + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await file.arrayBuffer()); + } catch { + setMediaError(copy.invalidMedia); + event.target.value = ""; + return; + } + const objectUrl = URL.createObjectURL(file); + setBackground((current) => { + if (current && current !== savedSnapshot.background) URL.revokeObjectURL(current.objectUrl); + return { objectUrl, mediaType, fileName: file.name, mimeType: file.type, bytes }; + }); + setBackgroundSource("uploaded"); + setMediaError(null); + setSaveState("idle"); + setSaveError(null); + } + + function updateTextColour(value: string) { + const normalized = value.toUpperCase(); + setTextColors((current) => theme.appearanceVariants + ? { ...current, [previewAppearance]: normalized } + : { light: normalized, dark: normalized }); + setSaveState("idle"); + setSaveError(null); + } + + function updateRgb(channel: keyof RgbColor, next: string) { + const value = Number(next); + if (!Number.isFinite(value)) return; + updateTextColour(rgbToHex({ ...currentRgb, [channel]: value })); + } + + function updatePaletteFromPointer(event: ReactPointerEvent) { + const bounds = event.currentTarget.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) return; + const saturation = clamp((event.clientX - bounds.left) / bounds.width); + const value = 1 - clamp((event.clientY - bounds.top) / bounds.height); + updateTextColour(hsvToHex({ hue: currentHsv.hue, saturation, value })); + } + + function handlePaletteKeyboard(event: KeyboardEvent) { + const delta = event.shiftKey ? 0.1 : 0.025; + let saturation = currentHsv.saturation; + let value = currentHsv.value; + if (event.key === "ArrowLeft") saturation -= delta; + else if (event.key === "ArrowRight") saturation += delta; + else if (event.key === "ArrowUp") value += delta; + else if (event.key === "ArrowDown") value -= delta; + else return; + event.preventDefault(); + updateTextColour(hsvToHex({ hue: currentHsv.hue, saturation, value })); + } + + async function saveDraft() { + if (!canSave || !onSaveDraft) return; + setSaveState("saving"); + try { + await onSaveDraft( + copyForDraft(theme, safeTextColors, surfaceOpacity, background, backgroundSource, previewTarget), + backgroundSource === "uploaded" && background ? { bytes: background.bytes } : null, + ); + setSavedSnapshot({ textColors, surfaceOpacity, background, backgroundSource }); + setSaveState("saved"); + setSaveError(null); + setHasLocalDraft(true); + } catch (error) { + const code = error instanceof Error ? error.message : ""; + setSaveError( + code.includes("theme-workbench-text-colour-appearance-unsupported") + ? "appearance" + : code.includes("theme-local-override-baseline-changed") + ? "baseline" + : code.includes("storage") + ? "storage" + : "generic", + ); + setSaveState("failed"); + } + } + + async function restoreOriginal() { + if (!onRestoreOriginal || !hasLocalDraft || restoringOriginal) return; + setRestoringOriginal(true); + try { + await onRestoreOriginal(theme.id); + setHasLocalDraft(false); + onClose(); + } catch (error) { + const code = error instanceof Error ? error.message : ""; + setSaveError(code.includes("storage") ? "storage" : "generic"); + setSaveState("failed"); + } finally { + setRestoringOriginal(false); + } + } + + return ( +
+
+ + + {!drawerOpen && } + +
+
+
+ {(["light", "dark"] as const).map((appearance) => )} +
+
+ {(["mac-codex", "mac-workbuddy"] as const).map((target) => { + const enabled = supportedPreviewTargets.includes(target); + const name = target === "mac-codex" ? copy.codex : copy.workbuddy; + return ; + })} +
+
+
+ {backgroundUrl && background?.mediaType === "video" + ?
+
+
+ ); +} diff --git a/app/src/types.ts b/app/src/types.ts index f310a05..a0b48fa 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -87,6 +87,17 @@ export interface ThemeCompatibility { note: string; } +export interface ThemeAppearanceVariants { + light: [string, string, string]; + dark: [string, string, string]; +} + +/** Semantic main-content veil stops for the static workbench preview. */ +export interface ThemePresentationScrim { + light: [string, string, string]; + dark: [string, string, string]; +} + export interface ThemeFamily { id: string; name: string; @@ -95,7 +106,11 @@ export interface ThemeFamily { defaultLocale?: string; localizations?: Record; colors: [string, string, string]; + appearanceVariants?: ThemeAppearanceVariants; + presentationSurfaceOpacity?: number; + presentationScrim?: ThemePresentationScrim; previewUrl?: string | null; + hasLocalDraft?: boolean; installed: boolean; updatedAt: string; compatibility: Record; diff --git a/app/tests/adapter-distribution.test.mjs b/app/tests/adapter-distribution.test.mjs index 4e08b7e..ae9db46 100644 --- a/app/tests/adapter-distribution.test.mjs +++ b/app/tests/adapter-distribution.test.mjs @@ -12,28 +12,33 @@ import { verifyAdapterPackage } from "../scripts/adapter-package.mjs"; const execute = promisify(execFile); const expectedIdentities = new Map([ - ["mac-codex", "mac-codex-26.715.71837-r2-macos-arm64"], - ["mac-doubao", "mac-doubao-2.19.9-r2-macos-arm64"], - ["mac-workbuddy", "mac-workbuddy-5.2.6-r2-macos-arm64"], + ["mac-codex", "mac-codex-26.715.71837-r3-macos-arm64"], + ["mac-doubao", "mac-doubao-2.19.9-r4-macos-arm64"], + ["mac-workbuddy", "mac-workbuddy-5.2.6-r4-macos-arm64"], +]); +const expectedRevisions = new Map([ + ["mac-codex", 3], + ["mac-doubao", 4], + ["mac-workbuddy", 4], ]); const expectedPublishedPackages = new Map([ ["mac-codex", { - bytes: 1_083_380, - sha256: "d30ab16038a11ae5a55d40772953e0198cf15ec9b2c9bc1816026d965a7d54b4", - manifestSha256: "13dcf5e6dd13104881a1e30fd94cebd69e7bbc7fc07634c1d3837797003fc925", - minimumManagerVersion: "0.2.1", + bytes: 1_098_823, + sha256: "6dd7938810b1b7f5f3ee7ff2b800f1b1df917ee0466e11eda8ab50bb7332cd34", + manifestSha256: "63c7e190e137dfcb1405e080a34c08fd62ecbf3c7a4b6ad408718e2cb6b2a1ea", + minimumManagerVersion: "0.2.2", }], ["mac-doubao", { - bytes: 161_537, - sha256: "9fd79e213e2627333bb111c5e087d1cab259a953424af27729be88a112b9d277", - manifestSha256: "76aa7d37fae88a243ec44762b9140d11c457dc9d9ee2d62bed997b0166df4895", - minimumManagerVersion: "0.2.1", + bytes: 187_920, + sha256: "6eea72b489fedaef453b7e67b710706827b8e6be94b650a2e3dce67387613b8a", + manifestSha256: "a14ee11246c2e1c31e24970a4f2d75a37241a3c121cbdd971faba8e3caa3ed38", + minimumManagerVersion: "0.2.2", }], ["mac-workbuddy", { - bytes: 736_560, - sha256: "abe6bdbca0a0a2714c289b012db1f2df6cc7cd943e7ee2a65d48de003010c4b9", - manifestSha256: "55903b3136157e0d136cfe3dd3368e5c3886257ba500f0c8ecf10bab7d58fa05", - minimumManagerVersion: "0.2.1", + bytes: 763_027, + sha256: "32437f3c59579c080567a5325f08d16da1e776187db59aa89337bdcb51ee90b0", + manifestSha256: "a6a3cfa25345c6df8a5d1756fb8ee830e704ffb7d6f63d1df3adc325527a4d98", + minimumManagerVersion: "0.2.2", }], ]); @@ -68,7 +73,7 @@ test("one distribution build produces three verified qualified Mac packages and assert.equal(verified.ok, true); assert.equal(verified.manifest.adapterId, packageRecord.adapterId); assert.equal(verified.manifest.assetIdentity, expectedIdentities.get(packageRecord.adapterId)); - assert.equal(verified.manifest.adapterReleaseRevision, 2); + assert.equal(verified.manifest.adapterReleaseRevision, expectedRevisions.get(packageRecord.adapterId)); assert.equal(verified.manifest.contracts.minimumManagerVersion, published.minimumManagerVersion); const extracted = path.join(outputDirectory, `extracted-${packageRecord.adapterId}`); await fs.mkdir(extracted); @@ -90,7 +95,7 @@ test("one distribution build produces three verified qualified Mac packages and assert.equal(adapter.releases[0].packages[0].downloadUrl, undefined); } assert.equal(result.catalog.adapters[0].current.adapterVersion, "26.715.71837"); - assert.equal(result.catalog.adapters[0].releases[0].contracts.minimumManagerVersion, "0.2.1"); + assert.equal(result.catalog.adapters[0].releases[0].contracts.minimumManagerVersion, "0.2.2"); await assert.rejects( buildAdapterDistribution({ outputDirectory, architecture: "arm64" }), diff --git a/app/tests/adapter-package.test.mjs b/app/tests/adapter-package.test.mjs index 36838a4..87f1893 100644 --- a/app/tests/adapter-package.test.mjs +++ b/app/tests/adapter-package.test.mjs @@ -19,15 +19,15 @@ const adapters = [ { adapterId: "mac-doubao", adapterVersion: "2.19.9", - adapterReleaseRevision: 2, - assetIdentity: "mac-doubao-2.19.9-r2-macos-arm64", + adapterReleaseRevision: 4, + assetIdentity: "mac-doubao-2.19.9-r4-macos-arm64", capabilityVersion: "1.3.0", }, { adapterId: "mac-workbuddy", adapterVersion: "5.2.6", - adapterReleaseRevision: 2, - assetIdentity: "mac-workbuddy-5.2.6-r2-macos-arm64", + adapterReleaseRevision: 4, + assetIdentity: "mac-workbuddy-5.2.6-r4-macos-arm64", capabilityVersion: "1.0.0", }, ]; diff --git a/app/tests/adapter-version-catalog.test.mjs b/app/tests/adapter-version-catalog.test.mjs index 352ae21..df5961d 100644 --- a/app/tests/adapter-version-catalog.test.mjs +++ b/app/tests/adapter-version-catalog.test.mjs @@ -192,11 +192,11 @@ test("catalog Schema closes every public object and keeps identity, OS, architec test("local generation publishes the qualified CodeX source and rejects a separate experimental fixture", async () => { const tracked = JSON.parse(await readFile(registryPath, "utf8")); assert.equal(tracked.adapters[0].current.adapterVersion, "26.715.71837"); - assert.equal(tracked.adapters[0].releases[0].contracts.minimumManagerVersion, "0.2.1"); + assert.equal(tracked.adapters[0].releases[0].contracts.minimumManagerVersion, "0.2.2"); const generated = await generateAdapterVersionCatalog({ workspaceRoot }); assert.equal(generated.adapters[0].current.adapterVersion, "26.715.71837"); - assert.equal(generated.adapters[0].releases[0].contracts.minimumManagerVersion, "0.2.1"); + assert.equal(generated.adapters[0].releases[0].contracts.minimumManagerVersion, "0.2.2"); await assert.doesNotReject( runCli(["--workspace", workspaceRoot, "--output", registryPath, "--check"]), ); diff --git a/app/tests/family-compiler.test.mjs b/app/tests/family-compiler.test.mjs index e6dfc4e..7c543be 100644 --- a/app/tests/family-compiler.test.mjs +++ b/app/tests/family-compiler.test.mjs @@ -38,6 +38,26 @@ test("the unified sample matches Adapter-owned target golden artifacts", async ( } }); +test("a high-fidelity family carries closed light and dark palettes through every registered Adapter", async () => { + const source = JSON.parse(await readFile( + path.join(managerRoot, "..", "themes", "example", "unified-theme.json"), + "utf8", + )); + const result = await compileThemeFamily(source, context); + + for (const adapterId of source.targets) { + const variants = result.themes[adapterId].appearanceVariants; + assert.ok(variants, `${adapterId} should retain high-fidelity appearance variants`); + assert.equal(variants.light.colors.text, "#203041", `${adapterId}: light text`); + assert.equal(variants.dark.colors.text, "#E9EEF7", `${adapterId}: dark text`); + assert.equal(variants.light.semanticColors.surfaceBase, "#F4F7FA", `${adapterId}: light canvas`); + assert.equal(variants.dark.semanticColors.surfaceBase, "#10151F", `${adapterId}: dark canvas`); + } + // Each projector normalizes before returning its compiled target theme. The + // compile result additionally carries its bounded runtime bookkeeping, so + // it must not be fed back into a raw skin-theme normalizer here. +}); + test("Manager uses one neutral registered projector invocation without host request builders", async () => { const compiler = await readFile(path.join(runtimeRoot, "theme-core/compiler.mjs"), "utf8"); for (const forbidden of ["REQUEST_BUILDERS", "buildCodexInvocation", "buildLegacyInvocation"]) { @@ -93,7 +113,7 @@ test("Doubao projects structural consumers while keeping native controls and rip Object.assign(source.sharedCore.tokens.colors, { surfaceCode: "#111111", textStrong: "#FFFFFF", - actionHover: "#777777", + actionHover: "#767676", actionPressed: "#555555", success: "#00AA00", warning: "#AA8800", diff --git a/app/tests/transition-runtime.test.mjs b/app/tests/transition-runtime.test.mjs index 2623f60..b626881 100644 --- a/app/tests/transition-runtime.test.mjs +++ b/app/tests/transition-runtime.test.mjs @@ -61,6 +61,27 @@ test("transition baseline carries the retired bundled CodeX identity without con assert.doesNotMatch(source, /find\(\(\{ assetIdentity \}\) => assetIdentity === descriptor\.assetIdentity\)/); }); +test("release baseline binds the current three Adapter identities to immutable Release assets", async () => { + const [baseline, registry, source] = await Promise.all([ + fs.readFile(path.join(managerRoot, "config", "release-baseline.json"), "utf8").then(JSON.parse), + fs.readFile(path.join(managerRoot, "registry", "adapter-versions.json"), "utf8").then(JSON.parse), + fs.readFile(path.join(managerRoot, "scripts", "prepare-runtime-resources.mjs"), "utf8"), + ]); + assert.equal(baseline.managerVersion, "0.2.2"); + assert.equal(baseline.profile, "release-bundled-latest"); + assert.equal(source.includes('"release-bundled-latest": "release-baseline.json"'), true); + assert.deepEqual( + baseline.adapters.map(({ assetIdentity }) => assetIdentity), + registry.adapters.map(({ releases }) => releases[0].assetIdentity), + ); + for (const adapter of baseline.adapters) { + assert.equal(adapter.source.releaseTag, "cc-theme-v0.2.2-adapters.1"); + assert.match(adapter.source.downloadUrl, new RegExp(`/${adapter.assetIdentity.replaceAll(".", "\\.")}\\.ccadapter$`)); + assert.match(adapter.source.archiveSha256, /^[0-9a-f]{64}$/); + assert.match(adapter.source.manifestSha256, /^[0-9a-f]{64}$/); + } +}); + test("a corrupt transition cache is replaced, while a verified cache is reused without downloading", async (t) => { const { root, built, descriptor } = await fixture(t); const cache = path.join(root, "cache"); @@ -72,10 +93,10 @@ test("a corrupt transition cache is replaced, while a verified cache is reused w downloads += 1; await fs.copyFile(built.archivePath, destination); }; - const first = await downloadTransitionPackage(cache, descriptor, "0.2.1", { download }); + const first = await downloadTransitionPackage(cache, descriptor, "0.2.2", { download }); assert.equal(first.verified.sha256, built.sha256); assert.equal(downloads, 1); - const second = await downloadTransitionPackage(cache, descriptor, "0.2.1", { + const second = await downloadTransitionPackage(cache, descriptor, "0.2.2", { download: async () => assert.fail("a verified cache must not redownload"), }); assert.equal(second.verified.manifestSha256, built.manifestSha256); @@ -88,14 +109,14 @@ test("transition download fails closed on archive and top-level manifest digest downloadTransitionPackage(path.join(root, "archive-drift"), { ...descriptor, source: { ...descriptor.source, archiveSha256: "0".repeat(64) }, - }, "0.2.1", { download }), + }, "0.2.2", { download }), /downloaded archive SHA-256 differs/, ); await assert.rejects( downloadTransitionPackage(path.join(root, "manifest-drift"), { ...descriptor, source: { ...descriptor.source, manifestSha256: "0".repeat(64) }, - }, "0.2.1", { download }), + }, "0.2.2", { download }), /verified package provenance differs/, ); }); diff --git a/app/tests/workspace-layout.test.mjs b/app/tests/workspace-layout.test.mjs index 4fbbcb2..3d71786 100644 --- a/app/tests/workspace-layout.test.mjs +++ b/app/tests/workspace-layout.test.mjs @@ -23,8 +23,8 @@ test("staged runtime uses only registered macOS Adapter Engines and stable packa assert.ok(manifest.entries.some(({ path: file }) => file === "theme-core/compiler.mjs")); assert.ok(manifest.entries.some(({ path: file }) => file === "adapter-sdk/adapter-registry.mjs")); assert.equal(manifest.schemaVersion, 2); - assert.equal(manifest.managerVersion, "0.2.1"); - assert.ok(["source-build", "transition-baseline"].includes(manifest.profile)); + assert.equal(manifest.managerVersion, "0.2.2"); + assert.ok(["source-build", "transition-baseline", "release-bundled-latest"].includes(manifest.profile)); assert.ok(manifest.entries.some(({ path: file }) => file === "runtime-attestation.json")); assert.ok(manifest.entries.some(({ path: file }) => file === "adapters/mac-workbuddy/scripts/workbuddy-theme-projection.mjs")); assert.ok(manifest.entries.some(({ path: file }) => file === "adapters/mac-doubao/scripts/adapter-capability.mjs")); @@ -117,6 +117,10 @@ test("formal transition builds keep the transition profile across the Tauri befo packageDocument.scripts["tauri:build:transition"], "npm run prepare:runtime && CC_THEME_RUNTIME_PROFILE=transition-baseline tauri build", ); + assert.equal( + packageDocument.scripts["tauri:build:release"], + "npm run prepare:runtime && CC_THEME_RUNTIME_PROFILE=release-bundled-latest tauri build", + ); assert.match(packageDocument.scripts["tauri:build"], /tauri build/); assert.match(config.build.beforeBuildCommand, /npm run prepare:resources/); assert.match(packageDocument.scripts["prepare:resources"], /prepare-runtime-resources\.mjs/); diff --git a/package-lock.json b/package-lock.json index 9ca6dc5..2d5fb33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-theme-workspace", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-theme-workspace", - "version": "0.2.1", + "version": "0.2.2", "workspaces": [ "app", "adapters/mac-codex", @@ -43,7 +43,7 @@ }, "app": { "name": "cc-theme", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/package.json b/package.json index b7666da..6cf7b94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-theme-workspace", - "version": "0.2.1", + "version": "0.2.2", "private": true, "type": "module", "workspaces": [ diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index 001b214..9719ea0 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -26,7 +26,7 @@ test("application release versions remain aligned", async () => { const cargo = await read("app/src-tauri/Cargo.toml"); const registry = JSON.parse(await read("app/registry/adapter-versions.json")); - assert.equal(workspace.version, "0.2.1"); + assert.equal(workspace.version, "0.2.2"); assert.equal(manager.version, workspace.version); assert.equal(tauri.version, workspace.version); assert.match(cargo, new RegExp(`^version = "${workspace.version.replaceAll(".", "\\.")}"$`, "m")); @@ -34,6 +34,10 @@ test("application release versions remain aligned", async () => { manager.scripts["tauri:build:transition"], "npm run prepare:runtime && CC_THEME_RUNTIME_PROFILE=transition-baseline tauri build", ); + assert.equal( + manager.scripts["tauri:build:release"], + "npm run prepare:runtime && CC_THEME_RUNTIME_PROFILE=release-bundled-latest tauri build", + ); for (const adapter of registry.adapters) { for (const release of adapter.releases) { assert.ok( @@ -67,9 +71,9 @@ test("formal Release workflow is manual, tag-pinned, non-overwriting, and exact" assert.match(workflow, /codesign --verify --deep --strict/); assert.match(workflow, /TeamIdentifier/); assert.match(workflow, /verify-runtime-resources\.mjs/); - assert.match(workflow, /transition-baseline\.json/); + assert.match(workflow, /release-baseline\.json/); assert.match(workflow, /shasum -a 256 "\$RESOURCES\/artifact-manifest\.json"/); - assert.match(workflow, /CodeX 26\.715\.31925-r1; Doubao 2\.19\.9-r1; WorkBuddy 5\.2\.6-r1/); + assert.match(workflow, /release-bundled-latest/); assert.match(workflow, /wc -l < "\$FINAL_FILE"/); assert.doesNotMatch(workflow, /--clobber/); assert.doesNotMatch(workflow, /\bmapfile\b/); diff --git a/themes/README.md b/themes/README.md index 71bc7d7..b804f4f 100644 --- a/themes/README.md +++ b/themes/README.md @@ -23,6 +23,15 @@ assets/ included in a theme package. The public theme resource format is the single first version (`schemaVersion: 1`). +Every new public theme must declare `sharedCore.appearanceVariants` as a +closed `light` and `dark` pair. Each side supplies the complete semantic color +set; the Manager validates both palettes independently and the Adapter chooses +only the palette matching the host's effective appearance. The base palette +remains a safe fallback for an older installed package, but it is not a +replacement for a public theme's two deliberate art directions. This field is +declarative color data only — it cannot carry CSS, selectors, scripts, +geometry, or host paths. + Build a local theme package: ```sh diff --git a/themes/example/README.md b/themes/example/README.md index 6e02fb6..1cfaa5c 100644 --- a/themes/example/README.md +++ b/themes/example/README.md @@ -19,3 +19,10 @@ npm run theme:pack -- themes/your-theme The command validates the theme, refreshes `family.json` byte counts and SHA-256 values, and writes `themes/dist/-.cctheme`. The output is a deterministic ZIP-compatible archive with the `.cctheme` extension. + +The example already includes the required public-theme pair: +`sharedCore.appearanceVariants.light` and `.dark`. Both contain a complete +semantic `colors` palette and are selected by the host appearance; they are +not per-client CSS overrides. Keep the normal Shared Core palette as the safe +fallback for older installed packages, but do not remove either variant from a +new public theme. diff --git a/themes/example/family.json b/themes/example/family.json index 63e093d..d9ce9b0 100644 --- a/themes/example/family.json +++ b/themes/example/family.json @@ -23,8 +23,8 @@ }, "source": { "path": "unified-theme.json", - "bytes": 1768, - "sha256": "91422fb9c64002a0d04e08b5fbb5a1d9dc94725b51223b72da4669f7055870a8" + "bytes": 4382, + "sha256": "a7476d3e4c67366312df167cc828a6f6865e6219e1a4e5da1c1d2f907be75496" }, "assets": [ { diff --git a/themes/example/unified-theme.json b/themes/example/unified-theme.json index 879a9a9..39e36e1 100644 --- a/themes/example/unified-theme.json +++ b/themes/example/unified-theme.json @@ -17,7 +17,7 @@ "borderSubtle": "#263244", "borderDefault": "#34445B", "action": "#2F7D57", - "actionHover": "#398F66", + "actionHover": "#2B7551", "actionPressed": "#286A4A", "actionForeground": "#FFFFFF", "hoverSurface": "#202B3B", @@ -33,12 +33,21 @@ "composerSurface": "#1A2432" }, "fonts": { - "ui": ["SF Pro Text", "system-ui"], - "display": ["SF Pro Display", "system-ui"], - "code": ["SFMono-Regular", "monospace"] + "ui": [ + "SF Pro Text", + "system-ui" + ], + "display": [ + "SF Pro Display", + "system-ui" + ], + "code": [ + "SFMono-Regular", + "monospace" + ] }, "appearance": { - "shellMode": "dark", + "shellMode": "auto", "backdropBlurPx": 16, "backdropSaturation": 1, "radiusScale": 1 @@ -58,7 +67,81 @@ "minimumLargeTextContrast": 3, "preserveSystemFocusRing": true, "transparencyFallback": "increased-scrim" + }, + "appearanceVariants": { + "light": { + "colors": { + "surfaceBase": "#F4F7FA", + "surfaceRaised": "#FAFCFE", + "surfaceElevated": "#FFFFFF", + "surfaceCode": "#EAF0F5", + "text": "#203041", + "textStrong": "#15110F", + "textMuted": "#5D7285", + "placeholder": "#7E91A1", + "borderSubtle": "rgba(38, 100, 70, .18)", + "borderDefault": "rgba(38, 100, 70, .32)", + "borderStrong": "#266446", + "action": "#266446", + "actionHover": "#20543B", + "actionPressed": "#193F2D", + "actionForeground": "#FFFFFF", + "hoverSurface": "rgba(38, 100, 70, .11)", + "pressedSurface": "rgba(38, 100, 70, .19)", + "selectedSurface": "rgba(38, 100, 70, .17)", + "selectedHoverSurface": "rgba(38, 100, 70, .27)", + "focusRing": "#266446", + "link": "#1E5B96", + "danger": "#B33F43", + "success": "#2E704E", + "warning": "#8B5C0A", + "sidebarSurface": "#E8EEF4", + "headerSurface": "#FBFDFF", + "mainScrimStart": "rgba(244, 247, 250, .95)", + "mainScrimMid": "rgba(244, 247, 250, .62)", + "mainScrimEnd": "rgba(244, 247, 250, .26)", + "composerSurface": "#FFFFFF" + } + }, + "dark": { + "colors": { + "surfaceBase": "#10151F", + "surfaceRaised": "#18202D", + "surfaceElevated": "#202B3B", + "text": "#E9EEF7", + "textStrong": "#FFFFFF", + "textMuted": "#AAB5C5", + "placeholder": "#8390A3", + "borderSubtle": "#263244", + "borderDefault": "#34445B", + "action": "#2F7D57", + "actionHover": "#2B7551", + "actionPressed": "#286A4A", + "actionForeground": "#FFFFFF", + "hoverSurface": "#202B3B", + "pressedSurface": "#273548", + "selectedSurface": "#294C40", + "focusRing": "#7BCFA8", + "link": "#8EB1FF", + "danger": "#EF7D7D", + "success": "#72C79B", + "warning": "#F0B96B", + "sidebarSurface": "#121925", + "headerSurface": "#141C28", + "composerSurface": "#1A2432", + "surfaceCode": "#18202D", + "borderStrong": "#34445B", + "selectedHoverSurface": "#294C40", + "mainScrimStart": "rgba(16, 21, 31, .86)", + "mainScrimMid": "rgba(16, 21, 31, .54)", + "mainScrimEnd": "rgba(16, 21, 31, .22)" + } + } } }, - "targets": ["mac-codex", "mac-doubao", "mac-workbuddy"] + "targets": [ + "mac-codex", + "mac-doubao", + "mac-workbuddy" + ] }