Skip to main content

DsEmojiPicker + Color-Emoji Rendering 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: A calm, low-stim emoji picker (DsEmojiChip + DsEmojiPicker, curated-default → expandable searchable grid) wired into the Setup wizard, plus a bundled color-emoji font so emoji render everywhere — including Flutter goldens.

Architecture: A color-emoji font asset in design_system registered for both runtime and the flow_test golden loader (the foundation, proven first). A data-only categorized emoji dataset + two curated sets. A DsEmojiChip atom + DsEmojiPicker molecule opened via the existing showDsSheet. Wiring into the Setup wizard steps so a chosen emoji reaches the already-emoji-ready SetupBloc.

Tech Stack: Flutter 3.44 / Dart 3.9 (FVM), design_system package (DsSheet, DsTextField, MotionTokens), golden_toolkit, the flow_test harness, flutter_bloc.

Global Constraints

  • Use fvm flutter / fvm dart for every command.
  • Capture the TRUE flutter exit code in every test step — run fvm 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 (it masks the real exit code). A step passes only when FLUTTER_EXIT=0 AND the file says "All tests passed!" with no "Exhausted heap"/"Out of Memory".
  • All animation honors reduced motion via MotionTokens.durationOrZero(context, ...) / MediaQuery.disableAnimationsOf(context) — one code path, static when reduced.
  • design_system stays model-agnostic (no client_sdk dep). The emoji dataset + picker live in design_system.
  • Maestro resolves id: against Semantics(identifier: '...'), NOT Keys — put a Semantics(identifier:) on the chip + sheet + a couple of stable emoji. Keep Keys for find.byKey in tests.
  • Codegen (if any) uses a scoped --build-filter; after it, git diff and restore any unintended .g.dart/.gr.dart from HEAD.
  • Goldens tagged golden, regenerated with --update-goldens; the gallery flutter build web --release must still succeed.
  • Emoji is OPTIONAL everywhere (null is valid). Commit per task on branch feat/emoji-picker; do NOT push.
  • Brand/user-facing copy via i18n where the app already does; DS strings stay in the DS.

File structure

design_system — font + data + components:

  • Add packages/design_system/assets/fonts/NotoColorEmoji.ttf (+ its OFL license) — the color-emoji font asset.
  • Modify packages/design_system/pubspec.yaml — declare the emoji font (replacing the TODO(fonts) line).
  • Modify packages/design_system/lib/src/tokens/typography_tokens.dart — expose the emoji font family name + wire it as the text-theme emoji fallback.
  • Create packages/design_system/lib/src/data/emoji_data.dart — the categorized emoji dataset (data-only: EmojiEntry{char, name, keywords, category} + kAllEmoji) + kCuratedHouseholdEmoji + kCuratedKidEmoji.
  • Create packages/design_system/lib/src/atoms/ds_emoji_chip.dartDsEmojiChip.
  • Create packages/design_system/lib/src/molecules/ds_emoji_picker.dartDsEmojiPicker + showDsEmojiPicker.
  • Modify packages/design_system/lib/design_system.dart — exports.
  • Modify packages/design_system/gallery/lib/main.dart — a DsEmojiPicker story.
  • Tests under packages/design_system/test/.

flow_test — golden loader:

  • Modify packages/flow_test/lib/src/golden_fonts.dart — register the color-emoji font for goldens; retire the monochrome workaround.

app — wizard wiring:

  • Modify app/lib/inside/routes/authenticated/setup/page.dart — own _householdEmoji/_kidEmoji state, pass to steps, include in dispatched events.
  • Modify app/.../setup/widgets/steps/name_step.dart + kid_step.dart — replace the "emoji deferred" slot with a DsEmojiChip.
  • Modify app/test/flows/setup_test.dart — pick an emoji, assert it reaches the bloc event.

Phase 1 — Color-emoji font (proof-first foundation)

