Kid colorKey↔Color persistence round-trip (design spec, 2026-06-21)
Brainstormed + approved 2026-06-21. Second of the two deferred follow-ups from the auth/onboarding + emoji-picker work (the first, the emoji picker, shipped). Closes the last "collected-but-discarded" gap: the Setup wizard's kid colour swatch.
Goal
Persist a kid's CHOSEN colour and use it for display — establishing a canonical
colorKey↔Color vocabulary, wiring the Setup wizard to persist the choice via
the bloc/SDK, and resolving colorKey→Color on every kid-colour surface (with
the positional index as the backward-compatible fallback).
Today's state (verified)
HouseholdMember.colorKey(String?) +setMemberColorKey+updateMemberexist in the SDK, butcolorKeyis stored and never read.design_systemcolor_tokens.darthas an ordered 4-colourkidPalette(#6AA4DAblue,#66B891green,#AE97D8violet,#CBB677amber) + a positional accessorkidColor(int index). No named keys.- Display is positional:
today_chore_rowuseskidColor(index);approval_card+bounty_rowhardcodekidColor(0)(always the first colour);DsAvatartakes a rawColor. - The Setup wizard collects
kidColorIndexvia a swatch but discards it (SetupKidSubmittedcarriescolorIndex, but the bloc never persists colour — it persists only emoji, viaupdateMember(copyWith(setEmoji:))).
1. The vocabulary (design_system)
In color_tokens.dart (or a small sibling), parallel to kidPalette:
static const List<String> kidColorKeys = ['sky', 'sage', 'lilac', 'honey'];— 4 keys, index-aligned 1:1 withkidPalette(sky=blue, sage=green, lilac=violet, honey=amber).String kidColorKeyForIndex(int index)→kidColorKeys[index % length](the wizard swatch index → a stable key for persistence).Color? kidColorByKey(String? key)→ the palette colour for a known key, elsenull.- One resolution seam all display uses:
Color kidColorResolved({String? colorKey, required int fallbackIndex})→ a knowncolorKeywins (kidColorByKey); a null/unknown key falls back to the positionalkidColor(fallbackIndex)(existing/skipped members keep today's behaviour). This is the SINGLE place the colorKey↔index↔Color logic lives.
Invariant: kidColorResolved(colorKey: kidColorKeyForIndex(i), fallbackIndex: x)
== kidColor(i) (the key for an index resolves to that index's colour).
2. Persist (widget maps index→key; bloc persists the key)
Keep the bloc free of design_system (a bloc shouldn't depend on UI tokens).
The widget layer already imports design_system (the swatch renders
kidColor(i)), so it owns the index→key mapping:
- In
kid_step/SetupPage, map the selected swatch index to a key viakidColorKeyForIndex(kidColorIndex). - Change
SetupKidSubmittedto carrycolorKey(String) instead of (or in addition to)colorIndex— the event/bloc speak the canonical key, not the positional index. (Confirm in the plan whether to replacecolorIndexor addcolorKey; replacing is cleaner since the index has no other consumer.) SetupBlocpersists the givencolorKeyALONGSIDE emoji in the existing singleupdateMember:updateMember(member.copyWith(setEmoji: () => emoji, setColorKey: () => colorKey))(one call sets both). Keep the existing non-fatal-on-failure behaviour (the member is already added; a colour/emoji persist failure must not roll back or duplicate the kid — the emoji fix already established this).
3. Display (the payoff — resolve everywhere, index fallback)
Switch every kid-colour surface from positional to resolved:
today_chore_row.dart:kidColor(index)→kidColorResolved(colorKey: <kid>.colorKey, fallbackIndex: index).approval_card.dart+bounty_row.dart: the hardcodedkidColor(0)→ the actual member'skidColorResolved(colorKey: member.colorKey, fallbackIndex: <the member's position>). (These need the member in scope — confirm each has access to the member + a sensible fallback index during the plan.)DsAvatarkid usages: pass the resolved colour.- The wizard
kid_stepswatch already driveskidColorIndex; no change needed to selection — but the chosen swatch's colour and the persisted key stay consistent viakidColorKeyForIndex.
Fallback rule everywhere: colorKey non-null + known → its colour; else the
positional kidColor(fallbackIndex) (no behaviour change for members without a
key).
4. Testing
- DS unit:
kidColorByKey(known→colour, unknown/null→null);kidColorKeyForIndex(index→key, cycling);kidColorResolved(key wins; null/unknown→index fallback); the round-trip invariant above. - Bloc/flow: the Setup flow test verifies the kid's
colorKeyreachesupdateMember(mirror the emojiverify(updateMember(... m.colorKey == 'X'))). - Display proof (end-to-end): a widget/golden test that a member with
colorKey: 'lilac'renders the VIOLET colour, NOT its positional colour — proving persist→resolve round-trips. - Keep the whole suite green (capture the TRUE flutter exit code — no
| tail).
Out of scope / deferred
- Changing the palette colours or count (stay at 4).
- A dedicated kid-editor screen (reuses the swatch + the same persist path when built; not part of this).
- The wizard "can't clear a picked emoji" gap (a separate emoji-feature follow-up).
- Per-key a11y contrast re-tuning beyond the existing palette.
Sequencing (for the plan)
- The vocabulary + resolver in
design_system(kidColorKeys,kidColorKeyForIndex,kidColorByKey,kidColorResolved) + DS unit tests. - Persist — widget maps index→key (
kidColorKeyForIndex);SetupKidSubmittedcarriescolorKey;SetupBlocpersists it alongside emoji inupdateMember- the flow-test colorKey verify.
- Display — rewire
today_chore_row,approval_card,bounty_row,DsAvatarkid usages tokidColorResolved+ the end-to-end display proof.