# Fase 19 β€” Emoji avatars expansion (food + animals) **v1.0 Β· 3/4. Third pass of the v1.0 cycle.** **Status: Shipped 2026-05-19.** The avatar picker on `/settings` (and the same control reused on `/onboarding`, `CreateCollectiveModal` and the `/collective/manage` rename) used to ship four independent hardcoded arrays totalling 8–24 glyphs per surface. Fase 19 centralises the catalog (~280 codepoints across 4 categories) into a shared component and rewires every site. Plan: `plan/fase-19-emoji-avatars.md`. No DB migration. ## What changed ### 19.1 β€” Catalog (`$lib/data/emoji-catalog.ts`) - Hardcoded `Record` with four categories: `faces` (64), `food` (119), `animals` (140), `objects` (36) = **359 codepoints** total. Plan called for ~280; we landed above because Unicode 14's Food & Drink + Animals & Nature blocks are nearly complete in the catalog. - **Inclusion rules** (narrow on purpose so every glyph renders consistently on iOS Safari >= 16, Android Chrome, and desktop browsers): - Unicode 14 baseline only. Excludes Unicode 15+ (e.g. wireless heart 🩷, light-blue heart 🩡, jellyfish πŸͺΌ) β€” iOS < 17 renders these as tofu. - No ZWJ sequences. No `πŸ‘¨β€πŸ‘©β€πŸ‘§` (family), no `πŸ•β€πŸ¦Ί` (service dog), no `πŸ³οΈβ€πŸŒˆ` (rainbow flag). Two reasons: (a) `users.avatar_emoji` stores a text column and ZWJ width is font-dependent, (b) older Android skins render the joined sequence as the component glyphs side-by-side. - No skin-tone variants (`🏻..🏿`) β€” parked. The base codepoint already renders. - Variation selectors kept where the canonical emoji form needs them (`β˜€οΈ U+2600 U+FE0F`, `πŸ•ŠοΈ U+1F54A U+FE0F`). - One semantic disambiguation: `πŸ„` lives in `food` (Animals & Nature in Unicode is the formal home, but the picker context is culinary). Enforced by EC-U-04 which would have surfaced the collision otherwise. - `CATEGORY_LABELS` keeps the visible tab labels in Spanish (`Caras`, `Comida`, `Animales`, `Objetos`) β€” mirrors the existing UI style at the integration sites, which never went through Paraglide for `EMOJI_OPTIONS` itself. ### 19.2 β€” `EmojiPicker.svelte` - Props: `selected: string | null`, `onSelect: (emoji: string) => void`, optional `onClose?: () => void`. Caller owns the persistence side-effect. - Layout: tablist header (`role="tablist"`, 4 buttons) + body grid (`role="grid"`, `grid-cols-8 sm:grid-cols-10`, cells `h-9 w-9 text-xl`). Cells match the legacy 9px button so caller layouts don't reflow. - **Lazy render**: only the active category's grid is mounted at any time. Switching tabs unmounts the previous grid and mounts the new one. Worst case is `animals` at 140 cells β€” ~40% smaller than mounting all 4 categories at once. - A11y: cells are `role="gridcell"` with roving `tabindex` (first cell `0`, rest `-1`). Arrow keys / Home / End walk focus cell-by-cell per the WAI-ARIA grid pattern. The tablist receives a single Tab stop. - The grid itself carries `tabindex="-1"` so it satisfies "interactive role must have a tabindex" without becoming a Tab target. ### 19.3 β€” Integration Four sites swapped their inline `EMOJI_OPTIONS` / `EMOJI_GRID` arrays for ``. Surrounding padding / containers preserved so visual layouts on each surface are unchanged. - `apps/web/src/routes/(app)/settings/+page.svelte` β€” user avatar (avatar-type=`emoji` branch). - `apps/web/src/routes/onboarding/+page.svelte` β€” initial collective emoji. - `apps/web/src/lib/components/CreateCollectiveModal.svelte` β€” collective creation from the sidebar. - `apps/web/src/routes/(app)/collective/manage/+page.svelte` β€” admin rename (Fase 10.2 surface). **Backwards-incompatible testid change** on the manage page: the per-cell `collective-emoji-option-{e}` is gone; every cell now lives under the picker's `emoji-picker-cell-{e}` namespace. Two existing specs were updated: `tests/e2e/onboarding.test.ts` O-02 (was selecting `getByRole('button', { name: '🏑' })`) and `tests/e2e/rename-collective.test.ts` MC-01a (was selecting `collective-emoji-option-🏑`). Both now drive the tab β†’ cell flow because 🏑 lives in the `objects` tab and the default is `faces`. ### 19.4 β€” Test coverage - `src/lib/data/emoji-catalog.test.ts` β€” EC-U-01..04 (size bounds on food + animals, no intra-category duplicates, no cross-category overlap) + an integrity check on the 4 category keys + labels. - `src/lib/components/EmojiPicker.test.ts` β€” EP-U-01..05 (4 tabs with `role="tab"`, default `faces` selected, clickβ†’`onSelect`, tab switch re-renders the grid with the new category's cells, ArrowRight moves focus to the next cell). - `tests/e2e/emoji-picker.test.ts` β€” EP-E-01 (Ana on `/settings` selects 🍎 from the Comida tab; DB row updates) + EP-E-02 (Eva on `/onboarding` picks 🐱 from Animales; collective row + sidebar update). ## Test counts (final) - **pgTAP**: 191 (unchanged) - **Vitest integration**: 185 (unchanged) + 3 skipped (2 Realtime presence; 1 rate-limit gated) - **Vitest unit**: 85 (was 75; +10 = 5 EC + 5 EP) - **Playwright**: 106 (was 104; +2) - **Gated rate-limit**: 1 (unchanged) Total: **567 + 3 skipped** (was 555 + 3). ## Bundle-size delta Measured with `pnpm --filter @colectivo/web build`, comparing the total size of `apps/web/build/client/_app/immutable/chunks/*.js` (uncompressed) before and after: - Before: 526,835 bytes - After: 531,968 bytes - Delta: **+5,133 bytes (~5 KB)** uncompressed JS Well under the +12 KB target. Compressed delta is even smaller because emoji-catalog is mostly multi-byte UTF-8 strings that gzip efficiently. No need for dynamic-import. ## Decisions worth remembering - **Catalog is hardcoded, not fetched.** ~3 KB minified β€” small enough to ship in the JS bundle, big enough that re-fetching on every picker open would be needless network. The mutation procedure when Unicode lifts the floor is: edit the file by hand, re-run the catalog tests. - **Unicode 14 baseline, not 15.** Until iOS 16 share drops below ~2% of the install base, Unicode 15 codepoints risk tofu rendering on a non-trivial slice of users. Re-evaluate in 2027. - **Lazy-render by tab, not virtualised.** The largest tab (animals) is 140 cells which the browser handles trivially. Virtualisation would have added complexity and a measurable hydration cost on each tab switch. - **No skin-tone selector.** Out of scope for the avatar use case; the base codepoint reads fine. Adding skin tones means re-shaping the picker into "base cell + modifier dropdown" β€” an order-of-magnitude bigger UX change. - **No search.** With 4 tabs and 36–140 cells each, scanning is fast. Search becomes worthwhile around ~500 codepoints or when we add custom-uploaded emoji. ## Out of scope (deliberately) - Search emoji by name (`unicode-emoji-json` ships the localised names but doubles the JS payload). - Skin-tone variants (`🏻..🏿`). - Recently-used tracking (per-user history of last-selected emoji). - Custom-uploaded emoji (per-collective sticker packs). - OS-level emoji-keyboard parity (`` plus a `:` autocomplete). ## Files indexed - `apps/web/src/lib/data/emoji-catalog.ts` + `emoji-catalog.test.ts` - `apps/web/src/lib/components/EmojiPicker.svelte` + `EmojiPicker.test.ts` - `apps/web/src/routes/(app)/settings/+page.svelte` (wired) - `apps/web/src/routes/onboarding/+page.svelte` (wired) - `apps/web/src/lib/components/CreateCollectiveModal.svelte` (wired) - `apps/web/src/routes/(app)/collective/manage/+page.svelte` (wired) - `apps/web/tests/e2e/emoji-picker.test.ts` - `apps/web/tests/e2e/onboarding.test.ts` (O-02 driver updated) - `apps/web/tests/e2e/rename-collective.test.ts` (MC-01a driver updated)