Task 1: Bundle + register the color-emoji font; PROVE it renders in a golden

Files:

  • Create: packages/design_system/assets/fonts/NotoColorEmoji.ttf + NotoColorEmoji-OFL.txt
  • Modify: packages/design_system/pubspec.yaml, packages/design_system/lib/src/tokens/typography_tokens.dart
  • Modify: packages/flow_test/lib/src/golden_fonts.dart
  • Test: packages/design_system/test/golden/emoji_render_golden_test.dart

Interfaces:

  • Produces: the emoji font family constant TypographyTokens.emojiFamily = 'Twemoji' used as the fontFamilyFallback tail on the DS text theme; the flow_test golden loader registers the same family so goldens render it.

PROVEN (no gamble): A pixel-probe experiment (2026-06-21) established that Twemoji COLRv0 renders color in Flutter 3.44 goldens (CBDT + COLRv0 → color; COLRv1 → invisible — the earlier failed attempt). The Twemoji font is already downloaded + staged at /tmp/Twemoji.ttf (Mozilla/jdecked build, CC-BY 4.0, ~1.5 MB). This task wires it in; the color-render golden is now a VERIFICATION, not a gamble.

  • Step 1: Place the font + license. cp /tmp/Twemoji.ttf packages/design_system/assets/fonts/Twemoji.ttf. Add packages/design_system/assets/fonts/Twemoji-CCBY.txt with the CC-BY 4.0 attribution (Twitter/Twemoji, © Twitter Inc., CC-BY 4.0; Mozilla/jdecked COLRv0 build). If /tmp/Twemoji.ttf is missing, re-download the Mozilla Twemoji COLRv0 TwemojiMozilla.ttf and note the source.

  • Step 2: Write the failing color-render golden + pixel-color probe

packages/design_system/test/golden/emoji_color_render_test.dart — render 🦊🏡⭐ in a Text with fontFamily: 'Twemoji', capture the RepaintBoundary image, and ASSERT a chromatic pixel exists (the proof color rendered, not mono/tofu). Use the probe technique from the experiment:

@Tags(<String>['golden'])
library;
// loads Twemoji via FontLoader, renders Text('🦊🏡⭐', fontFamily: 'Twemoji', size 64),
// boundary.toImage → rawRgba → assert: some opaque non-background pixel has (max-min channel) > 20.
test('Twemoji renders COLOR in the golden pipeline', () async {
// expect(foundColor, isTrue); // chromatic pixel present
});

Keep it self-contained + FAST (await the loader, pump once, read pixels, assert, return — do NOT leave pending timers; the experiment's probe hung 10 min because it didn't). Use a hard per-test timeout.

  • Step 3: Run → fail. cd packages/design_system && MSYS_NO_PATHCONV=1 fvm flutter test test/golden/emoji_color_render_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; grep -iE "foundColor|All tests passed|FAIL|Some tests failed" /tmp/t.txt → FAIL (font not wired / family unresolved).

  • Step 4: Register Twemoji (runtime + golden)

pubspec.yaml: replace the TODO(fonts) line with a - family: Twemoji\n fonts:\n - asset: assets/fonts/Twemoji.ttf entry. typography_tokens.dart: set static const emojiFamily = 'Twemoji'; and ensure the DS body/display TextStyles carry fontFamilyFallback: const [emojiFamily]. golden_fonts.dart: the bare-family re-registration loads Twemoji from the design_system pubspec; retire the _loadMonochromeEmojiFont() monochrome workaround (it forced mono Noto Emoji) so emoji now resolve to Twemoji color.

  • Step 5: Run → pass (color VERIFIED).

cd packages/design_system && MSYS_NO_PATHCONV=1 fvm flutter test test/golden/emoji_color_render_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; grep -iE "foundColor|All tests passed" /tmp/t.txtFLUTTER_EXIT=0, the probe's foundColor=true, "All tests passed!". (The pixel-color assertion IS the proof — no manual PNG inspection needed.)

  • Step 6: 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 packages/flow_test.

