Skip to main content

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 colorKeyColor 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 dart for every command.
  • Capture the TRUE flutter exit code in every test stepfvm flutter test <target> > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?" then inspect the file. NEVER pipe flutter test to tail/grep for the pass/fail decision. A step passes only when FLUTTER_EXIT=0 AND "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 imports design_system).
  • Bloc STATE is @JsonSerializable() (DevTools contract). Changing SetupState's shape requires regenerating state.g.dart with a SCOPED --build-filter "lib/inside/blocs/setup/state.g.dart"; after codegen, git diff and restore any unintended .g.dart/.gr.dart/.gr.dart siblings from HEAD (byte-identical). SetupKidSubmitted (in events.dart) is a PLAIN sealed class — NO codegen.
  • design_system stays model-agnostic. fvm flutter analyze → 0/0 whole workspace; fvm dart format clean.
  • 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 branch feat/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: #6AA4DA sky, #66B891 sage, #AE97D8 lilac, #CBB677 honey) + 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) holds kidColorIndex (int).
  • app/lib/inside/blocs/setup/bloc.dart _onKidSubmitted: stores kidColorIndex: event.colorIndex into state; calls addMember then (emoji non-empty) updateMember(member.copyWith(setEmoji: () => event.emoji)) — non-fatal.
  • app/.../setup/page.dart: int _kidColorIndex = 0; + dispatches SetupKidSubmitted(...).
  • app/.../setup/widgets/steps/kid_step.dart: the swatch row (onColorChanged(i), colors.kidColor(i)).
  • Display (positional today): today_chore_row.dart kidColor(index); approval_card.dart + bounty_row.dart hardcode kidColor(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 via ColorTokens)
  • 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 with kidPalette).
    • 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, else null (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: colorIndexcolorKey)
  • Modify: app/lib/inside/blocs/setup/state.dart (+ regen state.g.dart) (kidColorIndexkidColorKey)
  • Modify: app/lib/inside/blocs/setup/bloc.dart (_onKidSubmitted: persist colorKey + emoji in one updateMember)
  • 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.dart colorKey verify)

Interfaces:

  • Consumes: ColorTokens.kidColorKeyForIndex (Task 1).

  • Produces: SetupKidSubmitted({required String name, required String colorKey, String? emoji}); SetupState.kidColorKey (String?); the bloc's updateMember(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 a HouseholdMember (with colorKey) + 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 SetupState changes shape → scoped state.g.dart regen (T2 Step 3); the event is a plain class (no codegen).