/** * useToolDefaults — remembered per-tool settings. * * Signature-identical to `useFormPersist`, so adopting it in a tool is a * one-line change. It adds one thing: registering the slug in the localStore * index so `/my-stuff` can show "6 tools customized" and clear them. * * Values stay in synchronous localStorage on purpose. `useFormPersist` * hydrates in a `useRef` initializer specifically so a tool never renders its * defaults and then flickers into the restored values; IndexedDB is async-only * and would reintroduce that flicker on every tool page. Only the index — which * nothing renders during first paint — is async. See the storage-split note in * docs/superpowers/specs/2026-08-21-local-first-personalization-design.md. * * The `slug` MUST be the tool's registry slug from `data/tools.ts`, so * `/my-stuff` can map a stored entry back to a tool name. A typo produces an * orphan entry that maps to nothing; `tool-defaults-slugs.test.ts` guards this. */ import { useCallback, useEffect, useRef } from "react"; import { useFormPersist, type UseFormPersistResult } from "./useFormPersist"; import { registerCustomized, unregisterCustomized } from "@/lib/localStore/defaults"; import { clearPersisted, loadPersisted, savePersisted, shallowEqual } from "@/lib/persistence"; export interface ToolDefaultsApi extends UseFormPersistResult { /** * Update one field, by value or with an updater function. * * Tools were written against `useState` setters, and several call the * updater form (`setSizes(prev => [...prev, 48])`, `setFlag(v => !v)`). A * per-field setter that only accepted a value silently broke those call * sites — and the repo's `tsc --noEmit` does not catch it, because the root * tsconfig has `files: []` and only project references, so it compiles * nothing without `--build`. Use `--project tsconfig.app.json`. */ setField: (key: K, value: React.SetStateAction) => void; } export interface ToolDefaultsOptions { /** * Key this tool used before it moved to its registry slug. Migrated once, on * first render, then removed. * * The tools that already used `useFormPersist` keyed on short names * ("bates", "compress") rather than registry slugs. Renaming the key without * migrating would silently discard every existing user's saved settings. */ legacyKey?: string; } export function useToolDefaults>( slug: string, defaults: T, options: ToolDefaultsOptions = {}, ): [T, React.Dispatch>, ToolDefaultsApi] { // Runs BEFORE useFormPersist reads, so the migrated value is picked up on the // very first render and the user never sees a flash of defaults. const { legacyKey } = options; const migrated = useRef(false); if (!migrated.current) { migrated.current = true; if (legacyKey && legacyKey !== slug) { const legacy = loadPersisted(legacyKey); // An existing value under the new key wins — it is more recent by // definition, since the legacy key is only ever written by old code. if (legacy !== null && loadPersisted(slug) === null) { savePersisted(slug, legacy); } if (legacy !== null) clearPersisted(legacyKey); } } const [state, setState, api] = useFormPersist(slug, defaults); // Mirror the persistence layer's own rule: a value equal to defaults isn't // "customized", so it shouldn't appear in /my-stuff either. Tracking the last // registered state avoids an IndexedDB write on every keystroke. const lastRegistered = useRef(null); useEffect(() => { const customized = !shallowEqual( state as Record, defaults as Record, ); if (lastRegistered.current === customized) return; lastRegistered.current = customized; void (customized ? registerCustomized(slug) : unregisterCustomized(slug)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [state, slug]); const setField = useCallback( (key: K, value: React.SetStateAction) => { setState((current) => ({ ...current, [key]: typeof value === "function" ? (value as (prev: T[K]) => T[K])(current[key]) : value, })); }, [setState], ); const reset = useCallback(() => { api.reset(); lastRegistered.current = false; void unregisterCustomized(slug); }, [api, slug]); return [state, setState, { ...api, reset, setField }]; }