git add packages/design_system/assets/fonts/Twemoji.ttf packages/design_system/assets/fonts/Twemoji-CCBY.txt packages/design_system/pubspec.yaml packages/design_system/lib/src/tokens/typography_tokens.dart packages/flow_test/lib/src/golden_fonts.dart packages/design_system/test/golden/emoji_color_render_test.dart
git commit -m "feat(ds): bundle Twemoji (COLRv0) — color emoji render at runtime + in goldens; retire mono workaround"

Task 2: Regenerate the existing emoji-bearing goldens to true color

Files:

  • Modify (regenerate): app/test/flows/**/screenshots/*.png are gitignored — but any COMMITTED goldens that contain emoji (DS + app golden PNGs) need regenerating.
  • Test: the existing golden suites.

Interfaces: Consumes Task 1's color font.

  • Step 1: Find committed goldens that render emoji. Run cd rewhaven && git ls-files '*goldens*/*.png' | head and identify which DS/app golden images contain emoji (e.g. Today rows, vignettes, any chip). The flow-test screenshots are gitignored (not committed) so they don't need a commit; only COMMITTED goldens matter.

  • Step 2: Regenerate the affected committed goldens

Run the owning golden suites with --update-goldens, capturing the true exit: cd packages/design_system && MSYS_NO_PATHCONV=1 fvm flutter test -t golden --update-goldens > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?" then re-run WITHOUT the flag to confirm pass.

  • Step 3: Verify + commit

Run each affected suite without --update-goldensFLUTTER_EXIT=0. Eyeball one regenerated image to confirm color emoji. Then:

git add packages/design_system/test/golden/goldens
git commit -m "test(ds): regenerate emoji-bearing goldens to true color"

(If NO committed goldens contain emoji, skip the commit and note it — flow-test screenshots are gitignored.)


Phase 2 — Emoji dataset + curated sets

Task 3: The categorized emoji dataset + curated sets

Files:

  • Create: packages/design_system/lib/src/data/emoji_data.dart
  • Modify: packages/design_system/lib/design_system.dart (export)
  • Test: packages/design_system/test/data/emoji_data_test.dart

Interfaces:

  • Produces:

    • class EmojiEntry { final String char; final String name; final List<String> keywords; final EmojiCategory category; const EmojiEntry(...); }
    • enum EmojiCategory { smileys, animals, food, activities, travel, objects, symbols, nature }
    • const List<EmojiEntry> kAllEmoji — a curated-but-broad set (a few hundred common emoji with names + keywords; NOT the full 1800 Unicode set — YAGNI for naming a home/kid; enough to feel complete).
    • const List<String> kCuratedHouseholdEmoji (~16–24: 🏡🏠💛⭐🌙🌳🪴🍳🧺🛋️🔆🧸…)
    • const List<String> kCuratedKidEmoji (~16–24: 🦊🐼🐯🦄🐙🐱🦖🌟🚀🐢🦋🐝…)
    • List<EmojiEntry> searchEmoji(String query) — case-insensitive match on name + keywords; empty query → kAllEmoji.
  • Step 1: Write the failing tests

packages/design_system/test/data/emoji_data_test.dart:

test('search matches name and keywords, case-insensitive', () {
expect(searchEmoji('fox').map((e) => e.char), contains('🦊'));
expect(searchEmoji('FOX').map((e) => e.char), contains('🦊'));
expect(searchEmoji('').length, kAllEmoji.length);
});
test('curated sets are non-empty, deduped, and subsets of kAllEmoji chars', () {
for (final set in [kCuratedHouseholdEmoji, kCuratedKidEmoji]) {
expect(set, isNotEmpty);
expect(set.toSet().length, set.length); // no dupes
}
});
test('every emoji entry has a non-empty name', () {
expect(kAllEmoji.every((e) => e.name.trim().isNotEmpty), isTrue);
});
  • Step 2: Run to verify it fails

