Kid colorKey↔Color Persistence 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 a kid's chosen colour and use it for display — a canonical colorKey↔Color vocabulary, persisted through the Setup wizard → bloc → SDK, and resolved on every kid-colour surface (positional index as the fallback).
Architecture: A kidColorKeys vocabulary + a single kidColorResolved seam in design_system (colorKey wins, index fallback). The widget layer maps the swatch index→key (keeping the bloc free of design_system); SetupKidSubmitted carries colorKey; SetupBloc persists it alongside emoji in the existing single updateMember. Display surfaces switch from positional kidColor(index) to kidColorResolved.
Tech Stack: Flutter 3.44 / Dart 3.9 (FVM), design_system tokens, flutter_bloc, json_serializable (bloc state), the gadfly flow_test harness.
Global Constraints
- Use
fvm flutter/fvm dartfor every command. - Capture the TRUE flutter exit code in every test step —
fvm flutter test <target> > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"then inspect the file. NEVER pipeflutter testtotail/grepfor the pass/fail decision. A step passes only whenFLUTTER_EXIT=0AND "All tests passed!" with no "Exhausted heap"/"Out of Memory". - Keep the bloc free of
design_system— the index→key mapping happens at the widget layer (which already importsdesign_system). - Bloc STATE is
@JsonSerializable()(DevTools contract). ChangingSetupState's shape requires regeneratingstate.g.dartwith a SCOPED--build-filter "lib/inside/blocs/setup/state.g.dart"; after codegen,git diffand restore any unintended.g.dart/.gr.dart/.gr.dartsiblings from HEAD (byte-identical).SetupKidSubmitted(inevents.dart) is a PLAIN sealed class — NO codegen. design_systemstays model-agnostic.fvm flutter analyze→ 0/0 whole workspace;fvm dart formatclean.- The kid emoji+colour persist stays non-fatal on failure (the member is already added; a persist failure must not roll back or duplicate the kid — the emoji fix established this).
- Goldens tagged
golden, regenerated with--update-goldens. Commit per task on branchfeat/kid-color-persistence; do NOT push.
Today's shapes (verified)
packages/design_system/lib/src/tokens/color_tokens.dart:final List<Color> kidPalette(4 entries:#6AA4DAsky,#66B891sage,#AE97D8lilac,#CBB677honey) +Color kidColor(int index)(kidPalette[index % length], instance method — kidPalette is per-theme).app/lib/inside/blocs/setup/events.dart:class SetupKidSubmitted extends SetupEvent { SetupKidSubmitted({required this.name, required this.colorIndex, this.emoji}); final String name; final int colorIndex; final String? emoji; }.app/lib/inside/blocs/setup/state.dart(+state.g.dart):SetupState(@JsonSerializable) holdskidColorIndex(int).app/lib/inside/blocs/setup/bloc.dart_onKidSubmitted: storeskidColorIndex: event.colorIndexinto state; callsaddMemberthen (emoji non-empty)updateMember(member.copyWith(setEmoji: () => event.emoji))— non-fatal.app/.../setup/page.dart:int _kidColorIndex = 0;+ dispatchesSetupKidSubmitted(...).app/.../setup/widgets/steps/kid_step.dart: the swatch row (onColorChanged(i),colors.kidColor(i)).- Display (positional today):
today_chore_row.dartkidColor(index);approval_card.dart+bounty_row.darthardcodekidColor(0).
Task 1: The kidColorKeys vocabulary + kidColorResolved seam (design_system)
Files:
- Modify:
packages/design_system/lib/src/tokens/color_tokens.dart - Modify:
packages/design_system/lib/design_system.dart(export the static helpers if not already surfaced viaColorTokens) - Test:
packages/design_system/test/tokens/kid_color_test.dart
Interfaces:
-
Produces:
static const List<String> ColorTokens.kidColorKeys = <String>['sky', 'sage', 'lilac', 'honey'];(index-aligned 1:1 withkidPalette).static String ColorTokens.kidColorKeyForIndex(int index)→kidColorKeys[index % kidColorKeys.length](theme-independent — the widget maps a swatch index → a stable key).Color? kidColorByKey(String? key)(instance) →kidPalette[kidColorKeys.indexOf(key)]for a known key, elsenull(unknown/null key → null).Color kidColorResolved({String? colorKey, required int fallbackIndex})(instance) →kidColorByKey(colorKey) ?? kidColor(fallbackIndex)(colorKey wins; null/unknown → positional fallback).- Invariant:
kidColorResolved(colorKey: kidColorKeyForIndex(i), fallbackIndex: anything) == kidColor(i).
-
Step 1: Write the failing unit tests
packages/design_system/test/tokens/kid_color_test.dart:
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final c = DsTheme.dark.colors; // ColorTokens with the 4-entry kidPalette
test('kidColorKeys aligns 1:1 with kidPalette', () {
expect(ColorTokens.kidColorKeys.length, c.kidPalette.length);
expect(ColorTokens.kidColorKeys, ['sky', 'sage', 'lilac', 'honey']);
});
test('kidColorKeyForIndex maps + cycles', () {
expect(ColorTokens.kidColorKeyForIndex(0), 'sky');
expect(ColorTokens.kidColorKeyForIndex(2), 'lilac');
expect(ColorTokens.kidColorKeyForIndex(4), 'sky'); // wraps
});
test('kidColorByKey: known→palette colour, unknown/null→null', () {
expect(c.kidColorByKey('lilac'), c.kidPalette[2]);
expect(c.kidColorByKey('nope'), isNull);
expect(c.kidColorByKey(null), isNull);
});
test('kidColorResolved: colorKey wins, null/unknown→index fallback', () {
expect(c.kidColorResolved(colorKey: 'lilac', fallbackIndex: 0), c.kidPalette[2]);
expect(c.kidColorResolved(colorKey: null, fallbackIndex: 1), c.kidColor(1));
expect(c.kidColorResolved(colorKey: 'nope', fallbackIndex: 3), c.kidColor(3));
});
test('round-trip invariant: key-for-index resolves to that colour', () {
for (var i = 0; i < 4; i++) {
expect(c.kidColorResolved(colorKey: ColorTokens.kidColorKeyForIndex(i), fallbackIndex: 99), c.kidColor(i));
}
});
}
- Step 2: Run to verify it fails
Run: cd packages/design_system && MSYS_NO_PATHCONV=1 fvm flutter test test/tokens/kid_color_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; grep -iE "All tests passed|FAIL|Error" /tmp/t.txt
Expected: FAIL — kidColorKeys/kidColorByKey/kidColorResolved undefined.
- Step 3: Implement in
color_tokens.dart
In ColorTokens, near kidPalette/kidColor:
/// Canonical per-kid colour KEYS, index-aligned 1:1 with [kidPalette]. The
/// stored [HouseholdMember.colorKey]; theme-independent (the key picks a slot,
/// the theme supplies the actual colour).
static const List<String> kidColorKeys = <String>['sky', 'sage', 'lilac', 'honey'];
/// Maps a 0-based swatch index → a stable [kidColorKeys] key (wraps), for
/// persistence. Theme-independent.
static String kidColorKeyForIndex(int index) =>
kidColorKeys[index % kidColorKeys.length];
/// The palette colour for a known [kidColorKeys] key, else null.
Color? kidColorByKey(String? key) {
if (key == null) return null;
final i = kidColorKeys.indexOf(key);
if (i < 0 || kidPalette.isEmpty) return null;
return kidPalette[i % kidPalette.length];
}
/// The single display seam: a known [colorKey] wins; a null/unknown key falls
/// back to the positional [kidColor]\([fallbackIndex]\) (existing/skipped
/// members keep today's behaviour).
Color kidColorResolved({String? colorKey, required int fallbackIndex}) =>
kidColorByKey(colorKey) ?? kidColor(fallbackIndex);
If ColorTokens isn't already exported from the barrel, confirm it is (it must be, since DsTheme.of(context).colors is ColorTokens).
- Step 4: Run to verify pass
Run: cd packages/design_system && MSYS_NO_PATHCONV=1 fvm flutter test test/tokens/kid_color_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; grep -iE "All tests passed" /tmp/t.txt
Expected: FLUTTER_EXIT=0, "All tests passed!".
- Step 5: analyze + format + commit
Run: cd rewhaven && fvm flutter analyze > /tmp/a.txt 2>&1; echo "EXIT=$?"; tail -2 /tmp/a.txt (0/0) · fvm dart format packages/design_system.
git add packages/design_system/lib/src/tokens/color_tokens.dart packages/design_system/lib/design_system.dart packages/design_system/test/tokens/kid_color_test.dart
git commit -m "feat(ds): kidColorKeys vocabulary + kidColorResolved seam (colorKey wins, index fallback)"
Task 2: Persist the chosen colour (event → bloc → updateMember)
Files:
- Modify:
app/lib/inside/blocs/setup/events.dart(SetupKidSubmitted:colorIndex→colorKey) - Modify:
app/lib/inside/blocs/setup/state.dart(+ regenstate.g.dart) (kidColorIndex→kidColorKey) - Modify:
app/lib/inside/blocs/setup/bloc.dart(_onKidSubmitted: persistcolorKey+ emoji in oneupdateMember) - Modify:
app/lib/inside/routes/authenticated/setup/page.dart(map_kidColorIndex→ key when dispatching) - Test:
app/test/unit/setup_bloc_test.dart(+app/test/flows/setup_test.dartcolorKey verify)
Interfaces:
-
Consumes:
ColorTokens.kidColorKeyForIndex(Task 1). -
Produces:
SetupKidSubmitted({required String name, required String colorKey, String? emoji});SetupState.kidColorKey(String?); the bloc'supdateMember(member.copyWith(setEmoji: () => emoji, setColorKey: () => colorKey)). -
Step 1: Update the unit + flow tests to expect colorKey persistence (RED)
In app/test/unit/setup_bloc_test.dart: change the SetupKidSubmitted(...) construction to pass colorKey: 'lilac' (was colorIndex: 2); add/adjust an assertion that on the happy path updateMember is called with a member whose colorKey == 'lilac' (mirror the existing emoji verify(updateMember(... m.emoji == '🦊')) — combine into a member matcher asserting BOTH emoji and colorKey, or a second predicate). In app/test/flows/setup_test.dart's success scenario, the kid step picks colour index 2 (lilac); assert the dispatched flow persists colorKey == 'lilac' via verify(() => mocks.householdRepository.updateMember(any(that: predicate<HouseholdMember>((m) => m.colorKey == 'lilac' && m.emoji == '🦊')))). Run both → FAIL (event has no colorKey; bloc doesn't set colorKey):
Run: cd app && MSYS_NO_PATHCONV=1 fvm flutter test test/unit/setup_bloc_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; grep -iE "All tests passed|Error|FAIL" /tmp/t.txt
- Step 2: Change the event (
events.dart)
class SetupKidSubmitted extends SetupEvent {
SetupKidSubmitted({required this.name, required this.colorKey, this.emoji});
final String name;
final String colorKey;
final String? emoji;
}
- Step 3: Change the state field + regen (
state.dart)
Rename kidColorIndex (int) → kidColorKey (String) in SetupState's field + constructor + copyWith + props + toJson/fromJson annotations (mirror the existing field's pattern; default ''). Then regen:
cd app && fvm dart run build_runner build --delete-conflicting-outputs --build-filter "lib/inside/blocs/setup/state.g.dart" → git diff confirms ONLY setup/state.g.dart changed (now kidColorKey); restore any clobbered sibling from HEAD.
- Step 4: Bloc persists colorKey + emoji (
bloc.dart_onKidSubmitted)
Set kidColorKey: event.colorKey into state (replacing kidColorIndex: event.colorIndex). In the persist step, set BOTH in the one updateMember (keep the non-fatal try/catch):
final member = await _householdRepository.addMember(displayName: event.name, kind: MemberKind.child);
final hasEmoji = event.emoji != null && event.emoji!.isNotEmpty;
// Persist the chosen colour (always) + emoji (when present), non-fatally.
try {
await _householdRepository.updateMember(
member.copyWith(
setColorKey: () => event.colorKey,
setEmoji: hasEmoji ? () => event.emoji : null,
),
);
} catch (_) {/* non-fatal: member already added; colour/emoji are nice-to-haves */}
(Match the EXACT existing non-fatal structure in the file — read it first; only ADD setColorKey. If the existing code only calls updateMember when emoji is present, change it to ALSO run when there's a colorKey — colour is always chosen, so updateMember now always runs.)
- Step 5: Page maps index→key when dispatching (
page.dart)
Where it builds SetupKidSubmitted(...), pass colorKey: ColorTokens.kidColorKeyForIndex(_kidColorIndex) (import package:design_system/design_system.dart). The page keeps _kidColorIndex for the swatch UI; only the dispatched event speaks colorKey.
- Step 6: Run unit + flow → pass
Run: cd app && MSYS_NO_PATHCONV=1 fvm flutter test test/unit/setup_bloc_test.dart test/flows/setup_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; grep -iE "All tests passed|Some tests failed|FAIL" /tmp/t.txt
Expected: FLUTTER_EXIT=0, "All tests passed!". (If the flow test now changes the setup goldens, regenerate them: ... test/flows/setup_test.dart --update-goldens --dart-define=createScreenshots=true.)
- Step 7: Full gates + commit
Run: cd rewhaven && fvm flutter analyze (0/0) · cd app && fvm flutter test > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; tail -2 /tmp/t.txt; grep -cE "Exhausted heap|\[E\]" /tmp/t.txt (exit 0, all pass, 0 heap/fail) · cd rewhaven && fvm dart format ..
Verify ONLY setup/state.g.dart regenerated (no sibling clobber).
git add app/lib/inside/blocs/setup app/lib/inside/routes/authenticated/setup/page.dart app/test/unit/setup_bloc_test.dart app/test/flows/setup_test.dart
git commit -m "feat(app): persist the kid's chosen colorKey in SetupBloc (event→key, alongside emoji)"
Task 3: Display — resolve colorKey everywhere (fix the kidColor(0) hardcodes)
Files:
- Modify:
app/lib/inside/routes/authenticated/home/widgets/today_chore_row.dart - Modify:
app/lib/inside/routes/authenticated/home/widgets/approval_card.dart - Modify:
app/lib/inside/routes/authenticated/home/widgets/bounty_row.dart - Modify: any DsAvatar kid usage that passes
kidColor(index)(grep first) - Test:
app/test/widget/kid_color_display_test.dart(the end-to-end proof)
Interfaces:
-
Consumes:
ColorTokens.kidColorResolved(Task 1); each surface has aHouseholdMember(withcolorKey) + a positional index in scope. -
Step 1: Write the failing end-to-end display test
app/test/widget/kid_color_display_test.dart — render today_chore_row (or the simplest surface that takes a member) for a member with colorKey: 'lilac' at a NON-lilac position (e.g. index 0), and assert the rendered kid-colour is the LILAC palette colour (DsTheme.dark.colors.kidPalette[2]), NOT the positional kidColor(0). (Find the colour via the widget that paints it — e.g. a Container/DsAvatar's decoration colour, or a find.byType + the painted colour. If the row exposes no inspectable colour, pick the surface that does, or assert through DsAvatar's color prop.) Run → FAIL (display still positional):
Run: cd app && MSYS_NO_PATHCONV=1 fvm flutter test test/widget/kid_color_display_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; grep -iE "All tests passed|FAIL|Expected" /tmp/t.txt
- Step 2: Rewire
today_chore_row.dart
Replace c.kidColor(index) with c.kidColorResolved(colorKey: <kid>.colorKey, fallbackIndex: index) (the kid member is in scope as the row's subject; index is its existing positional arg).
- Step 3: Rewire
approval_card.dart+bounty_row.dart(the hardcodes)
Each currently uses kidColor(0). Replace with c.kidColorResolved(colorKey: <member>.colorKey, fallbackIndex: <the member's index>) — the member is the card/row's subject (the claimer / submitter / approver). Use a sensible fallbackIndex: the member's position if available, else 0 (preserving today's behaviour when there's no colorKey AND no position). Read each file to find the member in scope; if a member's position isn't readily available, pass the member's stable index if the surrounding list provides one, else 0 (document the choice in a comment).
- Step 4: Rewire DsAvatar kid usages
grep -rn "kidColor(" app/lib — for any remaining kid DsAvatar(color: ...kidColor(i)), switch to kidColorResolved(colorKey: member.colorKey, fallbackIndex: i). (The wizard kid_step swatch stays kidColor(i) — it's choosing by index, not displaying a persisted member.)
- Step 5: Run the display test → pass + regen any goldens
Run: cd app && MSYS_NO_PATHCONV=1 fvm flutter test test/widget/kid_color_display_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; grep -iE "All tests passed" /tmp/t.txt → exit 0. If any Today/home goldens shift (a seeded kid now shows its colorKey colour), regenerate them with --update-goldens --dart-define=createScreenshots=true and eyeball one.
- Step 6: Full gates + commit + graphify
Run: cd rewhaven && fvm flutter analyze (0/0) · cd app && fvm flutter test > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; tail -2 /tmp/t.txt; grep -cE "Exhausted heap|\[E\]" /tmp/t.txt (exit 0, all pass, 0 heap/fail) · cd rewhaven && fvm dart format ..
git add app/lib/inside/routes/authenticated/home/widgets app/test/widget/kid_color_display_test.dart
git commit -m "feat(app): resolve kid colorKey for display (today row, approval, bounty, avatar); fix kidColor(0) hardcodes"
Then cd rewhaven && graphify update . (don't commit graphify-out).
Self-review
- Spec coverage: vocabulary + resolver (T1), persist via widget→event→bloc→updateMember + flow verify (T2), display rewire of all 4 surface groups + the kidColor(0) hardcode fixes + the end-to-end proof (T3), the round-trip invariant (T1 Step 1), the non-fatal persist preserved (T2 Step 4), the bloc stays DS-free / mapping at the widget layer (T2 Step 5). ✓
- Placeholders: none — interfaces + test code + exact commands with the true-exit-code pattern. The two flagged discovery items have explicit fallbacks in-step: SetupKidSubmitted REPLACES colorIndex (T2 Step 2); approval_card/bounty_row use the member's index else
0(T3 Step 3). - Type consistency:
kidColorKeys/kidColorKeyForIndex(static),kidColorByKey/kidColorResolved(instance),SetupKidSubmitted(colorKey),SetupState.kidColorKey,member.copyWith(setColorKey:)used consistently across tasks. - Codegen: only
SetupStatechanges shape → scopedstate.g.dartregen (T2 Step 3); the event is a plain class (no codegen).