From 1bc37d41a182b89215cdb8e24a3bb38df18e8cdb Mon Sep 17 00:00:00 2001 From: felix Date: Wed, 22 Jul 2026 16:31:40 +0800 Subject: [PATCH 1/5] feat(app): add manual adapter update checks --- app/src/App.test.tsx | 92 +++++++++++++++++++++++++++++++++++++++++++- app/src/App.tsx | 56 +++++++++++++++++++-------- app/src/i18n.ts | 4 ++ app/src/styles.css | 5 ++- 4 files changed, 140 insertions(+), 17 deletions(-) diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 72a6e0f..832f59d 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -856,6 +856,95 @@ describe("CC Theme desktop dashboard", () => { expect(await within(card).findByRole("button", { name: "下载 Doubao Adapter 更新" })).toBeEnabled(); }); + it("允许用户主动绕过缓存检查 Adapter 更新,并在发现新版后显示下载入口", async () => { + const dashboard = cloneDemoDashboard(); + const checkAdapterUpdates = vi.fn() + .mockResolvedValueOnce({ + status: "success" as const, + code: "adapter-catalog-ready", + message: "已从缓存读取 Adapter 清单", + details: { + sequence: 2, + source: "cache" as const, + checkedAt: "2026-07-21T10:00:00Z", + adapters: [{ + adapterId: "mac-codex", + status: "current" as const, + latestVersion: "26.715.61943", + latestReleaseRevision: 1, + source: "cache" as const, + message: "缓存清单中已是最新版", + }], + }, + }) + .mockResolvedValueOnce({ + status: "success" as const, + code: "adapter-catalog-ready", + message: "已刷新官方签名清单", + details: { + sequence: 3, + source: "network" as const, + checkedAt: "2026-07-22T06:00:00Z", + adapters: [{ + adapterId: "mac-codex", + status: "update-available" as const, + latestVersion: "26.715.71837", + latestReleaseRevision: 1, + source: "network" as const, + message: "发现官方 Adapter 更新", + }], + }, + }); + const api = makeApi({ + getDashboardState: vi.fn(() => Promise.resolve(dashboard)), + checkAdapterUpdates, + }); + const user = userEvent.setup(); + + render(); + + const card = await screen.findByTestId("client-mac-codex"); + await waitFor(() => expect(checkAdapterUpdates).toHaveBeenCalledWith(false)); + expect(within(card).getByText("已是最新版")).toBeInTheDocument(); + + await user.click(within(card).getByRole("button", { name: "检查 Codex Adapter 更新" })); + + await waitFor(() => expect(checkAdapterUpdates).toHaveBeenLastCalledWith(true)); + expect(await within(card).findByRole("button", { name: "下载 Codex Adapter 更新" })).toBeEnabled(); + }); + + it("主动检查时只在所选客户端显示进度,并保持现有状态和操作布局", async () => { + const check = deferred>>(); + const initial = makeApi().checkAdapterUpdates(); + const checkAdapterUpdates = vi.fn() + .mockImplementationOnce(() => initial) + .mockImplementationOnce(() => check.promise); + const api = makeApi({ checkAdapterUpdates }); + const user = userEvent.setup(); + + render(); + + const codex = await screen.findByTestId("client-mac-codex"); + const doubao = screen.getByTestId("client-mac-doubao"); + const workbuddy = screen.getByTestId("client-mac-workbuddy"); + await waitFor(() => expect(checkAdapterUpdates).toHaveBeenCalledWith(false)); + await within(codex).findByText("已是最新版"); + + const checkButton = within(codex).getByRole("button", { name: "检查 Codex Adapter 更新" }); + await user.click(checkButton); + + await waitFor(() => expect(checkAdapterUpdates).toHaveBeenLastCalledWith(true)); + expect(within(codex).getByText("已是最新版")).toBeInTheDocument(); + expect(checkButton).toHaveTextContent("检查更新"); + expect(checkButton).toHaveAttribute("aria-busy", "true"); + expect(checkButton.querySelector(".spinner")).toBeInTheDocument(); + expect(doubao.querySelector(".spinner")).not.toBeInTheDocument(); + expect(workbuddy.querySelector(".spinner")).not.toBeInTheDocument(); + + check.resolve(await makeApi().checkAdapterUpdates()); + await waitFor(() => expect(checkButton).not.toHaveAttribute("aria-busy")); + }); + it("已就绪的 Adapter 展示版本与来源,并允许导入本地更新", async () => { const dashboard = cloneDemoDashboard(); const api = makeApi({ getDashboardState: vi.fn(() => Promise.resolve(dashboard)) }); @@ -868,7 +957,8 @@ describe("CC Theme desktop dashboard", () => { const card = await screen.findByTestId("client-mac-workbuddy"); expect(card).toHaveTextContent("5.2.6 · r1"); expect(card).toHaveTextContent("应用内置"); - expect(within(card).getByRole("button", { name: "已是最新版" })).toBeDisabled(); + expect(within(card).getByText("已是最新版")).toBeInTheDocument(); + expect(within(card).getByRole("button", { name: "检查 WorkBuddy Adapter 更新" })).toBeEnabled(); await user.click(within(card).getByRole("button", { name: "为 WorkBuddy 导入本地 Adapter 包" })); await waitFor(() => expect(api.installLocalAdapter).toHaveBeenCalledWith( "mac-workbuddy", diff --git a/app/src/App.tsx b/app/src/App.tsx index 5109c73..dbc0ed9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -203,7 +203,9 @@ function ClientCard({ onAction, installingAdapter, checkingAdapterCatalog, + checkingAdapter, adapterUpdate, + onCheckAdapterUpdates, onDownloadAdapter, onImportAdapter, }: { @@ -219,7 +221,9 @@ function ClientCard({ onAction: (client: ClientState, operation: Extract) => void; installingAdapter: boolean; checkingAdapterCatalog: boolean; + checkingAdapter: boolean; adapterUpdate?: AdapterUpdateState; + onCheckAdapterUpdates: () => void; onDownloadAdapter: (client: ClientState) => void; onImportAdapter: (client: ClientState) => void; }) { @@ -301,24 +305,40 @@ function ClientCard({

{t("adapter.activeNextUse")}

)}
- {client.adapterReady && ( + {client.adapterReady && checkingAdapterCatalog && !adapterUpdate && ( + + )} + {client.adapterReady && onlineCurrent && ( + + {t("adapter.current")} + + )} + {client.adapterReady && onlineUpdateAvailable && ( + )} + {!onlineUpdateAvailable && ( + )} + @@ -443,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); @@ -458,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) => { @@ -755,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); } @@ -1011,6 +1033,8 @@ export default function App({ api = desktopApi, windowApi = managerWindowApi, pa
)} + {workbenchTheme && setWorkbenchTheme(null)} onSaveDraft={saveWorkbenchDraft} onRestoreOriginal={restoreWorkbenchDraft} />} +
@@ -1032,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 7dcd646..ab48485 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -363,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..1c2884b 100644 --- a/app/tests/adapter-distribution.test.mjs +++ b/app/tests/adapter-distribution.test.mjs @@ -12,27 +12,32 @@ 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", + bytes: 1_098_823, + sha256: "e9e473059cf15847d59101d2e20b633f20406523180e90d42282e6a3f95b34d0", + manifestSha256: "b78a6bc7c0db07eb3b060e0dd508441c799a203bfe9ed84c025ad1f02f9692c1", minimumManagerVersion: "0.2.1", }], ["mac-doubao", { - bytes: 161_537, - sha256: "9fd79e213e2627333bb111c5e087d1cab259a953424af27729be88a112b9d277", - manifestSha256: "76aa7d37fae88a243ec44762b9140d11c457dc9d9ee2d62bed997b0166df4895", + bytes: 187_920, + sha256: "28a6cb24db0b3ec60583973b62163dca90a27cb37c87ec43d77b5c6ed486cda0", + manifestSha256: "fbb052ef3cfb99625a6586a09d715f87488d8acd027c23da631961bb127cd394", minimumManagerVersion: "0.2.1", }], ["mac-workbuddy", { - bytes: 736_560, - sha256: "abe6bdbca0a0a2714c289b012db1f2df6cc7cd943e7ee2a65d48de003010c4b9", - manifestSha256: "55903b3136157e0d136cfe3dd3368e5c3886257ba500f0c8ecf10bab7d58fa05", + bytes: 763_027, + sha256: "90961b9a921e0af4b88cbcbc8e904ab807869ef649c6fd44d9b51cdb4819396c", + manifestSha256: "e0a26b7a95ec0460d8f8aa241cc404689630118528d29111707899a9658ef586", minimumManagerVersion: "0.2.1", }], ]); @@ -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); 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/family-compiler.test.mjs b/app/tests/family-compiler.test.mjs index e6dfc4e..499bcdd 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", "gothic-void-crusade", "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, "#241C15", `${adapterId}: light text`); + assert.equal(variants.dark.colors.text, "#F3EAD7", `${adapterId}: dark text`); + assert.equal(variants.light.semanticColors.surfaceBase, "#F7F0E1", `${adapterId}: light canvas`); + assert.equal(variants.dark.semanticColors.surfaceBase, "#0D0D0E", `${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/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" + ] } From 98af5b4ecc047344f19c0b62b7207e2928aa955d Mon Sep 17 00:00:00 2001 From: felix Date: Thu, 23 Jul 2026 14:51:37 +0800 Subject: [PATCH 5/5] release: prepare 0.2.2 current Adapter baseline --- .github/workflows/release.yml | 10 ++-- app/config/release-baseline.json | 53 +++++++++++++++++++ app/config/transition-baseline.json | 2 +- app/package.json | 7 ++- app/registry/adapter-versions.json | 6 +-- app/scripts/prepare-runtime-resources.mjs | 61 ++++++++++++---------- app/scripts/verify-runtime-resources.mjs | 18 ++++--- app/src-tauri/Cargo.lock | 2 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- app/tests/adapter-distribution.test.mjs | 20 +++---- app/tests/adapter-version-catalog.test.mjs | 4 +- app/tests/family-compiler.test.mjs | 10 ++-- app/tests/transition-runtime.test.mjs | 29 ++++++++-- app/tests/workspace-layout.test.mjs | 8 ++- package-lock.json | 6 +-- package.json | 2 +- tests/release-workflow.test.mjs | 10 ++-- 18 files changed, 174 insertions(+), 78 deletions(-) create mode 100644 app/config/release-baseline.json 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/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/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/registry/adapter-versions.json b/app/registry/adapter-versions.json index 48afcce..8506004 100644 --- a/app/registry/adapter-versions.json +++ b/app/registry/adapter-versions.json @@ -16,7 +16,7 @@ "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 @@ -37,7 +37,7 @@ "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 @@ -58,7 +58,7 @@ "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/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/tests/adapter-distribution.test.mjs b/app/tests/adapter-distribution.test.mjs index 1c2884b..ae9db46 100644 --- a/app/tests/adapter-distribution.test.mjs +++ b/app/tests/adapter-distribution.test.mjs @@ -24,21 +24,21 @@ const expectedRevisions = new Map([ const expectedPublishedPackages = new Map([ ["mac-codex", { bytes: 1_098_823, - sha256: "e9e473059cf15847d59101d2e20b633f20406523180e90d42282e6a3f95b34d0", - manifestSha256: "b78a6bc7c0db07eb3b060e0dd508441c799a203bfe9ed84c025ad1f02f9692c1", - minimumManagerVersion: "0.2.1", + sha256: "6dd7938810b1b7f5f3ee7ff2b800f1b1df917ee0466e11eda8ab50bb7332cd34", + manifestSha256: "63c7e190e137dfcb1405e080a34c08fd62ecbf3c7a4b6ad408718e2cb6b2a1ea", + minimumManagerVersion: "0.2.2", }], ["mac-doubao", { bytes: 187_920, - sha256: "28a6cb24db0b3ec60583973b62163dca90a27cb37c87ec43d77b5c6ed486cda0", - manifestSha256: "fbb052ef3cfb99625a6586a09d715f87488d8acd027c23da631961bb127cd394", - minimumManagerVersion: "0.2.1", + sha256: "6eea72b489fedaef453b7e67b710706827b8e6be94b650a2e3dce67387613b8a", + manifestSha256: "a14ee11246c2e1c31e24970a4f2d75a37241a3c121cbdd971faba8e3caa3ed38", + minimumManagerVersion: "0.2.2", }], ["mac-workbuddy", { bytes: 763_027, - sha256: "90961b9a921e0af4b88cbcbc8e904ab807869ef649c6fd44d9b51cdb4819396c", - manifestSha256: "e0a26b7a95ec0460d8f8aa241cc404689630118528d29111707899a9658ef586", - minimumManagerVersion: "0.2.1", + sha256: "32437f3c59579c080567a5325f08d16da1e776187db59aa89337bdcb51ee90b0", + manifestSha256: "a6a3cfa25345c6df8a5d1756fb8ee830e704ffb7d6f63d1df3adc325527a4d98", + minimumManagerVersion: "0.2.2", }], ]); @@ -95,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-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 499bcdd..7c543be 100644 --- a/app/tests/family-compiler.test.mjs +++ b/app/tests/family-compiler.test.mjs @@ -40,7 +40,7 @@ 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", "gothic-void-crusade", "unified-theme.json"), + path.join(managerRoot, "..", "themes", "example", "unified-theme.json"), "utf8", )); const result = await compileThemeFamily(source, context); @@ -48,10 +48,10 @@ test("a high-fidelity family carries closed light and dark palettes through ever 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, "#241C15", `${adapterId}: light text`); - assert.equal(variants.dark.colors.text, "#F3EAD7", `${adapterId}: dark text`); - assert.equal(variants.light.semanticColors.surfaceBase, "#F7F0E1", `${adapterId}: light canvas`); - assert.equal(variants.dark.semanticColors.surfaceBase, "#0D0D0E", `${adapterId}: dark canvas`); + 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 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/);