Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/components/settings-appearance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
);
Comment on lines +442 to +446
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,
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 11 additions & 10 deletions src/components/settings-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1702,8 +1702,10 @@ function resolveSyncTokens(): Record<string, string> {
}

/** 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<boolean> {
if (typeof window === "undefined") return false;
if (!(await flushAppPreferences())) return false;
Expand Down Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading