diff --git a/backend/api/routers/profiles.py b/backend/api/routers/profiles.py
index a4483beb..80f497aa 100644
--- a/backend/api/routers/profiles.py
+++ b/backend/api/routers/profiles.py
@@ -73,6 +73,17 @@ async def create_profile(
raise ValueError("not an object")
except ValueError:
raise HTTPException(status_code=422, detail="vd_states must be a JSON object")
+ # Root-cause close for #983: a design profile must never be PERSISTED
+ # with a partial vd_states shape, regardless of which client (older
+ # frontend build, hand-edited payload, third-party API caller) created
+ # it — a missing category key crashes DesignMethodPanel's render on
+ # every future client that selects this profile. CATEGORY_ORDER is the
+ # same single source of truth the frontend's CATEGORIES keys mirror
+ # (core/describe_voice.py), so this can't drift from the picker.
+ from core.describe_voice import CATEGORY_ORDER
+ for _cat in CATEGORY_ORDER:
+ parsed.setdefault(_cat, "Auto")
+ vd_states = _json.dumps(parsed)
# An all-Auto design (every category left on "Auto") yields an empty
# instruct — that's still a valid, saveable voice: synthesis falls back
# to neutral instruct-only conditioning (see generation.py design path).
diff --git a/frontend/src/components/clone/DesignMethodPanel.jsx b/frontend/src/components/clone/DesignMethodPanel.jsx
index 57c37ba8..26a863b2 100644
--- a/frontend/src/components/clone/DesignMethodPanel.jsx
+++ b/frontend/src/components/clone/DesignMethodPanel.jsx
@@ -147,6 +147,13 @@ export default function DesignMethodPanel({
{Object.entries(CATEGORIES).map(([key, options]) => {
const many = options.length > 6;
const optLabel = (val) => {
+ // #983: a profile/localStorage-restored vdStates can carry a
+ // partial shape (missing category keys) — val is then undefined
+ // here even though the 'Auto' check above only catches the
+ // literal sentinel. Guard before .replace() rather than crash;
+ // 'Auto' matches how the rest of the component (the ternary
+ // above, the chip/select fallbacks) treats an unset category.
+ if (typeof val !== 'string' || !val) return 'Auto';
const tKey = `clone.opt_${val.replace(/[ -]/g, '_')}`;
const tl = t(tKey);
return tl !== tKey ? tl : val;
@@ -180,9 +187,13 @@ export default function DesignMethodPanel({
aria-label={t(`clone.cat_${key}`)}
>
{options.map((opt, i) => {
- const optTKey = `clone.opt_${opt.replace(/[ -]/g, '_')}`;
+ // `opt` is always a hardcoded CATEGORIES string today,
+ // never undefined — guarded anyway for consistency with
+ // the identical pattern above (#983).
+ const safeOpt = typeof opt === 'string' && opt ? opt : 'Auto';
+ const optTKey = `clone.opt_${safeOpt.replace(/[ -]/g, '_')}`;
const optTl = t(optTKey);
- const optLabel = optTl !== optTKey ? optTl : opt;
+ const optLabel = optTl !== optTKey ? optTl : safeOpt;
const checked = vdStates[key] === opt;
// Roving tabindex: the checked chip is the group's
// single tab stop (first chip if nothing matches).
diff --git a/frontend/src/components/clone/DesignMethodPanel.test.jsx b/frontend/src/components/clone/DesignMethodPanel.test.jsx
new file mode 100644
index 00000000..936e5e71
--- /dev/null
+++ b/frontend/src/components/clone/DesignMethodPanel.test.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DesignMethodPanel from './DesignMethodPanel';
+
+// #983: "Cannot read properties of undefined (reading 'replace')" — the
+// identity panel crashed whenever vdStates was missing one of the 6
+// CATEGORIES keys (a design profile saved by an older/foreign client, or a
+// stale localStorage shape). The label helper called `val.replace(...)` on
+// an undefined category value. This regression-tests the render guard added
+// to DesignMethodPanel.jsx directly, independent of the upstream data-shape
+// fixes in useProfiles.js / useAppData.js / profiles.py.
+
+// A minimal i18next-compatible mock: returns the defaultValue if given, else
+// echoes the key back (mirrors i18next's behavior for a missing translation,
+// which is what `optLabel`'s `tl !== tKey` check relies on).
+const t = (key, opts) => opts?.defaultValue ?? key;
+
+function setup(vdStates, props = {}) {
+ return render(
+ ,
+ );
+}
+
+describe('DesignMethodPanel — #983 partial vdStates crash', () => {
+ it('does not throw when vdStates is missing 5 of the 6 CATEGORIES keys', () => {
+ // Only Gender is set — Age, Pitch, Style, EnglishAccent, ChineseDialect
+ // are all undefined, exercising both the chip-based and