Skip to main content

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 colorKeyColor vocabulary, wiring the Setup wizard to persist the choice via the bloc/SDK, and resolving colorKeyColor on every kid-colour surface (with the positional index as the backward-compatible fallback).

Today's state (verified)

  • HouseholdMember.colorKey (String?) + setMemberColorKey + updateMember exist in the SDK, but colorKey is stored and never read.
  • design_system color_tokens.dart has an ordered 4-colour kidPalette (#6AA4DA blue, #66B891 green, #AE97D8 violet, #CBB677 amber) + a positional accessor kidColor(int index). No named keys.
  • Display is positional: today_chore_row uses kidColor(index); approval_card + bounty_row hardcode kidColor(0) (always the first colour); DsAvatar takes a raw Color.
  • The Setup wizard collects kidColorIndex via a swatch but discards it (SetupKidSubmitted carries colorIndex, but the bloc never persists colour — it persists only emoji, via updateMember(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 with kidPalette (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, else null.
  • One resolution seam all display uses: Color kidColorResolved({String? colorKey, required int fallbackIndex}) → a known colorKey wins (kidColorByKey); a null/unknown key falls back to the positional kidColor(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 via kidColorKeyForIndex(kidColorIndex).
  • Change SetupKidSubmitted to carry colorKey (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 replace colorIndex or add colorKey; replacing is cleaner since the index has no other consumer.)
  • SetupBloc persists the given colorKey ALONGSIDE emoji in the existing single updateMember: 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 hardcoded kidColor(0) → the actual member's kidColorResolved(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.)
  • DsAvatar kid usages: pass the resolved colour.
  • The wizard kid_step swatch already drives kidColorIndex; no change needed to selection — but the chosen swatch's colour and the persisted key stay consistent via kidColorKeyForIndex.

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 colorKey reaches updateMember (mirror the emoji verify(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)

  1. The vocabulary + resolver in design_system (kidColorKeys, kidColorKeyForIndex, kidColorByKey, kidColorResolved) + DS unit tests.
  2. Persist — widget maps index→key (kidColorKeyForIndex); SetupKidSubmitted carries colorKey; SetupBloc persists it alongside emoji in updateMember
    • the flow-test colorKey verify.
  3. Display — rewire today_chore_row, approval_card, bounty_row, DsAvatar kid usages to kidColorResolved + the end-to-end display proof.