Run: cd packages/design_system && fvm flutter test test/data/emoji_data_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; tail -5 /tmp/t.txt Expected: FAIL — undefined.

  • Step 3: Implement emoji_data.dart with the EmojiEntry/EmojiCategory types, a few-hundred-entry kAllEmoji (chars + names + keywords + category), the two curated lists, and searchEmoji. Export from the barrel.

  • Step 4: Run to verify pass

Run: ... fvm flutter test test/data/emoji_data_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?" → 0, "All tests passed!".

  • Step 5: analyze + format + commit

Run: cd rewhaven && fvm flutter analyze (0/0) · format.

git add packages/design_system/lib/src/data packages/design_system/lib/design_system.dart packages/design_system/test/data
git commit -m "feat(ds): categorized emoji dataset + curated household/kid sets + search"

Phase 3 — DsEmojiChip + DsEmojiPicker

Task 4: DsEmojiChip atom

Files:

  • Create: packages/design_system/lib/src/atoms/ds_emoji_chip.dart
  • Modify: packages/design_system/lib/design_system.dart (export)
  • Test: packages/design_system/test/atoms/ds_emoji_chip_test.dart + a golden in test/golden/

Interfaces:

  • Produces: class DsEmojiChip extends StatelessWidget { const DsEmojiChip({this.emoji, required this.onTap, this.semanticIdentifier, this.semanticLabel, super.key}); final String? emoji; final VoidCallback onTap; final String? semanticIdentifier; final String? semanticLabel; } — a rounded tappable tile (≥48dp) showing emoji at display size, or a neutral placeholder (a face/+ glyph in colors.inkMuted) when null. Wrapped in Semantics(identifier: semanticIdentifier, label: semanticLabel ?? 'Choose an emoji', button: true). Focus ring + AA per the DS atom floor.

  • Step 1: Write the failing widget + golden tests

testWidgets('DsEmojiChip shows the emoji and fires onTap', (tester) async {
var tapped = false;
await tester.pumpWidget(_wrap(DsEmojiChip(emoji: '🦊', onTap: () => tapped = true)));
expect(find.text('🦊'), findsOneWidget);
await tester.tap(find.byType(DsEmojiChip));
expect(tapped, isTrue);
});
testWidgets('DsEmojiChip shows a placeholder when null', (tester) async {
await tester.pumpWidget(_wrap(DsEmojiChip(emoji: null, onTap: () {})));
expect(find.text('🦊'), findsNothing);
});
// + a golden: chip filled (🦊) and chip empty, side by side (real color emoji)
  • Step 2: Run → fail. cd packages/design_system && fvm flutter test test/atoms/ds_emoji_chip_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; tail -5 /tmp/t.txt → FAIL.

  • Step 3: Implement DsEmojiChip per the interface. Export from the barrel.

  • Step 4: Run + regenerate golden → pass. Widget test exit 0; ... -t golden --update-goldens then re-run → exit 0; eyeball the golden shows a color fox.

  • Step 5: analyze + format + commit

git add packages/design_system/lib/src/atoms/ds_emoji_chip.dart packages/design_system/lib/design_system.dart packages/design_system/test/atoms packages/design_system/test/golden
git commit -m "feat(ds): DsEmojiChip atom (emoji tile / empty placeholder, a11y)"

Task 5: DsEmojiPicker molecule + showDsEmojiPicker

Files:

  • Create: packages/design_system/lib/src/molecules/ds_emoji_picker.dart
  • Modify: packages/design_system/lib/design_system.dart (export)
  • Modify: packages/design_system/gallery/lib/main.dart (story)
  • Test: packages/design_system/test/molecules/ds_emoji_picker_test.dart + a golden

