diff --git a/docs/superpowers/plans/2026-06-01-persist-cross-subject-plot-config.md b/docs/superpowers/plans/2026-06-01-persist-cross-subject-plot-config.md
new file mode 100644
index 0000000..6c750b2
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-01-persist-cross-subject-plot-config.md
@@ -0,0 +1,678 @@
+# Persist Cross-subject Plot Config Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Persist the "Cross-subject metrics" plot's display settings per experiment in the database, hydrating on load and auto-saving (debounced) on change.
+
+**Architecture:** Add a nullable `plot_config` JSONB column to `experiments` (Prisma). Expose `PUT /api/experiments/:id/plot-config` (validated, no audit log). On the frontend, pure helpers (`serializePlotConfig`/`readPlotConfig`) translate between React state and the stored blob; `ExperimentAnalysisCharts` initializes its state from the saved config and debounce-saves changes.
+
+**Tech Stack:** Postgres + Prisma 5, Express + express-validator, Jest + supertest (backend), React 18 + Recharts 2.15, Jest + React Testing Library (frontend).
+
+**Constraints:** Follow the existing `subject_info_template` route/migration pattern. No audit-log writes for plot-config. Do not change `SubjectAnalysisCharts`, the template/subject-template routes, or any existing behavior. Migration is additive and nullable — existing experiments load with defaults.
+
+---
+
+### Task 1: Backend — column, migration, route, validator, tests
+
+**Files:**
+- Modify: `backend/prisma/schema.prisma` (model `Experiment`)
+- Create: `backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql`
+- Modify: `backend/src/routes/experiments.js` (add validator near other validators; add route after the `PUT /:id/subject-template` handler, ~line 309)
+- Test: `backend/tests/experiments.test.js` (add a describe block)
+
+- [ ] **Step 1: Write the failing tests**
+
+Add this block to the end of `backend/tests/experiments.test.js` (it uses the file's existing `request`, `prisma` mock, `EXPERIMENT`, and `beforeEach(jest.clearAllMocks)`):
+
+```js
+describe('PUT /api/experiments/:id/plot-config', () => {
+ const CONFIG = { xField: '__days__', tickStep: 2, hiddenSubjects: ['a1', 'a2'], sidebarOpen: true };
+
+ it('saves a valid config and returns it without writing an audit log', async () => {
+ prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
+ prisma.experiment.update.mockResolvedValue({ ...EXPERIMENT, plot_config: CONFIG });
+ const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual(CONFIG);
+ expect(prisma.experiment.update).toHaveBeenCalledWith({
+ where: { id: EXPERIMENT.id },
+ data: { plot_config: CONFIG },
+ });
+ expect(prisma.auditLog.create).not.toHaveBeenCalled();
+ });
+
+ it('returns 404 when the experiment does not exist', async () => {
+ prisma.experiment.findUnique.mockResolvedValue(null);
+ const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
+ expect(res.status).toBe(404);
+ expect(prisma.experiment.update).not.toHaveBeenCalled();
+ });
+
+ it('rejects a non-object body with 422', async () => {
+ prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
+ const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send([1, 2, 3]);
+ expect(res.status).toBe(422);
+ expect(prisma.experiment.update).not.toHaveBeenCalled();
+ });
+
+ it('rejects an invalid hiddenSubjects with 422', async () => {
+ prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
+ const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send({ hiddenSubjects: [1, 2] });
+ expect(res.status).toBe(422);
+ expect(prisma.experiment.update).not.toHaveBeenCalled();
+ });
+
+ it('rejects an oversized config with 422', async () => {
+ prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
+ const big = { blob: 'x'.repeat(20000) };
+ const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(big);
+ expect(res.status).toBe(422);
+ expect(prisma.experiment.update).not.toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd backend && npx jest tests/experiments.test.js -t "plot-config"`
+Expected: FAIL — route returns 404/route not found (no handler yet), assertions fail.
+
+- [ ] **Step 3: Add the Prisma column**
+
+In `backend/prisma/schema.prisma`, in `model Experiment`, add a `plot_config` field (place it after `subject_info_template`):
+
+```prisma
+model Experiment {
+ id String @id @default(uuid())
+ title String
+ created_at DateTime @default(now())
+ template Json @default("[]")
+ subject_info_template Json @default("[]")
+ plot_config Json?
+ animals Animal[]
+
+ @@map("experiments")
+}
+```
+
+- [ ] **Step 4: Create the migration file**
+
+Create `backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql`:
+
+```sql
+-- AlterTable
+ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
+```
+
+- [ ] **Step 5: Regenerate the Prisma client**
+
+Run: `cd backend && npx prisma generate`
+Expected: "Generated Prisma Client" with no errors (no DB connection required).
+
+- [ ] **Step 6: Add the validator and route**
+
+In `backend/src/routes/experiments.js`, add this validator function alongside the other validators (e.g. right after `validateSubjectTemplate`, before the routes that use it):
+
+```js
+function validatePlotConfig(config) {
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
+ return 'plot_config must be an object';
+ }
+ let size;
+ try { size = JSON.stringify(config).length; } catch { return 'plot_config must be serializable'; }
+ if (size > 16384) return 'plot_config too large';
+ if ('hiddenSubjects' in config) {
+ const h = config.hiddenSubjects;
+ if (!Array.isArray(h) || h.length > 500 || !h.every((x) => typeof x === 'string')) {
+ return 'hiddenSubjects invalid';
+ }
+ }
+ return null;
+}
+```
+
+Then add this route immediately after the `PUT /:id/subject-template` handler (after its closing `});`, ~line 309):
+
+```js
+// PUT /api/experiments/:id/plot-config — persist UI display settings (no audit log)
+router.put('/:id/plot-config', async (req, res, next) => {
+ try {
+ const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } });
+ if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
+
+ const config = req.body;
+ const validationError = validatePlotConfig(config);
+ if (validationError) return res.status(422).json({ error: validationError });
+
+ await prisma.experiment.update({
+ where: { id: req.params.id },
+ data: { plot_config: config },
+ });
+ res.json(config);
+ } catch (err) { next(err); }
+});
+```
+
+- [ ] **Step 7: Run the tests to verify they pass**
+
+Run: `cd backend && npx jest tests/experiments.test.js -t "plot-config"`
+Expected: PASS — all 5 new tests green.
+
+- [ ] **Step 8: Run the full backend suite (no regressions)**
+
+Run: `cd backend && npm test`
+Expected: PASS — all suites green (note any pre-existing failures unrelated to this change).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add backend/prisma/schema.prisma backend/prisma/migrations/20260601000000_add_experiment_plot_config backend/src/routes/experiments.js backend/tests/experiments.test.js
+git commit -m "feat(backend): persist per-experiment plot_config via PUT /:id/plot-config"
+```
+
+---
+
+### Task 2: Frontend — serialize/read pure helpers
+
+**Files:**
+- Modify: `frontend/src/lib/crossSubjectChart.js` (append two functions + two private helpers)
+- Test: `frontend/tests/crossSubjectChart.test.js` (append describe blocks)
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `frontend/tests/crossSubjectChart.test.js`. First add the two new names to the existing import at the top of the file — change the import to also include `serializePlotConfig, readPlotConfig`:
+
+```js
+import {
+ sortPayloadByValueDesc,
+ toggleInSet,
+ setIdsHidden,
+ groupVisibilityState,
+ groupLines,
+ serializePlotConfig,
+ readPlotConfig,
+} from '../src/lib/crossSubjectChart';
+```
+
+Then append these describe blocks at the end of the file:
+
+```js
+describe('serializePlotConfig', () => {
+ const STATE = {
+ xField: '__days__', groupBy: 'g1', tickStep: 2, metric: 'Success',
+ hiddenSubjects: new Set(['b', 'a']), sidebarOpen: true,
+ counts: { height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' },
+ rate: { height: 160, yMin: '', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: null },
+ };
+
+ it('produces a plain JSON object with hiddenSubjects as a sorted array', () => {
+ const out = serializePlotConfig(STATE);
+ expect(out.hiddenSubjects).toEqual(['a', 'b']);
+ expect(out.sidebarOpen).toBe(true);
+ expect(out.xField).toBe('__days__');
+ expect(out.metric).toBe('Success');
+ expect(out.counts).toEqual({ height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' });
+ expect(out.rate.xFieldOverride).toBeNull();
+ });
+
+ it('round-trips through readPlotConfig (values preserved)', () => {
+ const back = readPlotConfig(serializePlotConfig(STATE));
+ expect(back.xField).toBe('__days__');
+ expect(back.groupBy).toBe('g1');
+ expect(back.tickStep).toBe(2);
+ expect(back.hiddenSubjects).toEqual(['a', 'b']);
+ expect(back.sidebarOpen).toBe(true);
+ expect(back.counts.height).toBe(220);
+ expect(back.counts.xFieldOverride).toBe('f2');
+ });
+});
+
+describe('readPlotConfig', () => {
+ it('returns safe defaults for null', () => {
+ const c = readPlotConfig(null);
+ expect(c.hiddenSubjects).toEqual([]);
+ expect(c.sidebarOpen).toBe(false);
+ expect(c.xField).toBeUndefined();
+ expect(c.counts.yMin).toBe('');
+ expect(c.counts.xFieldOverride).toBeNull();
+ });
+
+ it('returns safe defaults for a non-object', () => {
+ expect(readPlotConfig([1, 2]).hiddenSubjects).toEqual([]);
+ expect(readPlotConfig('nope').sidebarOpen).toBe(false);
+ });
+
+ it('filters non-string hiddenSubjects and ignores wrong-typed fields', () => {
+ const c = readPlotConfig({ hiddenSubjects: ['a', 5, 'b'], tickStep: 'x', sidebarOpen: 'yes' });
+ expect(c.hiddenSubjects).toEqual(['a', 'b']);
+ expect(c.tickStep).toBeUndefined();
+ expect(c.sidebarOpen).toBe(false);
+ });
+
+ it('does not throw on malformed nested chart settings', () => {
+ expect(() => readPlotConfig({ counts: 'bad', rate: [] })).not.toThrow();
+ const c = readPlotConfig({ counts: 'bad' });
+ expect(c.counts.yMin).toBe('');
+ expect(c.counts.height).toBeUndefined();
+ });
+});
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
+Expected: FAIL — `serializePlotConfig`/`readPlotConfig` are not exported.
+
+- [ ] **Step 3: Implement the helpers**
+
+Append to `frontend/src/lib/crossSubjectChart.js`:
+
+```js
+// ── Plot config (de)serialization ───────────────────────────────────────────────
+// Translate between ExperimentAnalysisCharts React state and the stored JSONB blob.
+// readPlotConfig is defensive: it never throws and fills safe defaults for missing
+// or malformed input (unknown keys are ignored).
+
+function snapshotChart(s = {}) {
+ return {
+ height: s.height,
+ yMin: s.yMin ?? '',
+ yMax: s.yMax ?? '',
+ xZoomMin: s.xZoomMin ?? '',
+ xZoomMax: s.xZoomMax ?? '',
+ xFieldOverride: s.xFieldOverride ?? null,
+ };
+}
+
+export function serializePlotConfig(state) {
+ const { xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, counts, rate } = state;
+ return {
+ xField,
+ groupBy,
+ tickStep,
+ metric,
+ hiddenSubjects: [...(hiddenSubjects ?? [])].sort(),
+ sidebarOpen: !!sidebarOpen,
+ counts: snapshotChart(counts),
+ rate: snapshotChart(rate),
+ };
+}
+
+function readChart(c) {
+ const o = c && typeof c === 'object' && !Array.isArray(c) ? c : {};
+ const str = (v) => (typeof v === 'string' ? v : '');
+ return {
+ height: Number.isFinite(o.height) ? o.height : undefined,
+ yMin: str(o.yMin),
+ yMax: str(o.yMax),
+ xZoomMin: str(o.xZoomMin),
+ xZoomMax: str(o.xZoomMax),
+ xFieldOverride: typeof o.xFieldOverride === 'string' ? o.xFieldOverride : null,
+ };
+}
+
+export function readPlotConfig(raw) {
+ const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
+ return {
+ xField: typeof c.xField === 'string' ? c.xField : undefined,
+ groupBy: typeof c.groupBy === 'string' ? c.groupBy : undefined,
+ tickStep: Number.isFinite(c.tickStep) ? c.tickStep : undefined,
+ metric: typeof c.metric === 'string' ? c.metric : undefined,
+ hiddenSubjects: Array.isArray(c.hiddenSubjects) ? c.hiddenSubjects.filter((x) => typeof x === 'string') : [],
+ sidebarOpen: c.sidebarOpen === true,
+ counts: readChart(c.counts),
+ rate: readChart(c.rate),
+ };
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
+Expected: PASS — all helper tests (including the new ones) green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/lib/crossSubjectChart.js frontend/tests/crossSubjectChart.test.js
+git commit -m "feat(frontend): serialize/read helpers for plot config"
+```
+
+---
+
+### Task 3: Frontend — hook refactor, API method, chart wiring, page props
+
+**Files:**
+- Modify: `frontend/src/api/client.js` (add `savePlotConfig` to `experimentsApi`, ~line 35)
+- Modify: `frontend/src/components/AnalysisCharts.jsx` (`useChartSettings` ~line 92; imports ~line 1; `ExperimentAnalysisCharts` ~line 448)
+- Modify: `frontend/src/pages/ExperimentDetail.jsx` (the `