Records: shipped 2026-05-19, 359 codepoints across 4 categories, Unicode 14 baseline (iOS < 17 tofu protection), lazy-render by tab, bundle delta +5,133 bytes uncompressed JS (well under +12 KB target). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7.6 KiB
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<EmojiCategory, readonly string[]>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_emojistores 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 infood(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_LABELSkeeps the visible tab labels in Spanish (Caras,Comida,Animales,Objetos) — mirrors the existing UI style at the integration sites, which never went through Paraglide forEMOJI_OPTIONSitself.
19.2 — EmojiPicker.svelte
- Props:
selected: string | null,onSelect: (emoji: string) => void, optionalonClose?: () => 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, cellsh-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
animalsat 140 cells — ~40% smaller than mounting all 4 categories at once. - A11y: cells are
role="gridcell"with rovingtabindex(first cell0, 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 <EmojiPicker selected={…} onSelect={…} />. Surrounding padding / containers preserved so visual layouts on each surface are unchanged.
apps/web/src/routes/(app)/settings/+page.svelte— user avatar (avatar-type=emojibranch).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 withrole="tab", defaultfacesselected, 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/settingsselects 🍎 from the Comida tab; DB row updates) + EP-E-02 (Eva on/onboardingpicks 🐱 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-jsonships 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 (
<input type="text">plus a:autocomplete).
Files indexed
apps/web/src/lib/data/emoji-catalog.ts+emoji-catalog.test.tsapps/web/src/lib/components/EmojiPicker.svelte+EmojiPicker.test.tsapps/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.tsapps/web/tests/e2e/onboarding.test.ts(O-02 driver updated)apps/web/tests/e2e/rename-collective.test.ts(MC-01a driver updated)