Interfaces:

  • Consumes: kAllEmoji/searchEmoji/kCuratedHouseholdEmoji/kCuratedKidEmoji (Task 3), showDsSheet<String> + DsSheetPanel, DsTextField, MotionTokens.

  • Produces:

    • class DsEmojiPicker extends StatefulWidget { const DsEmojiPicker({required this.curated, this.selected, required this.onSelected, super.key}); final List<String> curated; final String? selected; final ValueChanged<String?> onSelected; } — body: the curated grid (default); a "Search more" expander revealing a DsTextField + the full grid (searchEmoji(query)), filtered live; a "None" affordance → onSelected(null). The expand animation uses MotionTokens.durationOrZero. Each emoji tile is a button with Semantics(label: <name>); a stable couple carry Semantics(identifier:).
    • Future<String?> showDsEmojiPicker(BuildContext context, {required List<String> curated, String? selected}) — opens DsEmojiPicker inside showDsSheet<String> and resolves to the chosen emoji (or null).
  • Step 1: Write the failing tests

testWidgets('curated grid shows by default; tapping selects + pops', (tester) async {
String? picked = 'unset';
await tester.pumpWidget(_wrapWithSheetButton((ctx) async {
picked = await showDsEmojiPicker(ctx, curated: kCuratedKidEmoji);
}));
await tester.tap(find.text('Open')); await tester.pumpAndSettle();
expect(find.text(kCuratedKidEmoji.first), findsWidgets); // curated visible
await tester.tap(find.text(kCuratedKidEmoji.first).first); await tester.pumpAndSettle();
expect(picked, kCuratedKidEmoji.first); // selected + popped
});
testWidgets('Search more reveals search + filters the full grid', (tester) async {
// open picker → tap "Search more" → enter "fox" → 🦊 appears
});
testWidgets('None clears the selection', (tester) async {
// open → tap "None" → onSelected(null)
});
  • Step 2: Run → fail. cd packages/design_system && fvm flutter test test/molecules/ds_emoji_picker_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; tail -5 /tmp/t.txt → FAIL.

  • Step 3: Implement DsEmojiPicker + showDsEmojiPicker. Grid of InkWell/button tiles; the "Search more" toggle animates open with the reduced-motion duration; searchEmoji drives the filtered grid; export from the barrel.

  • Step 4: Run + golden → pass. Tests exit 0; add + regenerate a sheet golden (curated state + expanded-search state) showing real color emoji; re-run → exit 0.

  • Step 5: Gallery story + gates + commit. Add a DsEmojiPicker use-case to the molecules grouping in gallery/lib/main.dart; cd packages/design_system/gallery && MSYS_NO_PATHCONV=1 fvm flutter build web --release > /tmp/b.txt 2>&1; echo "EXIT=$?" (succeeds); cd rewhaven && fvm flutter analyze (0/0); format.

git add packages/design_system/lib/src/molecules/ds_emoji_picker.dart packages/design_system/lib/design_system.dart packages/design_system/gallery/lib/main.dart packages/design_system/test/molecules packages/design_system/test/golden
git commit -m "feat(ds): DsEmojiPicker (curated grid + expandable search) + showDsEmojiPicker"

Phase 4 — Wire into the Setup wizard

Task 6: Household + kid emoji in the wizard (chip → picker → bloc)

Files:

  • Modify: app/lib/inside/routes/authenticated/setup/page.dart
  • Modify: app/.../setup/widgets/steps/name_step.dart, kid_step.dart
  • Test: app/test/flows/setup_test.dart

Interfaces:

  • Consumes: DsEmojiChip, showDsEmojiPicker, kCuratedHouseholdEmoji, kCuratedKidEmoji. The page already dispatches SetupHouseholdNamed(name, emoji) + SetupKidSubmitted(name, colorIndex, emoji) (the bloc persists emoji already).

  • Produces: the Setup page owns String? _householdEmoji / String? _kidEmoji; the name + kid steps render a DsEmojiChip (onTapshowDsEmojiPicker with the context curated set → set the state) next to the name field; the dispatched events carry the chosen emoji instead of null.

  • Step 1: Update the flow test to expect the emoji reaching the bloc

