assignSeriesColors(names, scheme, prior?) from #30 keeps a series on its colour when another is filtered out — but only if the caller hands back the prior slot map. Nothing persists that map today, so it survives a filter and not a reload: close the dashboard, reopen it, and every series can shift a colour.
That is the exact failure the design is built to avoid. "Colour follows the entity, never its rank" has to hold across sessions, not just across a filter toggle.
The change
Add an optional field to PanelModel in src/lib/dashboard/model.ts:
/** Series name → palette slot, so colours survive a reload and a filter. */
seriesColorSlots?: Record<string, number>;
Then validate it in src/lib/dashboard/validate.ts and pin it in the round-trip tests.
The validation, which is the actual work
This is a user-controlled object with user-controlled keys arriving from a JSON body. validate.ts is the trust boundary, and there is already a helper for precisely this:
- Every key must pass
isSafeObjectKey. RESERVED_OBJECT_KEYS exists in model.ts because a key of __proto__ or constructor in a record that later gets spread or assigned is a prototype-pollution vector. Copy the pattern the existing record fields use — do not write a fresh check.
- Every value must be an integer in
[0, PALETTE_SIZE). A float, a negative, or an out-of-range slot is rejected, not clamped. assignSeriesColors already ignores an out-of-range prior at runtime, but the validator should not be storing garbage for it to ignore.
- Cap the entry count. A panel with 50,000 remembered series names is a payload-size attack that the
maxPayloadBytes limit alone does not shape well. Add a limit to the LIMITS object in model.ts next to maxTargetsPerPanel — something like maxColorSlots: 64 — and use the constant, never a literal.
serializeForSave sorts keys, so this participates in deterministic serialization for free. Add a test that a panel with these slots round-trips byte-identically, since that is what version-conflict detection rests on.
Then wire it
Whoever renders a panel reads panel.seriesColorSlots into the prior argument and writes the returned slots back when it changes. That write is a dashboard edit like any other — it goes through the normal save path with expectedVersion, not a side channel.
Good first issue notes
Self-contained: two files plus tests, with the patterns to copy sitting next to the code you are adding. npm test covers all of it — model.test.ts and validate.test.ts have many examples of exactly this shape. Read the existing record-field validation before writing new code; the guard you need is already there.
assignSeriesColors(names, scheme, prior?)from #30 keeps a series on its colour when another is filtered out — but only if the caller hands back thepriorslot map. Nothing persists that map today, so it survives a filter and not a reload: close the dashboard, reopen it, and every series can shift a colour.That is the exact failure the design is built to avoid. "Colour follows the entity, never its rank" has to hold across sessions, not just across a filter toggle.
The change
Add an optional field to
PanelModelinsrc/lib/dashboard/model.ts:Then validate it in
src/lib/dashboard/validate.tsand pin it in the round-trip tests.The validation, which is the actual work
This is a user-controlled object with user-controlled keys arriving from a JSON body.
validate.tsis the trust boundary, and there is already a helper for precisely this:isSafeObjectKey.RESERVED_OBJECT_KEYSexists inmodel.tsbecause a key of__proto__orconstructorin a record that later gets spread or assigned is a prototype-pollution vector. Copy the pattern the existing record fields use — do not write a fresh check.[0, PALETTE_SIZE). A float, a negative, or an out-of-range slot is rejected, not clamped.assignSeriesColorsalready ignores an out-of-range prior at runtime, but the validator should not be storing garbage for it to ignore.maxPayloadByteslimit alone does not shape well. Add a limit to theLIMITSobject inmodel.tsnext tomaxTargetsPerPanel— something likemaxColorSlots: 64— and use the constant, never a literal.serializeForSavesorts keys, so this participates in deterministic serialization for free. Add a test that a panel with these slots round-trips byte-identically, since that is what version-conflict detection rests on.Then wire it
Whoever renders a panel reads
panel.seriesColorSlotsinto thepriorargument and writes the returnedslotsback when it changes. That write is a dashboard edit like any other — it goes through the normal save path withexpectedVersion, not a side channel.Good first issue notes
Self-contained: two files plus tests, with the patterns to copy sitting next to the code you are adding.
npm testcovers all of it —model.test.tsandvalidate.test.tshave many examples of exactly this shape. Read the existing record-field validation before writing new code; the guard you need is already there.