diff --git a/src/components/settings-appearance.test.ts b/src/components/settings-appearance.test.ts index f009aca92..17c96370d 100644 --- a/src/components/settings-appearance.test.ts +++ b/src/components/settings-appearance.test.ts @@ -432,6 +432,23 @@ assert.match( /onClick=\{\(\) => void handleResync\(\)\}/, "the Resync button triggers a manual theme push", ); +// ── Single token publisher (cave-gvtw) ─────────────────────────────────────── +// RemoteThemeController owns change-driven token publishing: every selection, +// mode flip, and token commit in this section goes through updateAppPreferences, +// and the controller's reconcile publishes on the resulting signature change. +// The section's own on-change effect doubled every PUT /api/theme (identical +// payloads, 409 window when a second device wrote between them). Settings may +// only PUT via the user-invoked Resync button. +assert.doesNotMatch( + settings, + /void persistThemeTokens\(\)/, + "no automatic persistThemeTokens call may remain — RemoteThemeController is the sole on-change publisher (cave-gvtw double PUT)", +); +assert.match( + settings, + /Manual Resync only\. Change-driven publishing belongs to RemoteThemeController/, + "persistThemeTokens documents its manual-only contract so the on-change effect doesn't get reintroduced", +); assert.match(settings, /function ThemeTokenOverrides\(/, "a per-token override panel exists"); assert.match( settings, @@ -591,7 +608,7 @@ assert.match( assert.match( settings, /setCustomData\(\(prev\) => \{[\s\S]{0,400}JSON\.stringify\(prev\) === JSON\.stringify\(next\)[\s\S]{0,100}return prev;/, - "custom-theme state keeps its object identity when content is unchanged — store notifies must not retrigger the persist effect (echo PUTs)", + "custom-theme state keeps its object identity when content is unchanged — unrelated store notifies must not churn renders or the content-keyed reloadKey", ); assert.match( settings, diff --git a/src/components/settings-shell.tsx b/src/components/settings-shell.tsx index 3d4975038..82647c8a2 100644 --- a/src/components/settings-shell.tsx +++ b/src/components/settings-shell.tsx @@ -1702,8 +1702,10 @@ function resolveSyncTokens(): Record { } /** Push the active theme + resolved tokens to the daemon for cross-device sync. - * Returns whether the write reached the daemon (the manual Resync button shows - * the result; the automatic on-change call ignores it). */ + * Manual Resync only. Change-driven publishing belongs to RemoteThemeController: + * every selection/edit here lands in updateAppPreferences, whose store notify + * runs the controller's reconcile → publish. A second on-change PUT from this + * section raced it — identical payloads plus a 409 window (cave-gvtw). */ async function persistThemeTokens(): Promise { if (typeof window === "undefined") return false; if (!(await flushAppPreferences())) return false; @@ -2289,12 +2291,11 @@ function AppearanceSection({ scrollTarget }: { scrollTarget?: string | null }) { // there, distinct from the avatar-strip pin order set here). const { setActiveTab: setStudioTab } = useFamiliarStudio(); - // Mirror the active theme + resolved tokens to the daemon on change (and mount) - // so cross-device clients can read it. Best-effort; failures are swallowed. - useEffect(() => { - if (!appearanceHydrated) return; - void persistThemeTokens(); - }, [activeTheme, mode, customData, appearanceHydrated]); + // No on-change daemon mirror here: RemoteThemeController publishes tokens + // whenever the canonical theme signature changes (every selection, mode flip, + // and token commit in this section writes updateAppPreferences). A section- + // side effect doubled each PUT /api/theme (cave-gvtw); persistThemeTokens + // remains for the manual Resync button only. const [importUrl, setImportUrl] = useState(""); const [importing, setImporting] = useState(false); const [importError, setImportError] = useState(null); @@ -2341,8 +2342,8 @@ function AppearanceSection({ scrollTarget }: { scrollTarget?: string | null }) { // repaint the app, but this section hydrated its selection state once on // mount, leaving the theme grid and token-row swatches stale until a full // reload (cave-hkfq). Follow the store. setCustomData keeps the previous - // object when content is unchanged so the persist effect (which watches - // customData) doesn't re-emit a PUT for every unrelated store notify. + // object when content is unchanged so unrelated store notifies don't churn + // renders or the token rows' content-keyed reloadKey. useEffect(() => { if (!appearanceHydrated) return; return subscribeAppPreferences(() => {