In app/test/flows/setup_test.dart's success scenario, after entering the household name tap the household DsEmojiChip (find.byKey/its Semantics(identifier:)), pick a known curated emoji, then assert the SetupHouseholdNamed event carries that emoji (the flow harness asserts dispatched events / expectedEvents). Same for the kid step + SetupKidSubmitted. Run first → FAIL (chip not present / emoji still null): cd app && MSYS_NO_PATHCONV=1 fvm flutter test test/flows/setup_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; tail -5 /tmp/t.txt

  • Step 2: Add emoji state + the chip to the steps

In setup/page.dart: add String? _householdEmoji / _kidEmoji; pass each + an onEmojiSelected callback into the steps; include them in the dispatched events (emoji: _householdEmoji / emoji: _kidEmoji). In name_step.dart + kid_step.dart: replace the "emoji deferred" comment/slot with a DsEmojiChip(emoji: emoji, semanticIdentifier: 'SetupPage.householdEmoji' | 'SetupPage.kidEmoji', onTap: () async { final picked = await showDsEmojiPicker(context, curated: kCuratedHouseholdEmoji|kCuratedKidEmoji, selected: emoji); onEmojiSelected(picked); }). Keep the existing name field + color swatch + all other Semantics/Keys.

  • Step 3: Run the flow test → pass

cd app && MSYS_NO_PATHCONV=1 fvm flutter test test/flows/setup_test.dart > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?"; tail -5 /tmp/t.txtFLUTTER_EXIT=0, "All tests passed!".

  • Step 4: Regenerate the setup goldens + full suite + gates

Regenerate setup-flow goldens (now with a chosen emoji): cd app && MSYS_NO_PATHCONV=1 fvm flutter test test/flows/setup_test.dart --update-goldens --dart-define=createScreenshots=true > /tmp/t.txt 2>&1; echo "FLUTTER_EXIT=$?". Then the WHOLE app suite with the true exit: cd app && MSYS_NO_PATHCONV=1 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 tests passed!", 0 heap/failures. cd rewhaven && fvm flutter analyze (0/0); fvm dart format ..

  • Step 5: Commit + graphify
git add app/lib/inside/routes/authenticated/setup app/test/flows/setup_test.dart
git commit -m "feat(app): emoji picker in the Setup wizard (household + kid) → reaches SetupBloc"

Then cd rewhaven && graphify update . (don't commit graphify-out).


Self-review

  • Spec coverage: color-emoji font + loaders + retire monochrome (T1, T2), emoji dataset + curated sets (T3), DsEmojiChip (T4), DsEmojiPicker + showDsEmojiPicker + sheet + search + Widgetbook (T5), wizard wiring + flow tests (T6), a11y Semantics(label/identifier) (T4/T5/T6), reduced-motion (T5), goldens in true color (T1–T5). The prove-it-first gate is T1 Step 5. ✓
  • Placeholders: none — interfaces + test code + exact commands with the true-exit-code pattern. The kAllEmoji content is "a few hundred entries" (bounded, YAGNI vs the full 1800) — the implementer authors the list; the SHAPE (EmojiEntry) + tests are concrete.
  • Type consistency: EmojiEntry{char,name,keywords,category}, searchEmoji, kCuratedHouseholdEmoji/kCuratedKidEmoji, DsEmojiChip({emoji,onTap,semanticIdentifier,semanticLabel}), DsEmojiPicker({curated,selected,onSelected}), showDsEmojiPicker(context,{curated,selected})→Future<String?>, TypographyTokens.emojiFamily are used consistently across tasks.
  • Risk gate: T1 is proof-first; if color won't rasterize in goldens, the implementer STOPS and the spec fallback applies — the picker tasks don't silently build on an unproven font.