Compare commits
10 Commits
ae6ceef34f
...
mvp2
| Author | SHA1 | Date | |
|---|---|---|---|
| cbf8227dc4 | |||
| d1f6d3a5d6 | |||
| d3b4187119 | |||
| 878402e579 | |||
| e9c29dcf38 | |||
| 39ec7996c5 | |||
| ee13e7bc9d | |||
| 9068ac4586 | |||
| 2f01eb9cb0 | |||
| a075a792d6 |
@@ -9,7 +9,14 @@ labels for the area, not log entries.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- [new] Required list title + auto-numbered prefix + admin-curated title catalog (Fase 18).
|
||||
## [v1.0.0] — 2026-05-19
|
||||
|
||||
First tagged release. Closes the v1.0 cycle (Fases 17–20): versioning + changelog tooling, the shopping-list creation flow rework, the emoji picker expansion, and Basque (Euskera) as a supported locale.
|
||||
|
||||
- [new] Semver git tags + Keep-a-Changelog `CHANGELOG.md` in repo root, About modal in `/settings` renders the bundled changelog via `?raw` import, deploy script HEAD-tag check + tag column in `.deploys.log`, `scripts/check-changelog.mjs` linter wired into `just check` (Fase 17).
|
||||
- [new] Required list title + per-collective per-prefix auto-numbered `#N` parsed from the typed value, `collectives.default_list_title`, admin-curated `collective_list_titles` catalog with `add_list_title` / `remove_list_title` SECURITY DEFINER RPCs, autocomplete union (catalog + last-10) (Fase 18).
|
||||
- [new] Emoji picker expanded with full food + animals categories (~280 codepoints, Unicode 14 baseline for iOS<17 compat), new `EmojiPicker.svelte` with tabbed lazy-rendered grids replacing the 8-emoji hardcoded selector at 4 sites (Fase 19).
|
||||
- [new] Euskera (Basque) locale — `messages/eu.json`, `users.language` check constraint extended to `('en','es','eu')`, `accept-language` parser recognises `eu` / `eu-ES` / `eu-FR`, "Euskera" option in the `/settings` selector. Machine-translated seed flagged with `[needs review]` pending native review (Fase 20).
|
||||
|
||||
## [v0.0.0-beta] — 2026-05-19 — MVP + MVP2 (Fases 0–16)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**Fase 16: loading spinner** — `Spinner.svelte` (sizes sm/md/lg = 16/24/40px, `currentColor` for theme, `motion-reduce:animate-none`, `role="status"` + sr-only `m.loading()`) integrated at 7 loading sites (auth splash + search + notes archive + create-collective modal + manage page: members-list / invite generator / add-common-item button). No DB migration; reuses existing `m.loading()`. Added 5 Vitest unit (SP-U-01..05) + 3 Playwright e2e (SP-E-01..03). Vitest config gained `resolve.conditions: ['browser']` + `server.deps.inline: [/^svelte/]` so Svelte 5's `mount()` resolves the browser entry; see `docs/history/fase-16-spinner.md`. **Final cumulative MVP2 (fases 9–16): 177 pgTAP + 179 Vitest integration + 52 Vitest unit + 96 Playwright + 1 gated rate-limit = 505 tests green; 3 skipped.**
|
||||
|
||||
**v1.0 — in progress (1/4 ✅):** four fases on `mvp2` branch (will retag at the end). Aimed at producing the first tagged release. **Fase 17: versioning + CHANGELOG + about modal** — shipped 2026-05-19 (`docs/history/fase-17-versioning-changelog.md`); root `CHANGELOG.md` with `[v0.0.0-beta]` backfill of Fases 0–16 (58 bullets, `[new]/[fix]/[tweak]` prefixes), `scripts/check-changelog.mjs` linter wired into `just check`, `ChangelogModal.svelte` rendering the CHANGELOG via `?raw` build-time import (5 levels up from the component file), `/settings` › About "Ver historial" trigger, deploy-script HEAD tag warn (5s) + 4th `<tag>` column appended to `.deploys.log` (compat-safe; rollback `--list` surfaces it), `package.json` bumped to `1.0.0-rc.0` + local `v1.0.0-rc.0` tag (no push). | **Fase 18 shopping list flow** — required title (client + DB tightening), `#N` auto-numbering parsed from prefix per collective, `collectives.default_list_title` + `collective_list_titles` catalog with admin-only RPCs, autocomplete union catalog + last-10 titles (`plan/fase-18-shopping-list-flow.md`, migration 027) | **Fase 19 emoji avatars** — `EMOJI_CATALOG` lazy-load per category (faces / food / animals / objects, ~280 codepoints, Unicode 14 baseline for iOS<17 compat), new `EmojiPicker.svelte` replacing the 8-emoji hardcoded selector at 4 sites (`plan/fase-19-emoji-avatars.md`, no migration) | **Fase 20 Euskera locale** — `messages/eu.json` via machine-translate seed + `[needs review]` marker, `users.language` check constraint extended to `('en','es','eu')`, `accept-language` parser + selector in `/settings`; v1.0.0 tag lands at the end of this fase (`plan/fase-20-euskera-locale.md`, migration 028).
|
||||
**v1.0 — COMPLETE (4/4 ✅, 2026-05-19, tag `v1.0.0`).** Four fases on `mvp2` branch produced the first tagged release. **Fase 17: versioning + CHANGELOG + about modal** — root `CHANGELOG.md` with `[v0.0.0-beta]` backfill of Fases 0–16 (58 bullets, `[new]/[fix]/[tweak]` prefixes), `scripts/check-changelog.mjs` linter wired into `just check`, `ChangelogModal.svelte` rendering the CHANGELOG via `?raw` build-time import, `/settings` › About "Ver historial" trigger, deploy-script HEAD tag warn (5s) + 4th `<tag>` column appended to `.deploys.log` (compat-safe; rollback `--list` surfaces it); see `docs/history/fase-17-versioning-changelog.md`. **Fase 18: shopping list flow** — required title (client + DB tightening), `#N` auto-numbering parsed per-collective per-prefix from typed value, migration 027 (`collectives.default_list_title` + `collective_list_titles` table + `add_list_title` / `remove_list_title` SECURITY DEFINER RPCs, P0002 on non-admin), `CreateListModal.svelte` replaces the empty-create-then-rename flow with prefilled default + suggestion chip + auto-suffix on submit, `/collective/manage` "Títulos sugeridos" subsection (admin write, member read-only). pgTAP 019; see `docs/history/fase-18-shopping-list-flow.md`. **Fase 19: emoji avatars** — `EMOJI_CATALOG` (~280 codepoints, Unicode 14 baseline for iOS<17 compat) across faces/food/animals/objects, new `EmojiPicker.svelte` with tabbed lazy-rendered grids + arrow-key nav replacing the 8-emoji hardcoded selector at 4 sites (settings / onboarding / create-collective modal / manage); no migration; see `docs/history/fase-19-emoji-avatars.md`. **Fase 20: Euskera locale** — `messages/eu.json` machine-translated seed flagged with `[needs review]`, migration 028 (`users.language` check extended to `('en','es','eu')`), `accept-language` recognises `eu`/`eu-ES`/`eu-FR`, "Euskera" option in `/settings`; pgTAP 020; see `docs/history/fase-20-euskera-locale.md`. **Release v1.0.0 cut from commit `d1f6d3a`** (CHANGELOG `[Unreleased]` flushed to `## [v1.0.0]`, `package.json` bumped to `1.0.0`, local tag `v1.0.0`, no push). **Final cumulative v1.0 tests (`just test-all`): 196 pgTAP + 187 Vitest integration + 89 Vitest unit + 108 Playwright + 1 gated rate-limit = 581 tests green; 3 skipped (2 Realtime presence — upstream `handle_out/3` bug `supabase/realtime#1617`; 1 rate-limit — gated). Delta vs MVP2 close: +19 pgTAP + +8 integration + +37 unit + +12 e2e = +76 tests across v1.0 fases 17–20.**
|
||||
|
||||
## Documentation Map
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -28,12 +28,12 @@
|
||||
| ↳ Fase 14 — PWA hardening | ✅ |
|
||||
| ↳ Fase 15 — Common items management | ✅ |
|
||||
| ↳ Fase 16 — Loading spinner | ✅ |
|
||||
| **v1.0** — Fase 17 → 20 | 📋 Planificada |
|
||||
| ↳ Fase 17 — Versioning + CHANGELOG | 📋 |
|
||||
| ↳ Fase 18 — Shopping list flow | 📋 |
|
||||
| ↳ Fase 19 — Emoji avatars | 📋 |
|
||||
| ↳ Fase 20 — Euskera locale | 📋 (cierra `v1.0.0`) |
|
||||
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ **505 tests verdes** (177 pgTAP + 179 integración + 52 unit + 96 E2E) + 1 rate-limit gated (`just test-rate-limit`). 3 skipped en `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). |
|
||||
| **v1.0** — Fase 17 → 20 | ✅ Completa (rama `mvp2`, tag `v1.0.0`, 2026-05-19) |
|
||||
| ↳ Fase 17 — Versioning + CHANGELOG | ✅ |
|
||||
| ↳ Fase 18 — Shopping list flow | ✅ |
|
||||
| ↳ Fase 19 — Emoji avatars | ✅ |
|
||||
| ↳ Fase 20 — Euskera locale | ✅ (`v1.0.0`) |
|
||||
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ **581 tests verdes** (196 pgTAP + 187 integración + 89 unit + 108 E2E) + 1 rate-limit gated (`just test-rate-limit`). 3 skipped en `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
365
apps/web/messages/eu.json
Normal file
365
apps/web/messages/eu.json
Normal file
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_lists": "Zerrendak [needs review]",
|
||||
"nav_tasks": "Zereginak [needs review]",
|
||||
"nav_notes": "Oharrak [needs review]",
|
||||
"nav_search": "Bilatu [needs review]",
|
||||
"nav_settings": "Ezarpenak [needs review]",
|
||||
"nav_collective": "Kolektiboa [needs review]",
|
||||
"app_name": "Colectivo [needs review]",
|
||||
"loading": "Kargatzen… [needs review]",
|
||||
"error_generic": "Zerbait gaizki joan da. Saiatu berriro, mesedez. [needs review]",
|
||||
"login_title": "Sartu Colectivo-n [needs review]",
|
||||
"login_button": "Saioa hasi [needs review]",
|
||||
"logout_button": "Saioa itxi [needs review]",
|
||||
"auth_back_to_home": "Hasierara itzuli [needs review]",
|
||||
"logged_out_title": "Saioa itxita [needs review]",
|
||||
"logged_out_subtitle": "Hasi saioa berriro jarraitzeko. [needs review]",
|
||||
"logged_out_sign_in": "Saioa hasi [needs review]",
|
||||
"onboarding_title": "Ongi etorri Colectivo-ra [needs review]",
|
||||
"onboarding_subtitle": "Sortu kolektibo berri bat edo elkartu dagoen batekin. [needs review]",
|
||||
"onboarding_create_tab": "Sortu [needs review]",
|
||||
"onboarding_join_tab": "Elkartu [needs review]",
|
||||
"onboarding_create_collective": "Sortu kolektibo bat [needs review]",
|
||||
"onboarding_join_collective": "Elkartu gonbidapen esteka batekin [needs review]",
|
||||
"onboarding_collective_name": "Kolektiboaren izena [needs review]",
|
||||
"onboarding_collective_name_placeholder": "adib. Gure Etxea [needs review]",
|
||||
"onboarding_collective_emoji": "Ikonoa [needs review]",
|
||||
"onboarding_invite_link_label": "Gonbidapen esteka [needs review]",
|
||||
"onboarding_invite_link_placeholder": "Itsatsi hemen gonbidapen esteka… [needs review]",
|
||||
"onboarding_create_btn": "Sortu kolektiboa [needs review]",
|
||||
"onboarding_join_btn": "Elkartu kolektiboarekin [needs review]",
|
||||
"collective_name_label": "Kolektiboaren izena [needs review]",
|
||||
"collective_name_placeholder": "adib. Gure Etxea [needs review]",
|
||||
"invitation_title": "Gonbidapen bat duzu [needs review]",
|
||||
"invitation_subtitle": "Kolektibo batera elkartzeko gonbidatu zaituzte. [needs review]",
|
||||
"invitation_accept_btn": "Gonbidapena onartu [needs review]",
|
||||
"invitation_success": "Kolektiboari elkartu zara! [needs review]",
|
||||
"invitation_invalid_link": "Honek ez dirudi baliozko gonbidapen esteka. [needs review]",
|
||||
"invitation_error_not_found": "Gonbidapen esteka hau ez da baliozkoa. [needs review]",
|
||||
"invitation_error_already_used": "Gonbidapen hau dagoeneko erabili da. [needs review]",
|
||||
"invitation_error_expired": "Gonbidapen hau iraungita dago. [needs review]",
|
||||
"invitation_error_already_member": "Dagoeneko kolektibo honetako kide zara. [needs review]",
|
||||
"invitation_error_unauthenticated": "Gonbidapena onartzeko, saioa hasi behar duzu. [needs review]",
|
||||
"invitation_error_unknown": "Zerbait gaizki joan da. Saiatu berriro, mesedez. [needs review]",
|
||||
"manage_title": "Kolektiboa kudeatu [needs review]",
|
||||
"manage_members": "Kideak [needs review]",
|
||||
"manage_you": "zu [needs review]",
|
||||
"manage_remove": "Kidea ezabatu [needs review]",
|
||||
"manage_invite_title": "Inor gonbidatu [needs review]",
|
||||
"manage_invite_role": "Rola [needs review]",
|
||||
"manage_generate_link": "Gonbidapen esteka sortu [needs review]",
|
||||
"manage_copy": "Kopiatu [needs review]",
|
||||
"manage_copied": "Kopiatuta! [needs review]",
|
||||
"manage_link_expires": "Esteka 7 egunetan iraungiko da. [needs review]",
|
||||
"role_admin": "Administratzailea [needs review]",
|
||||
"role_member": "Kidea [needs review]",
|
||||
"role_guest": "Gonbidatua [needs review]",
|
||||
"settings_title": "Ezarpenak [needs review]",
|
||||
"settings_display_name": "Ikusgai dagoen izena [needs review]",
|
||||
"settings_saving": "Gordetzen… [needs review]",
|
||||
"settings_language": "Hizkuntza [needs review]",
|
||||
"settings_avatar": "Abatarra [needs review]",
|
||||
"settings_avatar_initials": "Inizialak [needs review]",
|
||||
"settings_avatar_emoji": "Emoji [needs review]",
|
||||
"settings_avatar_upload": "Argazkia [needs review]",
|
||||
"settings_avatar_upload_btn": "Argazkia igo… [needs review]",
|
||||
"settings_account": "Kontua [needs review]",
|
||||
"settings_keycloak_link": "Helbide elektronikoa edo pasahitza aldatu [needs review]",
|
||||
"settings_logout": "Saioa itxi [needs review]",
|
||||
"settings_save": "Aldaketak gorde [needs review]",
|
||||
"settings_saved": "Aldaketak gordeta [needs review]",
|
||||
"invite_member": "Kidea gonbidatu [needs review]",
|
||||
"invite_email_label": "Helbide elektronikoa [needs review]",
|
||||
"invite_send": "Gonbidapena bidali [needs review]",
|
||||
"masthead_lists_label": "Espazioa [needs review]",
|
||||
"masthead_tasks_label": "Zereginak [needs review]",
|
||||
"masthead_notes_label": "Biltegia [needs review]",
|
||||
"masthead_search_label": "Bilatu [needs review]",
|
||||
"masthead_search_title": "Aurkitu dena. [needs review]",
|
||||
"lists_title": "Erosketa zerrendak [needs review]",
|
||||
"lists_new_list": "Zerrenda berria [needs review]",
|
||||
"lists_no_lists": "Zerrendarik ez [needs review]",
|
||||
"lists_create_first": "Sortu zure lehen erosketa zerrenda [needs review]",
|
||||
"lists_trash": "Zakarrontzia [needs review]",
|
||||
"list_status_active": "Zerrenda aktiboa [needs review]",
|
||||
"list_status_completed": "Osatua [needs review]",
|
||||
"list_status_archived": "Artxibatua [needs review]",
|
||||
"list_name_placeholder": "adib. Asteko erosketa [needs review]",
|
||||
"list_items_empty": "Zerrenda hutsa [needs review]",
|
||||
"list_items_empty_hint": "Gehitu zure lehen produktua behean [needs review]",
|
||||
"list_reset": "Zerrenda berrabiarazi [needs review]",
|
||||
"list_archive": "Artxibatu [needs review]",
|
||||
"list_delete": "Zakarrontzira eraman [needs review]",
|
||||
"list_actions": "Zerrendaren ekintzak [needs review]",
|
||||
"list_item_placeholder": "Produktua gehitu… [needs review]",
|
||||
"list_qty_label": "Kop. [needs review]",
|
||||
"list_add_item": "Produktua gehitu… [needs review]",
|
||||
"list_to_buy": "Erosteko [needs review]",
|
||||
"list_checked": "Markatuta [needs review]",
|
||||
"list_start_session": "Erosi [needs review]",
|
||||
"list_finish_shopping": "Erosketa amaitu [needs review]",
|
||||
"list_finish_confirm": "Zerrenda hau osatutzat eman? [needs review]",
|
||||
"list_finish_confirm_yes": "Bai, amaitu [needs review]",
|
||||
"list_finish_confirm_no": "Erosten jarraitu [needs review]",
|
||||
"tasks_title": "Zereginak [needs review]",
|
||||
"tasks_new_list": "Zerrenda berria [needs review]",
|
||||
"tasks_no_lists": "Zeregin zerrendarik ez [needs review]",
|
||||
"tasks_create_first": "Sortu zure lehen zeregin zerrenda [needs review]",
|
||||
"tasks_new_list_placeholder": "Zeregin zerrenda berria [needs review]",
|
||||
"tasks_new_task_placeholder": "Zeregina gehitu [needs review]",
|
||||
"tasks_empty": "Zereginik ez [needs review]",
|
||||
"tasks_empty_hint": "Gehitu zure lehen zeregina behean [needs review]",
|
||||
"tasks_completed_section": "Osatuak [needs review]",
|
||||
"tasks_pending_section": "Egiteke [needs review]",
|
||||
"tasks_completed_by": "{name}-(e)k osatua [needs review]",
|
||||
"tasks_toggle": "Zeregina txandakatu [needs review]",
|
||||
"tasks_uncheck": "Zeregina desmarkatu [needs review]",
|
||||
"tasks_delete": "Zeregina ezabatu [needs review]",
|
||||
"tasks_delete_list": "Zerrenda ezabatu [needs review]",
|
||||
"tasks_delete_list_confirm": "Zerrenda hau eta bere zeregin guztiak ezabatu? [needs review]",
|
||||
"notes_title": "Oharrak [needs review]",
|
||||
"notes_new": "Ohar berria [needs review]",
|
||||
"notes_pinned_section": "Finkatuak [needs review]",
|
||||
"notes_active_section": "Oharrak [needs review]",
|
||||
"notes_archive": "Artxibatu [needs review]",
|
||||
"notes_archive_view": "Artxibatutako oharrak [needs review]",
|
||||
"notes_archived_empty": "Artxibatutako oharrik ez [needs review]",
|
||||
"notes_trash_view": "Oharren zakarrontzia [needs review]",
|
||||
"notes_trash_empty": "Zakarrontzia hutsik dago [needs review]",
|
||||
"notes_pin": "Oharra finkatu [needs review]",
|
||||
"notes_unpin": "Oharra desfinkatu [needs review]",
|
||||
"notes_unarchive": "Oharra desartxibatu [needs review]",
|
||||
"notes_send_to_trash": "Zakarrontzira bidali [needs review]",
|
||||
"notes_restore": "Berreskuratu [needs review]",
|
||||
"notes_duplicate": "Bikoiztu [needs review]",
|
||||
"notes_color": "Kolorea [needs review]",
|
||||
"notes_title_placeholder": "Izenburua [needs review]",
|
||||
"notes_content_placeholder": "Hasi idazten… [needs review]",
|
||||
"notes_content_aria": "Edukia [needs review]",
|
||||
"notes_back_to_board": "Taulara itzuli [needs review]",
|
||||
"notes_saved": "Gordeta [needs review]",
|
||||
"notes_saving": "Gordetzen… [needs review]",
|
||||
"notes_empty_board": "Oharrik ez [needs review]",
|
||||
"notes_empty_board_hint": "Sakatu \"Ohar berria\" hasteko [needs review]",
|
||||
"search_title": "Bilatu [needs review]",
|
||||
"search_placeholder": "Bilatu zerrendak, zereginak, oharrak… [needs review]",
|
||||
"search_filter_all": "Dena [needs review]",
|
||||
"search_filter_lists": "Zerrendak [needs review]",
|
||||
"search_filter_tasks": "Zereginak [needs review]",
|
||||
"search_filter_notes": "Oharrak [needs review]",
|
||||
"search_group_shopping_items": "Produktuak [needs review]",
|
||||
"search_group_tasks": "Zereginak [needs review]",
|
||||
"search_group_notes": "Oharrak [needs review]",
|
||||
"search_empty": "Emaitzarik ez [needs review]",
|
||||
"search_hint": "Idatzi gutxienez bi karaktere [needs review]",
|
||||
"trash_restore": "Berreskuratu [needs review]",
|
||||
"trash_delete_permanent": "Behin betiko ezabatu [needs review]",
|
||||
"trash_empty": "Zakarrontzia hutsik dago [needs review]",
|
||||
"trash_expires_in": "{days} egunetan iraungiko da [needs review]",
|
||||
"confirm_delete": "Ezabatu? [needs review]",
|
||||
"cancel": "Utzi [needs review]",
|
||||
"save": "Gorde [needs review]",
|
||||
"action_delete": "Ezabatu [needs review]",
|
||||
"edit": "Editatu [needs review]",
|
||||
"add": "Gehitu [needs review]",
|
||||
"done": "Eginda [needs review]",
|
||||
"pwa_update_available": "Bertsio berria eskuragarri [needs review]",
|
||||
"pwa_update_reload": "Birkargatu [needs review]",
|
||||
"pwa_offline_ready": "Lineaz kanpo erabiltzeko prest [needs review]",
|
||||
"pwa_offline_chip": "Konexiorik gabe [needs review]",
|
||||
"pwa_offline_tooltip": "Konexiorik gabe — aldaketak itzultzean sinkronizatuko dira [needs review]",
|
||||
"pwa_pending_ops": "{count} aldaketa zain [needs review]",
|
||||
"pwa_pending_ops_plural": "{count} aldaketa zain [needs review]",
|
||||
"pwa_sync_conflicts_banner": "Sinkronizazio gatazkak daude [needs review]",
|
||||
"pwa_sync_conflicts_review": "Berrikusi [needs review]",
|
||||
"sync_conflicts_title": "Sinkronizazio gatazkak [needs review]",
|
||||
"sync_conflicts_empty": "Ezer zain — lokala eta zerbitzaria bat datoz. [needs review]",
|
||||
"sync_conflicts_discard_local": "Lokala baztertu [needs review]",
|
||||
"sync_conflicts_discard_remote": "Urrunekoa baztertu [needs review]",
|
||||
"sync_conflicts_back": "Ezarpenetara itzuli [needs review]",
|
||||
"settings_about": "Honi buruz [needs review]",
|
||||
"settings_about_version": "v{version} · {commit} · {date} [needs review]",
|
||||
"settings_about_view_changelog": "Historia ikusi [needs review]",
|
||||
"changelog_modal_title": "Aldaketen historia [needs review]",
|
||||
"changelog_modal_close": "Itxi [needs review]",
|
||||
"sync_offline": "Konexiorik gabe — aldaketak berriz konektatzean sinkronizatuko dira [needs review]",
|
||||
"sync_syncing": "Sinkronizatzen… [needs review]",
|
||||
"undo": "Desegin [needs review]",
|
||||
"undo_deleted_item": "{name} ezabatuta [needs review]",
|
||||
"list_item_delete_aria": "Produktua ezabatu [needs review]",
|
||||
"list_qty_decrease": "Kopurua jaitsi [needs review]",
|
||||
"list_qty_increase": "Kopurua igo [needs review]",
|
||||
"list_reorder_handle": "Berrantolatu [needs review]",
|
||||
"list_edit_item": "Produktua editatu [needs review]",
|
||||
"list_confirm": "Baieztatu [needs review]",
|
||||
"list_close": "Itxi [needs review]",
|
||||
"list_select": "Hautatu [needs review]",
|
||||
"list_selection_count": "{n} hautatuta [needs review]",
|
||||
"list_cancel_selection": "Utzi [needs review]",
|
||||
"list_bulk_delete": "Ezabatu [needs review]",
|
||||
"list_bulk_move": "Mugitu [needs review]",
|
||||
"list_bulk_mark_checked": "Markatu [needs review]",
|
||||
"list_undo_bulk_delete": "{n} ezabatuta [needs review]",
|
||||
"list_move_title": "Zerrendara eraman [needs review]",
|
||||
"list_move_create_new": "Zerrenda berria sortu [needs review]",
|
||||
"settings_appearance": "Itxura [needs review]",
|
||||
"settings_theme_light": "Argia [needs review]",
|
||||
"settings_theme_dark": "Iluna [needs review]",
|
||||
"settings_theme_system": "Sistema [needs review]",
|
||||
"settings_sync_conflicts": "Sinkronizazio gatazkak [needs review]",
|
||||
"settings_sync_conflicts_empty": "Erregistratutako gatazkarik ez. [needs review]",
|
||||
"settings_sync_conflicts_export": "JSON esportatu [needs review]",
|
||||
"settings_collectives": "Zure kolektiboak [needs review]",
|
||||
"settings_collectives_empty": "Ez zara inongo kolektiboko kide. [needs review]",
|
||||
"settings_collectives_leave": "Utzi [needs review]",
|
||||
"settings_collectives_confirm_leave_title": "{name} utzi? [needs review]",
|
||||
"settings_collectives_confirm_leave_body": "Kolektibo honetarako sarbidea galduko duzu. Edukia gainerako kideentzat geratuko da. [needs review]",
|
||||
"settings_collectives_confirm_leave_button": "Kolektiboa utzi [needs review]",
|
||||
"settings_collectives_dissolve_hint": "Kide bakarra zara — erabili 'Desegin' kolektiboaren orrian horren ordez. [needs review]",
|
||||
"settings_danger_zone": "Eremu arriskutsua [needs review]",
|
||||
"settings_delete_account": "Nire kontua ezabatu [needs review]",
|
||||
"settings_delete_account_blurb": "Behin betiko ezabatzen ditu zure kontua eta zure kidetza guztiak. Sortu duzun edukia kolektiboetan geratuko da, ezabatutako erabiltzaile bati esleitua. [needs review]",
|
||||
"settings_delete_account_modal_title": "Zure kontua ezabatu? [needs review]",
|
||||
"settings_delete_account_modal_body": "Hau behin betikoa da. Ezabatzen dena: zure kontua, zure kidetzak, zure hizkuntza eta gai gordeak. Geratzen dena: sortu dituzun zerrendak, zereginak edo oharrak — euren kolektiboetan geratzen dira, ezabatutako erabiltzaile bati esleituak. Kolektiboren bateko administratzaile bakarra bazara, kide zaharrena automatikoki sustatuko da. Oharra: identitate-hornitzailearen kontua EZ da ezabatzen; helbide elektroniko berarekin berriro erregistratu zaitezke eta profil berri bat sortuko da. [needs review]",
|
||||
"settings_delete_account_confirm_label": "Idatzi {word} baieztatzeko [needs review]",
|
||||
"settings_delete_account_confirm_word": "EZABATU [needs review]",
|
||||
"settings_delete_account_button": "Kontua behin betiko ezabatu [needs review]",
|
||||
"settings_delete_account_blocked": "Sustatu ezin diren kideak dituen kolektibo bateko administratzaile bakarra zara. Sustatu norbait lehenik edo ezabatu gainerako kideak. [needs review]",
|
||||
"common_cancel": "Utzi [needs review]",
|
||||
"manage_collective_name_label": "Kolektiboaren izena [needs review]",
|
||||
"manage_collective_emoji_label": "Ikonoa [needs review]",
|
||||
"manage_collective_save": "Gorde [needs review]",
|
||||
"manage_dissolve_section": "Eremu arriskutsua [needs review]",
|
||||
"manage_dissolve_button": "Kolektiboa desegin [needs review]",
|
||||
"manage_dissolve_blurb": "Kolektibo hau eta bere eduki guztia behin betiko ezabatzen ditu. Ezin da desegin. [needs review]",
|
||||
"manage_dissolve_title": "{name} desegin? [needs review]",
|
||||
"manage_dissolve_warning": "{lists} zerrenda, {tasks} zeregin eta {notes} ohar ezabatuko dira. Ekintza hau itzulezina da. [needs review]",
|
||||
"manage_dissolve_confirm_label": "Idatzi {name} baieztatzeko [needs review]",
|
||||
"manage_dissolve_confirm_button": "Behin betiko desegin [needs review]",
|
||||
"sidebar_create_collective": "+ Kolektibo berria [needs review]",
|
||||
"create_collective_modal_title": "Kolektibo berria [needs review]",
|
||||
"create_collective_modal_button": "Sortu [needs review]",
|
||||
"tag_picker_placeholder": "Etiketa bilatu edo sortu [needs review]",
|
||||
"tag_picker_create": "«{name}» sortu [needs review]",
|
||||
"tag_picker_empty": "Oraindik ez dago etiketarik. [needs review]",
|
||||
"tag_picker_label": "Etiketak [needs review]",
|
||||
"list_filter_by_tag": "Etiketaz iragazi [needs review]",
|
||||
"list_filter_clear": "Garbitu [needs review]",
|
||||
"settings_tags_section": "Etiketak [needs review]",
|
||||
"settings_tags_empty": "Oraindik ez dago etiketarik — sortu edozein zerrendatatik. [needs review]",
|
||||
"settings_tags_delete": "Ezabatu [needs review]",
|
||||
"settings_tags_color": "Kolorea [needs review]",
|
||||
"section_disabled_for_collective": "Atal hau kolektibo honentzat ezkutatuta dago. [needs review]",
|
||||
"settings_section_visibility_title": "Ikusgai dauden atalak [needs review]",
|
||||
"settings_section_visibility_blurb": "Ezkutatu erabiltzen ez dituzun goi-mailako atalak. Zure administratzaileak ere kolektibo osorako ezkuta ditzake. [needs review]",
|
||||
"settings_section_visibility_overridden": "Kolektiboak ezkutatua. [needs review]",
|
||||
"collective_section_visibility_title": "Kolektiboaren atalak [needs review]",
|
||||
"collective_section_visibility_blurb": "Ezkutatu atalak kolektibo honetako kide guztientzat. Kide bakoitzak ere bere buruarentzat ezkuta ditzake. [needs review]",
|
||||
"section_label_lists": "Zerrendak [needs review]",
|
||||
"section_label_tasks": "Zereginak [needs review]",
|
||||
"section_label_notes": "Oharrak [needs review]",
|
||||
"section_label_search": "Bilatu [needs review]",
|
||||
"admin_banner": "Admin modua — ekintzak erregistratzen dira [needs review]",
|
||||
"admin_nav_collectives": "Kolektiboak [needs review]",
|
||||
"admin_nav_admins": "Administratzaileak [needs review]",
|
||||
"admin_nav_audit": "Erregistroa [needs review]",
|
||||
"admin_nav_server": "Zerbitzaria [needs review]",
|
||||
"admin_menu_entry": "Zerbitzariko administratzailea [needs review]",
|
||||
"admin_collectives_title": "Kolektiboak [needs review]",
|
||||
"admin_collectives_search_placeholder": "Izenez bilatu… [needs review]",
|
||||
"admin_collectives_empty": "Ez dator bat kolektiborik. [needs review]",
|
||||
"admin_collectives_col_name": "Izena [needs review]",
|
||||
"admin_collectives_col_members": "Kideak [needs review]",
|
||||
"admin_collectives_col_created": "Sortua [needs review]",
|
||||
"admin_collectives_col_status": "Egoera [needs review]",
|
||||
"admin_collectives_status_active": "Aktiboa [needs review]",
|
||||
"admin_collectives_status_deleted": "Ezabatua [needs review]",
|
||||
"admin_action_view": "Ikusi [needs review]",
|
||||
"admin_action_soft_delete": "Soft-delete [needs review]",
|
||||
"admin_action_restore": "Berreskuratu [needs review]",
|
||||
"admin_action_hard_delete": "Ezabatu [needs review]",
|
||||
"admin_action_remove": "Kanporatu [needs review]",
|
||||
"admin_modal_soft_delete_title": "Kolektiboa soft-delete egin [needs review]",
|
||||
"admin_modal_soft_delete_help": "Kolektiboa ezkutatuta geratzen da kideentzat, baina berreskuragarri 30 egunez. Arrazoia erregistratzen da. [needs review]",
|
||||
"admin_modal_reason_label": "Arrazoia [needs review]",
|
||||
"admin_modal_reason_placeholder": "Zergatik? [needs review]",
|
||||
"admin_modal_hard_delete_title": "Kolektiboa ezabatu [needs review]",
|
||||
"admin_modal_hard_delete_help": "Hau itzulezina da. Zerrenda, item, zeregin eta ohar guztiak ezabatuko dira. [needs review]",
|
||||
"admin_modal_hard_delete_confirm": "Itzulezina dela ulertzen dut [needs review]",
|
||||
"admin_modal_hard_delete_force_label": "Behartu (30 eguneko itxaronaldia saltatu) [needs review]",
|
||||
"admin_modal_cancel": "Utzi [needs review]",
|
||||
"admin_modal_confirm": "Baieztatu [needs review]",
|
||||
"admin_collective_detail_back": "Kolektiboetara itzuli [needs review]",
|
||||
"admin_collective_detail_members": "Kideak [needs review]",
|
||||
"admin_collective_detail_recent_actions": "Azken ekintzak [needs review]",
|
||||
"admin_modal_remove_member_title": "Kidea kanporatu [needs review]",
|
||||
"admin_modal_remove_member_help": "Kidetza berehala ezeztatzen da. Arrazoia erregistratzen da. [needs review]",
|
||||
"admin_admins_title": "Zerbitzariko administratzaileak [needs review]",
|
||||
"admin_admins_promote": "Erabiltzailea sustatu [needs review]",
|
||||
"admin_admins_promote_help": "Sartu sustatu beharreko erabiltzailearen helbide elektronikoa. [needs review]",
|
||||
"admin_admins_email_label": "Helbide elektronikoa [needs review]",
|
||||
"admin_admins_email_placeholder": "erabiltzailea@adibidea.com [needs review]",
|
||||
"admin_admins_email_not_found": "Helbide elektroniko hori duen erabiltzailerik ez. [needs review]",
|
||||
"admin_admins_revoke_self": "zu (ezin duzu zeure burua ezeztatu administratzaile bakarra bazara) [needs review]",
|
||||
"admin_admins_revoke": "Ezeztatu [needs review]",
|
||||
"admin_audit_title": "Ekintzen erregistroa [needs review]",
|
||||
"admin_audit_empty": "Oraindik ez da ekintzarik erregistratu. [needs review]",
|
||||
"admin_audit_col_when": "Noiz [needs review]",
|
||||
"admin_audit_col_actor": "Eragilea [needs review]",
|
||||
"admin_audit_col_action": "Ekintza [needs review]",
|
||||
"admin_audit_col_target": "Helburua [needs review]",
|
||||
"admin_audit_col_payload": "Payload [needs review]",
|
||||
"admin_server_title": "Zerbitzariaren konfigurazioa [needs review]",
|
||||
"admin_server_default_sections": "Atalen lehenetsitako ikusgaitasuna [needs review]",
|
||||
"admin_server_default_sections_help": "Zerbitzari mailako override-a. Hemen ON edo OFF jartzeak edozein kolektibo edo erabiltzaile gainditzen du. [needs review]",
|
||||
"admin_server_section_state_off": "OFF [needs review]",
|
||||
"admin_server_section_state_on": "ON [needs review]",
|
||||
"admin_server_section_state_unset": "Iritzirik gabe [needs review]",
|
||||
"admin_server_info": "Zerbitzariaren informazioa [needs review]",
|
||||
"admin_error_forbidden": "Ez duzu eremu honetarako sarbiderik. [needs review]",
|
||||
"admin_error_unknown": "Zerbait gaizki joan da. Saiatu berriro. [needs review]",
|
||||
"common_items_title": "Item ohikoak [needs review]",
|
||||
"common_items_blurb": "Sustatu beti iradokizunetan lehenik agertzea nahi dituzun itemak, edo ezkutatu ikusi nahi ez dituzunak. Kideek katalogoa ikusten dute baina ezin dute editatu. [needs review]",
|
||||
"common_items_search_placeholder": "Itemak bilatu… [needs review]",
|
||||
"common_items_empty": "Oraindik ez dago item ohikorik. Zerrendetara gehitzen diren itemak hemen agertuko dira. [needs review]",
|
||||
"common_items_empty_filtered": "Ez dator bat itemik zure bilaketarekin. [needs review]",
|
||||
"common_items_col_name": "Izena [needs review]",
|
||||
"common_items_col_weight": "Ikusgaitasuna [needs review]",
|
||||
"common_items_col_uses": "Erabilerak [needs review]",
|
||||
"common_items_col_last_used": "Azken erabilera [needs review]",
|
||||
"common_items_col_actions": "Ekintzak [needs review]",
|
||||
"common_items_state_hide": "Ezkutatu [needs review]",
|
||||
"common_items_state_normal": "Normala [needs review]",
|
||||
"common_items_state_boost": "Nabarmendu [needs review]",
|
||||
"common_items_state_locked_tooltip": "Administratzaileek soilik kudea ditzakete item ohikoak. [needs review]",
|
||||
"common_items_purge_button": "Ezabatu [needs review]",
|
||||
"common_items_purge_modal_title": "{name} ezabatu? [needs review]",
|
||||
"common_items_purge_modal_body": "Honek iradokizunetatik sarrera kentzen du soilik. Dagoeneko zerrendetan dauden itemak ez dira aldatzen. [needs review]",
|
||||
"common_items_purge_confirm": "Ezabatu [needs review]",
|
||||
"common_items_add_button": "Item komuna gehitu [needs review]",
|
||||
"common_items_add_modal_title": "Item komuna gehitu [needs review]",
|
||||
"common_items_add_name_label": "Itemaren izena [needs review]",
|
||||
"common_items_add_name_placeholder": "adib. oliba olioa [needs review]",
|
||||
"common_items_add_visibility_label": "Ikusgaitasuna [needs review]",
|
||||
"common_items_add_save": "Gehitu [needs review]",
|
||||
"create_list_modal_title": "Erosketa zerrenda berria [needs review]",
|
||||
"create_list_input_label": "Zerrendaren izena [needs review]",
|
||||
"create_list_input_placeholder": "adib. Asteko erosketa [needs review]",
|
||||
"create_list_submit": "Sortu [needs review]",
|
||||
"create_list_suggestion_chip": "Iradokizuna: {title} [needs review]",
|
||||
"create_list_auto_numbered_toast": "«{title}» bezala sortua [needs review]",
|
||||
"manage_list_titles_section_title": "Iradokitako izenburuak [needs review]",
|
||||
"manage_list_titles_blurb": "Aurrekonfiguratu zerrenda berrientzat lehenetsitako izenburu bat eta mantendu iradokizun zerrenda bat. [needs review]",
|
||||
"manage_default_list_title_label": "Lehenetsitako izenburua [needs review]",
|
||||
"manage_default_list_title_placeholder": "adib. Asteko erosketa [needs review]",
|
||||
"manage_default_list_title_clear": "Garbitu [needs review]",
|
||||
"manage_list_titles_empty": "Oraindik ez dago iradokitako izenbururik. [needs review]",
|
||||
"manage_list_titles_add_button": "Izenburua gehitu [needs review]",
|
||||
"manage_list_titles_add_modal_title": "Iradokitako izenburua gehitu [needs review]",
|
||||
"manage_list_titles_add_name_label": "Izenburua [needs review]",
|
||||
"manage_list_titles_add_name_placeholder": "adib. Erosketa [needs review]",
|
||||
"manage_list_titles_add_save": "Gehitu [needs review]",
|
||||
"manage_list_titles_remove": "Ezabatu [needs review]",
|
||||
"manage_list_titles_readonly": "Administratzaileek soilik kudea ditzakete izenburu hauek. [needs review]"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"sourceLanguageTag": "en",
|
||||
"languageTags": ["en", "es"],
|
||||
"languageTags": ["en", "es", "eu"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@1/dist/index.js"
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import EmojiPicker from './EmojiPicker.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
const EMOJI_OPTIONS = [
|
||||
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
|
||||
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
|
||||
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
|
||||
];
|
||||
// Fase 19 — emoji catalog moved into the shared `EmojiPicker`. The 24
|
||||
// hardcoded city/house glyphs the old `EMOJI_OPTIONS` array shipped are
|
||||
// covered by the new "objects" tab.
|
||||
|
||||
let name = $state('');
|
||||
let emoji = $state('🏠');
|
||||
@@ -73,19 +72,7 @@
|
||||
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.onboarding_collective_emoji()}
|
||||
</p>
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
{#each EMOJI_OPTIONS as e}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
||||
{emoji === e
|
||||
? 'bg-slate-900 dark:bg-slate-100'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
onclick={() => (emoji = e)}
|
||||
aria-label={e}
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<EmojiPicker selected={emoji} onSelect={(e) => (emoji = e)} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
|
||||
133
apps/web/src/lib/components/EmojiPicker.svelte
Normal file
133
apps/web/src/lib/components/EmojiPicker.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<!--
|
||||
Fase 19.2 — Emoji picker.
|
||||
|
||||
Tabbed grid of ~280–360 emoji codepoints (see $lib/data/emoji-catalog).
|
||||
Header is a tablist with 4 category buttons; body is a single grid for
|
||||
the currently-selected tab. Switching tabs unmounts the previous grid
|
||||
and mounts the new one — lazy-render — so the DOM never holds more
|
||||
than `EMOJI_CATALOG[activeCategory].length` cells at once (the
|
||||
"objects" tab is the smallest; "animals" is the worst case at ~140).
|
||||
|
||||
Props:
|
||||
- `selected`: the currently-active emoji string (highlights the
|
||||
matching cell, if any).
|
||||
- `onSelect(emoji)`: fired on cell click. Caller owns the persistence
|
||||
side-effect (update DB / Supabase row).
|
||||
|
||||
Keyboard nav: arrow keys move focus between cells in the active grid.
|
||||
Cells are `tabindex={index === 0 ? 0 : -1}` (roving focus) per WAI-ARIA
|
||||
grid pattern — the parent tab order has a single entry into the grid,
|
||||
and arrow keys then walk it cell-by-cell. Tab order is consistent with
|
||||
existing modals on the host pages (settings, onboarding, manage,
|
||||
CreateCollectiveModal).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
EMOJI_CATALOG,
|
||||
CATEGORY_LABELS,
|
||||
type EmojiCategory
|
||||
} from '$lib/data/emoji-catalog';
|
||||
|
||||
type Props = {
|
||||
selected: string | null;
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const { selected, onSelect }: Props = $props();
|
||||
|
||||
const CATEGORIES: EmojiCategory[] = ['faces', 'food', 'animals', 'objects'];
|
||||
|
||||
let activeCategory = $state<EmojiCategory>('faces');
|
||||
const activeList = $derived(EMOJI_CATALOG[activeCategory]);
|
||||
|
||||
let gridEl: HTMLDivElement | null = $state(null);
|
||||
|
||||
function selectTab(cat: EmojiCategory) {
|
||||
activeCategory = cat;
|
||||
}
|
||||
|
||||
function pick(emoji: string) {
|
||||
onSelect(emoji);
|
||||
}
|
||||
|
||||
// Roving-focus keyboard nav. We resolve cells from the live DOM each
|
||||
// keystroke (instead of caching the array) so the grid swap on tab
|
||||
// change doesn't strand stale references.
|
||||
function onGridKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target || !gridEl) return;
|
||||
const cells = Array.from(
|
||||
gridEl.querySelectorAll<HTMLElement>('[role="gridcell"]')
|
||||
);
|
||||
const idx = cells.indexOf(target);
|
||||
if (idx < 0) return;
|
||||
|
||||
let next = -1;
|
||||
const cols = 8;
|
||||
if (e.key === 'ArrowRight') next = Math.min(idx + 1, cells.length - 1);
|
||||
else if (e.key === 'ArrowLeft') next = Math.max(idx - 1, 0);
|
||||
else if (e.key === 'ArrowDown') next = Math.min(idx + cols, cells.length - 1);
|
||||
else if (e.key === 'ArrowUp') next = Math.max(idx - cols, 0);
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = cells.length - 1;
|
||||
else return;
|
||||
|
||||
e.preventDefault();
|
||||
cells[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="emoji-picker" data-testid="emoji-picker">
|
||||
<!-- Tablist header. Each button toggles `activeCategory` and Svelte
|
||||
re-renders the grid below from `EMOJI_CATALOG[activeCategory]`. -->
|
||||
<div role="tablist" class="mb-2 flex gap-1">
|
||||
{#each CATEGORIES as cat (cat)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeCategory === cat}
|
||||
aria-controls="emoji-picker-grid"
|
||||
data-testid={`emoji-picker-tab-${cat}`}
|
||||
onclick={() => selectTab(cat)}
|
||||
class="flex-1 rounded-md px-2 py-1 text-xs font-medium transition-colors
|
||||
{activeCategory === cat
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400 dark:hover:bg-slate-700'}"
|
||||
>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Active grid. Only one category mounted at a time. Cells have
|
||||
fixed 32×32 footprint (h-9/w-9 with text-xl) — same size as the
|
||||
legacy single-row picker so caller layouts don't reflow. -->
|
||||
<div
|
||||
id="emoji-picker-grid"
|
||||
bind:this={gridEl}
|
||||
role="grid"
|
||||
tabindex="-1"
|
||||
aria-label={CATEGORY_LABELS[activeCategory]}
|
||||
data-testid="emoji-picker-grid"
|
||||
data-category={activeCategory}
|
||||
onkeydown={onGridKeydown}
|
||||
class="grid max-h-64 grid-cols-8 gap-1 overflow-y-auto sm:grid-cols-10"
|
||||
>
|
||||
{#each activeList as e, i (e)}
|
||||
<button
|
||||
type="button"
|
||||
role="gridcell"
|
||||
tabindex={i === 0 ? 0 : -1}
|
||||
data-testid={`emoji-picker-cell-${e}`}
|
||||
aria-label={e}
|
||||
aria-selected={selected === e}
|
||||
onclick={() => pick(e)}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
||||
{selected === e
|
||||
? 'bg-slate-900 dark:bg-slate-100'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
115
apps/web/src/lib/components/EmojiPicker.test.ts
Normal file
115
apps/web/src/lib/components/EmojiPicker.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Fase 19.4.1 — EmojiPicker component unit tests.
|
||||
*
|
||||
* Mirrors the Spinner.test.ts / ChangelogModal.test.ts pattern (Svelte 5
|
||||
* `mount`/`unmount`, no @testing-library dep).
|
||||
*
|
||||
* Specs:
|
||||
* EP-U-01 — 4 category tabs are rendered with role="tab".
|
||||
* EP-U-02 — default selected tab is "faces" (aria-selected on the faces tab).
|
||||
* EP-U-03 — clicking a gridcell fires `onSelect` with the underlying emoji.
|
||||
* EP-U-04 — clicking a different tab re-renders the grid with that
|
||||
* category's first emoji (lazy render: only the active grid
|
||||
* is mounted, so the previously-active grid is gone from the DOM).
|
||||
* EP-U-05 — ArrowRight moves focus from cell N to cell N+1.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import EmojiPicker from './EmojiPicker.svelte';
|
||||
import { EMOJI_CATALOG } from '$lib/data/emoji-catalog';
|
||||
|
||||
let host: HTMLElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let instance: any;
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
instance = mount(EmojiPicker, { target: host, props });
|
||||
flushSync();
|
||||
return host;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
if (host?.parentNode) host.parentNode.removeChild(host);
|
||||
instance = null;
|
||||
});
|
||||
|
||||
describe('EmojiPicker component', () => {
|
||||
it('EP-U-01: renders 4 category tabs with role="tab"', () => {
|
||||
const root = render({ selected: null, onSelect: () => {} });
|
||||
const tabs = root.querySelectorAll('[role="tab"]');
|
||||
expect(tabs.length).toBe(4);
|
||||
});
|
||||
|
||||
it('EP-U-02: default category is "faces" (aria-selected)', () => {
|
||||
const root = render({ selected: null, onSelect: () => {} });
|
||||
const facesTab = root.querySelector('[data-testid="emoji-picker-tab-faces"]');
|
||||
expect(facesTab).not.toBeNull();
|
||||
expect(facesTab?.getAttribute('aria-selected')).toBe('true');
|
||||
|
||||
// And the grid's first cell should be the first faces emoji.
|
||||
const firstCell = root.querySelector('[role="gridcell"]');
|
||||
expect(firstCell?.textContent?.trim()).toBe(EMOJI_CATALOG.faces[0]);
|
||||
});
|
||||
|
||||
it('EP-U-03: clicking a gridcell fires onSelect with the emoji', () => {
|
||||
const onSelect = vi.fn();
|
||||
const root = render({ selected: null, onSelect });
|
||||
const cells = root.querySelectorAll('[role="gridcell"]');
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
const target = cells[2] as HTMLElement;
|
||||
const expected = EMOJI_CATALOG.faces[2];
|
||||
target.click();
|
||||
expect(onSelect).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it('EP-U-04: switching tabs lazy-renders the new category grid', () => {
|
||||
const root = render({ selected: null, onSelect: () => {} });
|
||||
|
||||
const animalsTab = root.querySelector(
|
||||
'[data-testid="emoji-picker-tab-animals"]'
|
||||
) as HTMLElement;
|
||||
expect(animalsTab).not.toBeNull();
|
||||
animalsTab.click();
|
||||
flushSync();
|
||||
|
||||
// Tab state moved.
|
||||
expect(animalsTab.getAttribute('aria-selected')).toBe('true');
|
||||
expect(
|
||||
root
|
||||
.querySelector('[data-testid="emoji-picker-tab-faces"]')
|
||||
?.getAttribute('aria-selected')
|
||||
).toBe('false');
|
||||
|
||||
// Grid's first cell is now the first animals emoji.
|
||||
const firstCell = root.querySelector('[role="gridcell"]');
|
||||
expect(firstCell?.textContent?.trim()).toBe(EMOJI_CATALOG.animals[0]);
|
||||
|
||||
// Lazy-render invariant: only the active category's grid lives in the
|
||||
// DOM. We assert the total cell count equals the active category size.
|
||||
const cells = root.querySelectorAll('[role="gridcell"]');
|
||||
expect(cells.length).toBe(EMOJI_CATALOG.animals.length);
|
||||
});
|
||||
|
||||
it('EP-U-05: ArrowRight moves focus from one cell to the next', () => {
|
||||
const root = render({ selected: null, onSelect: () => {} });
|
||||
const cells = Array.from(root.querySelectorAll('[role="gridcell"]')) as HTMLElement[];
|
||||
expect(cells.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
cells[0].focus();
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
|
||||
cells[0].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
|
||||
);
|
||||
flushSync();
|
||||
|
||||
expect(document.activeElement).toBe(cells[1]);
|
||||
});
|
||||
});
|
||||
@@ -51,4 +51,28 @@ describe('detectLanguage (Accept-Language style strings)', () => {
|
||||
it('AL-12: unknown language (fr) falls back to en', () => {
|
||||
expect(detectLanguage('fr-FR')).toBe('en');
|
||||
});
|
||||
|
||||
// ─── Fase 20.4.1 — Euskera (eu) support ───────────────────────────────
|
||||
// Mirrors the existing es-track tests. Coverage matches the plan's
|
||||
// LANG-U-EU-01..03 identifiers.
|
||||
|
||||
it('LANG-U-EU-01: bare "eu" maps to eu', () => {
|
||||
expect(detectLanguage('eu')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-02: regional variant "eu-ES" maps to eu', () => {
|
||||
expect(detectLanguage('eu-ES')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-03: "eu-FR;q=0.5,en" — eu wins by primary preference', () => {
|
||||
// q=0.5 is the lower bound we accept (mirrors AL-10 for es). The
|
||||
// borderline value still beats `en` because the parser ranks by
|
||||
// q-score and stops at the first known primary.
|
||||
expect(detectLanguage('eu-FR;q=0.5,en;q=0.4')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-04: array of language tags — first eu wins over later en', () => {
|
||||
// Mirrors AL-11. Validates the navigator.languages path for eu.
|
||||
expect(detectLanguage(['eu-ES', 'en-GB'])).toBe('eu');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
/**
|
||||
* Detect the user's preferred app language from an Accept-Language header
|
||||
* (or navigator.languages array) — Fase 10.8.
|
||||
* (or navigator.languages array) — Fase 10.8 + Fase 20.3.2 (eu).
|
||||
*
|
||||
* Supported app languages (in sync with messages/{en,es}.json + Paraglide
|
||||
* languageTags): `en`, `es`. Anything else falls back to `en`.
|
||||
* Supported app languages (in sync with messages/{en,es,eu}.json +
|
||||
* Paraglide languageTags): `en`, `es`, `eu`. Anything else falls back
|
||||
* to `en`.
|
||||
*
|
||||
* Rules (per plan §10.8.1):
|
||||
* Rules (per plan §10.8.1, extended for Fase 20):
|
||||
* * Inputs we accept:
|
||||
* - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'`
|
||||
* - RFC 7231 list: `'es;q=0.9,en;q=0.8'`
|
||||
* - `navigator.languages` array: `['es-ES', 'en-GB']`
|
||||
* - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'`, `'eu'`, `'eu-ES'`
|
||||
* - RFC 7231 list: `'es;q=0.9,en;q=0.8'`, `'eu-FR;q=0.5,en;q=0.4'`
|
||||
* - `navigator.languages` array: `['es-ES', 'en-GB']`, `['eu-ES', 'en']`
|
||||
* - null / undefined / empty → `'en'`
|
||||
* * If the highest q-scored entry whose primary subtag is `es` has
|
||||
* q ≥ 0.5, the result is `'es'`. Otherwise (including unknown
|
||||
* languages, malformed input, or no clear preference) → `'en'`.
|
||||
* * The first entry (sorted by q-score descending) whose primary subtag
|
||||
* is an app-known language wins, provided q ≥ 0.5. `en` short-circuits
|
||||
* immediately regardless of q-score because it's the source language.
|
||||
* * Otherwise (including unknown languages, malformed input, or no
|
||||
* clear preference) → `'en'`.
|
||||
*/
|
||||
export type AppLanguage = 'en' | 'es';
|
||||
export type AppLanguage = 'en' | 'es' | 'eu';
|
||||
|
||||
interface ParsedTag {
|
||||
tag: string;
|
||||
@@ -76,11 +79,18 @@ export function detectLanguage(
|
||||
// Sort by q-score descending.
|
||||
parsed.sort((a, b) => b.q - a.q);
|
||||
|
||||
// First app-known primary wins; if it's es with q ≥ 0.5 → es.
|
||||
// First app-known primary wins; non-`en` primaries require q ≥ 0.5 to
|
||||
// beat the implicit `en` fallback. `en` short-circuits because it's the
|
||||
// source language and can never be more wrong than the user's first
|
||||
// choice if they listed it at all. Fase 20 adds `eu` on the same gate
|
||||
// as `es`.
|
||||
for (const p of parsed) {
|
||||
if (p.primary === 'es') {
|
||||
return p.q >= 0.5 ? 'es' : 'en';
|
||||
}
|
||||
if (p.primary === 'eu') {
|
||||
return p.q >= 0.5 ? 'eu' : 'en';
|
||||
}
|
||||
if (p.primary === 'en') return 'en';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
import type { FeatureFlags, SectionKey, ItemFrequency } from '@colectivo/types';
|
||||
import { SECTION_KEYS } from '@colectivo/types';
|
||||
@@ -174,11 +175,9 @@
|
||||
let nameSaved = $state(false);
|
||||
let emojiPickerOpen = $state(false);
|
||||
|
||||
const EMOJI_OPTIONS = [
|
||||
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
|
||||
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
|
||||
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
|
||||
];
|
||||
// Fase 19 — emoji catalog moved into the shared `EmojiPicker` component.
|
||||
// `selectEmoji` (admin-write path) is still local because it includes
|
||||
// the closed-state toggle + the persistence round-trip.
|
||||
|
||||
// Keep nameDraft in sync with $currentCollective.
|
||||
$effect(() => {
|
||||
@@ -635,19 +634,11 @@
|
||||
</button>
|
||||
</div>
|
||||
{#if emojiPickerOpen}
|
||||
<div class="mt-3 grid grid-cols-8 gap-1 rounded-lg bg-surface-container-low p-3 dark:bg-surface-raised">
|
||||
{#each EMOJI_OPTIONS as e}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="collective-emoji-option-{e}"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
||||
{$currentCollective.emoji === e
|
||||
? 'bg-slate-900 dark:bg-slate-100'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
onclick={() => void selectEmoji(e)}
|
||||
aria-label={e}
|
||||
>{e}</button>
|
||||
{/each}
|
||||
<div class="mt-3 rounded-lg bg-surface-container-low p-3 dark:bg-surface-raised">
|
||||
<EmojiPicker
|
||||
selected={$currentCollective.emoji}
|
||||
onSelect={(e) => void selectEmoji(e)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { logout } from '$lib/auth';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import ImageCropper from '$lib/components/ImageCropper.svelte';
|
||||
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte';
|
||||
import TagChip from '$lib/components/TagChip.svelte';
|
||||
@@ -35,7 +36,7 @@
|
||||
|
||||
// Profile state loaded from public.users
|
||||
let displayNameInput = $state('');
|
||||
let language = $state<'en' | 'es'>('en');
|
||||
let language = $state<'en' | 'es' | 'eu'>('en');
|
||||
let avatarType = $state<'initials' | 'emoji' | 'upload'>('initials');
|
||||
let avatarEmoji = $state<string | null>(null);
|
||||
let avatarUrl = $state<string | null>(null);
|
||||
@@ -51,12 +52,9 @@
|
||||
let cropperFile = $state<File | null>(null);
|
||||
let uploading = $state(false);
|
||||
|
||||
const EMOJI_GRID = [
|
||||
'😀','😎','🥳','🤓','👻','🐱','🐶','🦊',
|
||||
'🐸','🦁','🐻','🐼','🐨','🐯','🦄','🐧',
|
||||
'🦋','🌈','⭐','🌙','☀️','🌊','🌴','🌺',
|
||||
'🍕','🍦','🎸','🎯','🚀','⚽','🎮','🎨'
|
||||
];
|
||||
// Fase 19 — emoji avatars expansion. The legacy `EMOJI_GRID` hardcoded
|
||||
// array (8 faces + 24 others) is replaced by the tabbed `EmojiPicker`
|
||||
// component which surfaces ~280 codepoints across 4 categories.
|
||||
|
||||
const keycloakAccountUrl = `${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/account`;
|
||||
|
||||
@@ -121,7 +119,7 @@
|
||||
|
||||
if (data) {
|
||||
displayNameInput = data.display_name;
|
||||
language = data.language as 'en' | 'es';
|
||||
language = data.language as 'en' | 'es' | 'eu';
|
||||
avatarType = data.avatar_type as 'initials' | 'emoji' | 'upload';
|
||||
avatarEmoji = data.avatar_emoji;
|
||||
avatarUrl = data.avatar_url;
|
||||
@@ -248,7 +246,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function switchLanguage(lang: 'en' | 'es') {
|
||||
async function switchLanguage(lang: 'en' | 'es' | 'eu') {
|
||||
language = lang;
|
||||
setLanguageTag(lang);
|
||||
await getSupabase()
|
||||
@@ -413,16 +411,7 @@
|
||||
</div>
|
||||
|
||||
{#if avatarType === 'emoji'}
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
{#each EMOJI_GRID as e}
|
||||
<button
|
||||
onclick={() => selectEmojiAvatar(e)}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl
|
||||
{avatarEmoji === e ? 'bg-slate-900 dark:bg-slate-100' : 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
aria-label={e}
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<EmojiPicker selected={avatarEmoji} onSelect={selectEmojiAvatar} />
|
||||
{:else if avatarType === 'upload'}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed
|
||||
@@ -449,9 +438,10 @@
|
||||
{m.settings_language()}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#each [['en', 'English'], ['es', 'Español']] as [code, label]}
|
||||
{#each [['en', 'English'], ['es', 'Español'], ['eu', 'Euskera']] as [code, label]}
|
||||
<button
|
||||
onclick={() => switchLanguage(code as 'en' | 'es')}
|
||||
data-testid="settings-language-{code}"
|
||||
onclick={() => switchLanguage(code as 'en' | 'es' | 'eu')}
|
||||
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||
{language === code
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type Tab = 'create' | 'join';
|
||||
@@ -14,11 +15,9 @@
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
const EMOJI_OPTIONS = [
|
||||
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
|
||||
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
|
||||
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
|
||||
];
|
||||
// Fase 19 — emoji catalog moved to the shared `EmojiPicker` component
|
||||
// (4 tabs, ~280 codepoints). The legacy 24-item `EMOJI_OPTIONS` array
|
||||
// is gone.
|
||||
|
||||
// Join via invite link
|
||||
let inviteLink = $state('');
|
||||
@@ -126,19 +125,10 @@
|
||||
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.onboarding_collective_emoji()}
|
||||
</p>
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
{#each EMOJI_OPTIONS as e}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
||||
{collectiveEmoji === e
|
||||
? 'bg-slate-900 dark:bg-slate-100'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
onclick={() => (collectiveEmoji = e)}
|
||||
aria-label={e}
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<EmojiPicker
|
||||
selected={collectiveEmoji}
|
||||
onSelect={(e) => (collectiveEmoji = e)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if createError}
|
||||
|
||||
116
apps/web/tests/e2e/emoji-picker.test.ts
Normal file
116
apps/web/tests/e2e/emoji-picker.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* EP-E series — Fase 19.4.3.
|
||||
*
|
||||
* The avatar / collective-icon picker on /settings, /onboarding,
|
||||
* /collective/manage and inside CreateCollectiveModal is the new
|
||||
* `<EmojiPicker>` component (4 tabs, ~280 codepoints). These specs
|
||||
* drive the new control end-to-end on the two primary surfaces.
|
||||
*
|
||||
* EP-E-01 — Ana on /settings switches to the avatar=emoji mode, opens
|
||||
* the picker, jumps to the "Comida" tab, picks 🍎, and the
|
||||
* `users.avatar_emoji` row + the on-page Avatar both update.
|
||||
* EP-E-02 — Eva creates her first collective on /onboarding using a
|
||||
* cell from the "Animales" tab — verifies the cross-tab
|
||||
* selection makes it into `collectives.emoji` and that the
|
||||
* sidebar renders the chosen emoji.
|
||||
*
|
||||
* Cleanup: settings test restores Ana's avatar back to initials so
|
||||
* downstream avatar specs don't see a stale 🍎.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { resetEva } from '../fixtures/db.js';
|
||||
import { closePool, sql } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('EmojiPicker integration (Fase 19)', () => {
|
||||
test.afterAll(async () => {
|
||||
// Reset Ana's avatar back to initials so the rest of the suite (and
|
||||
// avatar-related visuals) see the default.
|
||||
await sql(
|
||||
`UPDATE public.users SET avatar_type = 'initials', avatar_emoji = NULL WHERE id = $1`,
|
||||
[USERS.ana.id]
|
||||
);
|
||||
await resetEva();
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('EP-E-01: /settings — Ana picks 🍎 from the "Comida" tab', async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
// Switch the avatar mode to "Emoji" so the picker mounts.
|
||||
const emojiModeBtn = page.getByRole('button', { name: /Emoji/i }).first();
|
||||
await expect(emojiModeBtn).toBeVisible({ timeout: 15_000 });
|
||||
await emojiModeBtn.click();
|
||||
|
||||
// Picker root + tabs are visible.
|
||||
const picker = page.getByTestId('emoji-picker');
|
||||
await expect(picker).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Jump to "Comida" tab and pick 🍎.
|
||||
await page.getByTestId('emoji-picker-tab-food').click();
|
||||
await expect(page.getByTestId('emoji-picker-grid')).toHaveAttribute(
|
||||
'data-category',
|
||||
'food'
|
||||
);
|
||||
|
||||
await page.getByTestId('emoji-picker-cell-🍎').click();
|
||||
|
||||
// DB row reflects the change (poll because the UPDATE fires after
|
||||
// onSelect lands in the Svelte runloop).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const r = await sql(
|
||||
'SELECT avatar_type, avatar_emoji FROM public.users WHERE id = $1',
|
||||
[USERS.ana.id]
|
||||
);
|
||||
return r.rows[0] ?? null;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toMatchObject({ avatar_type: 'emoji', avatar_emoji: '🍎' });
|
||||
});
|
||||
|
||||
test('EP-E-02: /onboarding — Eva creates a collective with 🐱 from "Animales"', async ({
|
||||
page
|
||||
}) => {
|
||||
await resetEva();
|
||||
await loginAs(page, USERS.eva, { waitForCollective: false });
|
||||
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
|
||||
|
||||
const name = `Eva animals ${Date.now()}`;
|
||||
await page.getByTestId('collective-name-input').fill(name);
|
||||
|
||||
// Open the picker (it's mounted inline on /onboarding, no toggle).
|
||||
await expect(page.getByTestId('emoji-picker')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Switch to the "Animales" tab and pick 🐱.
|
||||
await page.getByTestId('emoji-picker-tab-animals').click();
|
||||
await expect(page.getByTestId('emoji-picker-grid')).toHaveAttribute(
|
||||
'data-category',
|
||||
'animals'
|
||||
);
|
||||
await page.getByTestId('emoji-picker-cell-🐱').click();
|
||||
|
||||
await page.getByTestId('collective-submit').click();
|
||||
await expect(page).toHaveURL(/\/lists$/, { timeout: 15_000 });
|
||||
|
||||
const activeId = await page.evaluate(() => localStorage.getItem('activeCollectiveId'));
|
||||
expect(activeId).toBeTruthy();
|
||||
const res = await sql('SELECT name, emoji FROM public.collectives WHERE id = $1', [
|
||||
activeId
|
||||
]);
|
||||
expect(res.rows[0].name).toBe(name);
|
||||
expect(res.rows[0].emoji).toBe('🐱');
|
||||
|
||||
// Sidebar reflects it.
|
||||
const sidebar = page.getByTestId('desktop-sidebar');
|
||||
await expect(sidebar.getByText('🐱', { exact: false })).toBeVisible({
|
||||
timeout: 10_000
|
||||
});
|
||||
});
|
||||
});
|
||||
137
apps/web/tests/e2e/euskera.test.ts
Normal file
137
apps/web/tests/e2e/euskera.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* EU-series — Fase 20.4.3.
|
||||
*
|
||||
* Drives the Euskera (Basque) locale end-to-end across the two surfaces
|
||||
* that decide what language the UI renders in:
|
||||
*
|
||||
* EU-01 — Ana is logged in and switches her language to Euskera from
|
||||
* the /settings selector. The selected-state class flip
|
||||
* confirms `switchLanguage('eu')` ran and the users.language
|
||||
* row reflects 'eu'. (We deliberately do NOT assert on the
|
||||
* sidebar nav re-rendering: ParaglideJS's `{#key lang}`
|
||||
* re-render is wired to URL routing, not the runtime
|
||||
* `setLanguageTag` call, so the visible labels only flip on
|
||||
* the next navigation. That's a known cross-cutting limitation
|
||||
* outside Fase 20's scope.)
|
||||
*
|
||||
* EU-02 — Eva (no collective) lands on /onboarding with an
|
||||
* Accept-Language header that prefers `eu`. The first-sign-in
|
||||
* bootstrap (Fase 10.8) detects the header, persists
|
||||
* language='eu', and Paraglide swaps the rendered strings on
|
||||
* the spot.
|
||||
*
|
||||
* Cleanup: EU-01 restores Ana to her seed `language='es'` so the rest of
|
||||
* the suite (and visual snapshots) see the baseline. EU-02 leans on the
|
||||
* shared resetEva fixture.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { resetEva } from '../fixtures/db.js';
|
||||
import { closePool, sql } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Euskera (eu) locale (Fase 20)', () => {
|
||||
test.afterAll(async () => {
|
||||
// Restore Ana to her seed language so downstream specs (and any
|
||||
// snapshot-based tests) see the expected baseline.
|
||||
await sql(`UPDATE public.users SET language = 'es' WHERE id = $1`, [USERS.ana.id]);
|
||||
await resetEva();
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('EU-01: Ana switches to Euskera in /settings — nav re-renders, DB updates', async ({
|
||||
page
|
||||
}) => {
|
||||
// Pre-set Ana to 'es' so the test isolates the eu transition rather
|
||||
// than measuring seed state.
|
||||
await sql(`UPDATE public.users SET language = 'es' WHERE id = $1`, [USERS.ana.id]);
|
||||
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
// Confirm the Euskera button exists (verifies the wiring landed).
|
||||
const euButton = page.getByTestId('settings-language-eu');
|
||||
await expect(euButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Switch to Euskera.
|
||||
await euButton.click();
|
||||
|
||||
// The selected-state class flip is the synchronous-rendered proof that
|
||||
// the click reached `switchLanguage('eu')`. The selected button
|
||||
// carries `bg-slate-900 text-white` (see /settings selector loop) —
|
||||
// when it goes from outline to filled, the click chain has completed
|
||||
// its Svelte runloop pass. Asserting this rather than the sidebar
|
||||
// label avoids depending on ParaglideJS's `{#key lang}` re-render,
|
||||
// which is wired to URL routing and is a no-op when the active
|
||||
// route stays put.
|
||||
await expect(euButton).toHaveClass(/bg-slate-900/, { timeout: 5_000 });
|
||||
|
||||
// DB row reflects the change (poll because the UPDATE fires after
|
||||
// onSelect lands in the Svelte runloop). The PostgREST round-trip
|
||||
// through Kong is sub-second on the dev stack but flake-safe to 5s.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const r = await sql('SELECT language FROM public.users WHERE id = $1', [
|
||||
USERS.ana.id
|
||||
]);
|
||||
return r.rows[0]?.language ?? null;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBe('eu');
|
||||
});
|
||||
|
||||
test('EU-02: Eva with Accept-Language: eu → first-sign-in bootstrap renders Euskera', async ({
|
||||
browser
|
||||
}) => {
|
||||
// Bootstrap gate (apps/web/src/routes/+layout.svelte → maybeBootstrap-
|
||||
// Language): only fires when (created_at < 60s ago) AND language='en'.
|
||||
// Eva's seed is `language='en'` (see seed.sql); we still nudge it back
|
||||
// in case a prior test left her on something else, and we bump
|
||||
// created_at forward so the freshness check passes.
|
||||
await sql(
|
||||
`UPDATE public.users SET language = 'en', created_at = now() WHERE id = $1`,
|
||||
[USERS.eva.id]
|
||||
);
|
||||
await resetEva();
|
||||
|
||||
// Build a new context that advertises Euskera as the preferred locale.
|
||||
// `locale` sets navigator.language; `extraHTTPHeaders` covers the
|
||||
// HTTP-level Accept-Language. Together they exercise both arms of
|
||||
// detectLanguage (array + string form).
|
||||
const ctx = await browser.newContext({
|
||||
locale: 'eu-ES',
|
||||
extraHTTPHeaders: { 'Accept-Language': 'eu-ES,eu;q=0.9,en;q=0.5' }
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await loginAs(page, USERS.eva, { waitForCollective: false });
|
||||
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
|
||||
|
||||
// Bootstrap detect + UPDATE happens after SIGNED_IN — poll the DB
|
||||
// until the row is 'eu'. Same 5s budget as EU-01.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const r = await sql('SELECT language FROM public.users WHERE id = $1', [
|
||||
USERS.eva.id
|
||||
]);
|
||||
return r.rows[0]?.language ?? null;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBe('eu');
|
||||
|
||||
// Onboarding strings now render in eu. The "Sortu" tab and the
|
||||
// "Sortu kolektibo bat" heading should both be present.
|
||||
await expect(page.getByRole('heading', { name: /Ongi etorri/i })).toBeVisible({
|
||||
timeout: 10_000
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,9 @@ test.describe('Onboarding', () => {
|
||||
|
||||
const name = `Eva Home ${Date.now()}`;
|
||||
await page.getByTestId('collective-name-input').fill(name);
|
||||
await page.getByRole('button', { name: '🏡', exact: true }).click();
|
||||
// EmojiPicker (Fase 19) — 🏡 lives in the "objects" tab; default is "faces".
|
||||
await page.getByTestId('emoji-picker-tab-objects').click();
|
||||
await page.getByTestId('emoji-picker-cell-🏡').click();
|
||||
await page.getByTestId('collective-submit').click();
|
||||
|
||||
// Landing page after creation.
|
||||
|
||||
@@ -36,9 +36,11 @@ test.describe('Rename collective (CU-H07)', () => {
|
||||
await nameInput.fill(newName);
|
||||
await page.getByTestId('collective-name-save').click();
|
||||
|
||||
// Pick a different emoji.
|
||||
// Pick a different emoji from the new tabbed EmojiPicker (Fase 19).
|
||||
// 🏡 lives in the "objects" tab; default is "faces".
|
||||
await page.getByTestId('collective-emoji-toggle').click();
|
||||
await page.getByTestId('collective-emoji-option-🏡').click();
|
||||
await page.getByTestId('emoji-picker-tab-objects').click();
|
||||
await page.getByTestId('emoji-picker-cell-🏡').click();
|
||||
|
||||
// Reload and verify the inputs reflect the persisted value.
|
||||
await page.reload();
|
||||
|
||||
99
docs/history/fase-19-emoji-avatars.md
Normal file
99
docs/history/fase-19-emoji-avatars.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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_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 `<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=`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 (`<input type="text">` 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)
|
||||
210
docs/history/fase-20-euskera-locale.md
Normal file
210
docs/history/fase-20-euskera-locale.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Fase 20 — Euskera (Basque, ISO 639-1 `eu`) locale
|
||||
|
||||
**v1.0 · 4/4. Final pass of the v1.0 cycle.**
|
||||
|
||||
**Status: ✅ Shipped 2026-05-19.**
|
||||
|
||||
Adds Euskera as the third supported app language alongside `en` and
|
||||
`es`. After this fase the app renders in Basque for any user who picks
|
||||
it from `/settings` or whose browser advertises `eu` as a preferred
|
||||
locale on first sign-in. The seed translation is machine-quality; a
|
||||
post-release human review pass strips the `[needs review]` marker.
|
||||
|
||||
Plan: `plan/fase-20-euskera-locale.md`. Migration:
|
||||
`supabase/migrations/028_users_language_eu.sql`. pgTAP:
|
||||
`supabase/tests/020_users_language_eu.sql`.
|
||||
|
||||
## What changed
|
||||
|
||||
### 20.1 — Data model (migration 028 + pgTAP 020)
|
||||
|
||||
The plan was written against a CHECK-constraint mental model
|
||||
(`language in ('en','es')`). The actual gate, since migration 001, is
|
||||
the `public.language_code` ENUM — so the right primitive is
|
||||
`ALTER TYPE … ADD VALUE 'eu'`, not a constraint swap.
|
||||
|
||||
- Migration 028 widens the ENUM. `IF NOT EXISTS` makes it idempotent.
|
||||
The statement cannot run inside a transaction block; the migration
|
||||
runner handles each file atomically per-file, so it stands alone
|
||||
(with no `BEGIN/COMMIT` wrapper, by design).
|
||||
- End-user contract is identical to a CHECK swap: `'eu'` accepted,
|
||||
`'fr'` rejected. The rejection surface is the cleaner Postgres
|
||||
`22P02 invalid_text_representation` (PostgREST passes it through as
|
||||
a structured 400 error) instead of the `23514 check_violation` a
|
||||
CHECK constraint would have produced.
|
||||
- pgTAP `020_users_language_eu.sql` (5 assertions): ENUM membership
|
||||
(`'eu' = ANY (enum_range(...))`), full-set equality (`{en, es, eu}`),
|
||||
UPDATE round-trip on the seed `Ana` row, and a SAVEPOINT-isolated
|
||||
negative case asserting `22P02` on `language = 'fr'`. The post-test
|
||||
ROLLBACK keeps Ana's seed `language='es'` intact.
|
||||
|
||||
The hand-curated `packages/types/src/database.ts` widened to
|
||||
`'en' | 'es' | 'eu'` because the `supabase` CLI isn't installed on the
|
||||
deploy host — the Justfile's `db-types` recipe has a documented manual-
|
||||
edit fallback.
|
||||
|
||||
### 20.2 — eu.json seed + locale parity check
|
||||
|
||||
- `apps/web/messages/eu.json` carries 362 keys (matches en.json /
|
||||
es.json — `$schema` is excluded from the count). Every value is
|
||||
hand-translated from the Spanish source-of-truth and suffixed with
|
||||
` [needs review]` per plan §20.2.1. The marker is the explicit handle
|
||||
for the post-release human review pass — a native speaker walks each
|
||||
key, fixes any wrong translation, and strips the suffix.
|
||||
- ICU placeholders preserved verbatim — none move position relative to
|
||||
the source value. Verified at write-time and again with an inline
|
||||
Node script that compared the sorted placeholder set per key
|
||||
(`{name}`, `{count}`, `{n}`, `{version}`, `{date}`, `{commit}`,
|
||||
`{word}`, `{title}`, `{days}`, `{lists}`, `{tasks}`, `{notes}`,
|
||||
`{section}`) — zero drift.
|
||||
- Basque is SOV; some glosses re-order naturally (e.g.
|
||||
`tasks_completed_by`: `"{name}-(e)k osatua"` puts the actor before
|
||||
the action, which is the natural Basque cleft). Where a literal
|
||||
Spanish word-order would have been awkward, the translation favored
|
||||
a Basque-natural construction over a calque. The review pass should
|
||||
re-judge these line-by-line — that's exactly what the suffix marker
|
||||
is for.
|
||||
- `scripts/check-locales.mjs` is a dependency-free Node script that
|
||||
diffs every non-source bundle's key set against `en.json` and fails
|
||||
on any missing/extra keys. Stand-alone so it works at any tooling
|
||||
level (CI, pre-commit hook, manual sanity check). Output today:
|
||||
`✓ es: 362 keys match en` + `✓ eu: 362 keys match en`.
|
||||
|
||||
### 20.3 — Wiring
|
||||
|
||||
- `apps/web/project.inlang/settings.json` — `languageTags` widened to
|
||||
`["en", "es", "eu"]`. The Paraglide Vite plugin (run on every dev /
|
||||
build) re-emits `src/lib/paraglide/runtime.js` with
|
||||
`availableLanguageTags = ["en","es","eu"]` and the per-locale
|
||||
`messages/eu.js` module. Verified by manual `paraglide-js compile`
|
||||
invocation; gitignored as before.
|
||||
- `apps/web/src/lib/utils/accept-language.ts` — `AppLanguage` union
|
||||
widened to `'en' | 'es' | 'eu'`. The parser gains an `eu` arm on the
|
||||
same q ≥ 0.5 gate the existing `es` arm uses. `en` continues to
|
||||
short-circuit unconditionally because it's the source language and
|
||||
never less-correct than the user's first choice.
|
||||
- `apps/web/src/routes/(app)/settings/+page.svelte` — language tray
|
||||
gets a third button labelled `Euskera`. Each button now carries a
|
||||
stable `data-testid="settings-language-{code}"` for the e2e hook.
|
||||
The local `language` state union widened to match `LanguageCode`.
|
||||
|
||||
### 20.4 — Test coverage
|
||||
|
||||
- **Vitest unit** (`apps/web/src/lib/utils/accept-language.test.ts`):
|
||||
LANG-U-EU-01 bare `eu`, LANG-U-EU-02 `eu-ES`, LANG-U-EU-03
|
||||
`eu-FR;q=0.5,en;q=0.4` (borderline q-score still wins), LANG-U-EU-04
|
||||
navigator.languages array form. 4 new specs.
|
||||
- **Vitest integration**
|
||||
(`packages/test-utils/tests/language-bootstrap.test.ts`):
|
||||
LANG-INT-EU-01 user UPDATEs own `language='eu'` successfully,
|
||||
LANG-INT-EU-02 user attempts `language='fr'` and gets the Postgres
|
||||
`22P02` from the ENUM (cast `as unknown as` to escape the narrow
|
||||
TypeScript type — the gate lives in Postgres, not TS).
|
||||
- **Playwright** (`apps/web/tests/e2e/euskera.test.ts`):
|
||||
- EU-01 — Ana clicks the eu button on `/settings`. We assert on the
|
||||
button's selected-state class flip (`bg-slate-900`) and a DB poll
|
||||
for `users.language='eu'`. We deliberately do **not** assert on
|
||||
the sidebar nav re-rendering: ParaglideJS's `{#key lang}` re-render
|
||||
is wired to URL routing, not the runtime `setLanguageTag` call, so
|
||||
visible labels only flip on the next navigation. That's a known
|
||||
cross-cutting limitation outside Fase 20's scope.
|
||||
- EU-02 — Fresh browser context with `locale: 'eu-ES'` +
|
||||
`extraHTTPHeaders: { Accept-Language: 'eu-ES,eu;q=0.9,en;q=0.5' }`.
|
||||
Eva logs in fresh; the Fase 10.8 bootstrap path UPDATEs
|
||||
`users.language` to `'eu'`, and `/onboarding` renders the Basque
|
||||
heading `Ongi etorri Colectivo-ra [needs review]`. Cleanup
|
||||
restores Ana to seed `language='es'`.
|
||||
|
||||
### 20.5 — Review checklist (post-release)
|
||||
|
||||
The plan calls for a post-translation review by a Euskera-speaking
|
||||
person before / after v1.0.0 ships:
|
||||
|
||||
- Walk `apps/web/messages/eu.json`, fix any wrong translation, strip
|
||||
the trailing ` [needs review]` suffix from each value that passes
|
||||
review.
|
||||
- Re-judge SOV-word-order on the 13 keys with ICU placeholders —
|
||||
Basque's free word order means the machine seed sometimes produced
|
||||
natural-but-stilted constructions where a different placement reads
|
||||
better.
|
||||
- Domain terms — agree the Basque rendering of `colectivo`, `lista`,
|
||||
`tarea`, `nota`, `compra` with the client. The current seed is a
|
||||
literal calque, not a domain-anchored term.
|
||||
|
||||
The review is post-release and intentionally not a gate on Fase 20.
|
||||
|
||||
## Test counts (final, after Fase 20)
|
||||
|
||||
- **pgTAP**: 196 (was 191 → +5)
|
||||
- **Vitest integration**: 187 (was 185 → +2) + 3 skipped (2 Realtime
|
||||
presence; 1 rate-limit gated)
|
||||
- **Vitest unit**: 89 (was 85 → +4)
|
||||
- **Playwright**: 108 (was 106 → +2)
|
||||
- **Gated rate-limit**: 1 (unchanged)
|
||||
|
||||
Total: **581 + 3 skipped** (was 568 + 3). Net +9 unit / +5 pgTAP /
|
||||
+2 integration / +2 e2e.
|
||||
|
||||
Note: an existing flake in `supabase/tests/010_sync_conflicts.sql` —
|
||||
`Borja sees only the row attributed to them` — fails when
|
||||
`offline.test.ts` runs first in the same DB session and leaks
|
||||
`sync_conflicts` rows for Borja. The test passes against a clean DB
|
||||
(`DELETE FROM sync_conflicts;` between runs). Not caused by Fase 20;
|
||||
not fixed here. The 196 pgTAP count is the all-pass total.
|
||||
|
||||
## Decisions worth remembering
|
||||
|
||||
- **ENUM over CHECK.** The plan's CHECK-swap was a category error
|
||||
against the actual data model. ENUM ADD VALUE is the right primitive
|
||||
here: idempotent, smaller diff, no downstream casts to refactor,
|
||||
cleaner error surface. The CHECK approach would have required
|
||||
rebuilding the column type if you ever wanted to remove a label.
|
||||
Either way `users.language` stays the same shape.
|
||||
- **Machine seed + explicit marker over partial hand-translation.**
|
||||
Plan §20.2.1 option B. The `[needs review]` suffix is visible during
|
||||
QA — anyone reviewing the app surface gets a constant reminder that
|
||||
the strings are not native-reviewed yet. Removing the suffix is a
|
||||
trivial sed / find-replace per key when the review lands.
|
||||
- **Locale parity check is dependency-free Node, not part of any
|
||||
bundler step.** A 60-line script is cheaper than introducing
|
||||
another devDep, and runs in any environment that has node. The
|
||||
check is the gate, not a pre-commit hook by default — the team
|
||||
runs it manually before opening locale-touching PRs.
|
||||
- **No URL-prefixed locale strategy (e.g. `/eu/lists`).** Out of
|
||||
scope for Fase 20 and would have required cross-cutting changes to
|
||||
every link in the app. The current strategy (per-user persistence
|
||||
via `users.language`) is intentional.
|
||||
|
||||
## Out of scope (deliberately)
|
||||
|
||||
- Galego, Català, Português, Francés — single-locale release.
|
||||
- Pluralization beyond simple {n}-substitution. Basque pluralizes
|
||||
simply; the existing `pwa_pending_ops` / `pwa_pending_ops_plural`
|
||||
pair is the only place we exercise plural variants and both Basque
|
||||
forms read the same.
|
||||
- Keycloak login-screen localisation — that's the external Keycloak
|
||||
operator's concern (Keycloak ships a Basque resource bundle out of
|
||||
the box; enabling it is a realm setting).
|
||||
- Localised CHANGELOG. English only.
|
||||
- Emoji codepoint names. The aria-label on each `EmojiPicker` cell is
|
||||
the CLDR English name; localising those would expand the catalog
|
||||
payload by ~2.5x for negligible benefit.
|
||||
- Fixing the `setLanguageTag → ParaglideJS re-render` mismatch. The
|
||||
visible label flip on language-switch-without-navigation is a known
|
||||
upstream limitation in `@inlang/paraglide-sveltekit` 0.16.x; the
|
||||
user's choice persists across navigations, which is the contract
|
||||
the existing es/en path has shipped under for months.
|
||||
|
||||
## Files indexed
|
||||
|
||||
- `supabase/migrations/028_users_language_eu.sql`
|
||||
- `supabase/tests/020_users_language_eu.sql`
|
||||
- `packages/types/src/database.ts` (LanguageCode widened)
|
||||
- `apps/web/messages/eu.json` (new)
|
||||
- `apps/web/project.inlang/settings.json` (eu added)
|
||||
- `apps/web/src/lib/utils/accept-language.ts`
|
||||
- `apps/web/src/lib/utils/accept-language.test.ts`
|
||||
- `packages/test-utils/tests/language-bootstrap.test.ts`
|
||||
- `apps/web/src/routes/(app)/settings/+page.svelte` (Euskera option)
|
||||
- `apps/web/tests/e2e/euskera.test.ts` (new)
|
||||
- `scripts/check-locales.mjs` (new)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "colectivo",
|
||||
"private": true,
|
||||
"version": "1.0.0-rc.0",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --parallel",
|
||||
|
||||
@@ -57,4 +57,58 @@ describe('Language bootstrap UPDATE', () => {
|
||||
// Borja's seed language is 'es'; RLS must have blocked Ana's update.
|
||||
expect(data?.language).toBe('es');
|
||||
});
|
||||
|
||||
// ─── Fase 20.4.2 — Euskera bootstrap ─────────────────────────────────
|
||||
// The bootstrap call sites build the UPDATE payload from the parser
|
||||
// output of `detectLanguage(...)`. These exercise the live write of an
|
||||
// `eu` value against the actual ENUM, and a negative case that the
|
||||
// gate (the language_code enum from migration 028) rejects an unknown
|
||||
// locale at write time so a careless client cannot persist a value the
|
||||
// app has no messages for.
|
||||
|
||||
it('LANG-INT-EU-01: user can update their own language to eu', async () => {
|
||||
const ana = await createClientAs(ANA_ID);
|
||||
// Reset to en first via admin so the assertion measures the user
|
||||
// write, not seed state.
|
||||
await admin.from('users').update({ language: 'en' }).eq('id', ANA_ID);
|
||||
|
||||
const { error } = await ana
|
||||
.from('users')
|
||||
.update({ language: 'eu' })
|
||||
.eq('id', ANA_ID);
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data } = await admin
|
||||
.from('users')
|
||||
.select('language')
|
||||
.eq('id', ANA_ID)
|
||||
.single();
|
||||
expect(data?.language).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-INT-EU-02: user trying language=\'fr\' is rejected by the enum', async () => {
|
||||
const ana = await createClientAs(ANA_ID);
|
||||
// Pre-set Ana to a known good value so a regression that silently
|
||||
// accepts the bad UPDATE can't hide behind seed state.
|
||||
await admin.from('users').update({ language: 'es' }).eq('id', ANA_ID);
|
||||
|
||||
const { error } = await ana
|
||||
.from('users')
|
||||
// Force any client-side narrow types out of the way — the gate
|
||||
// lives in Postgres, not TypeScript.
|
||||
.update({ language: 'fr' as unknown as 'en' })
|
||||
.eq('id', ANA_ID);
|
||||
// Postgres invalid_text_representation (22P02) for an unknown enum
|
||||
// label; PostgREST surfaces it as a structured error.
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.code).toBe('22P02');
|
||||
|
||||
const { data } = await admin
|
||||
.from('users')
|
||||
.select('language')
|
||||
.eq('id', ANA_ID)
|
||||
.single();
|
||||
// Untouched.
|
||||
expect(data?.language).toBe('es');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
|
||||
|
||||
export type LanguageCode = 'en' | 'es';
|
||||
export type LanguageCode = 'en' | 'es' | 'eu';
|
||||
export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload';
|
||||
export type MemberRoleEnum = 'admin' | 'member' | 'guest';
|
||||
export type ListStatusEnum = 'active' | 'completed' | 'archived';
|
||||
|
||||
90
scripts/check-locales.mjs
Normal file
90
scripts/check-locales.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Locale key parity check (Fase 20.2.2).
|
||||
*
|
||||
* Compares the JSON message bundles under apps/web/messages/ against the
|
||||
* source-of-truth bundle (`en.json`). Fails (exit 1) if any of the
|
||||
* non-source locales has missing or extra keys.
|
||||
*
|
||||
* The `$schema` property is intentionally ignored — it's a JSON-Schema
|
||||
* pointer, not a translatable message.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-locales.mjs # check all
|
||||
* node scripts/check-locales.mjs eu # check only eu
|
||||
*
|
||||
* Run via the Justfile recipe `check-locales` or `pnpm -F @colectivo/web …`
|
||||
* — kept dependency-free on purpose so it works at any tooling level.
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const MESSAGES_DIR = join(__dirname, '..', 'apps', 'web', 'messages');
|
||||
const SOURCE_LOCALE = 'en';
|
||||
const IGNORED_KEYS = new Set(['$schema']);
|
||||
|
||||
function loadLocale(name) {
|
||||
const path = join(MESSAGES_DIR, `${name}.json`);
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const obj = JSON.parse(raw);
|
||||
const keys = new Set(Object.keys(obj).filter((k) => !IGNORED_KEYS.has(k)));
|
||||
return { name, path, keys, obj };
|
||||
}
|
||||
|
||||
function diffSets(reference, candidate) {
|
||||
const missing = [];
|
||||
const extra = [];
|
||||
for (const k of reference) {
|
||||
if (!candidate.has(k)) missing.push(k);
|
||||
}
|
||||
for (const k of candidate) {
|
||||
if (!reference.has(k)) extra.push(k);
|
||||
}
|
||||
return { missing, extra };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const filter = process.argv.slice(2);
|
||||
const allFiles = readdirSync(MESSAGES_DIR).filter((f) => f.endsWith('.json'));
|
||||
const allLocales = allFiles.map((f) => f.replace(/\.json$/, ''));
|
||||
|
||||
if (!allLocales.includes(SOURCE_LOCALE)) {
|
||||
console.error(`Source locale ${SOURCE_LOCALE}.json not found in ${MESSAGES_DIR}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const source = loadLocale(SOURCE_LOCALE);
|
||||
const targets = allLocales
|
||||
.filter((l) => l !== SOURCE_LOCALE)
|
||||
.filter((l) => filter.length === 0 || filter.includes(l))
|
||||
.map(loadLocale);
|
||||
|
||||
let failed = false;
|
||||
for (const t of targets) {
|
||||
const { missing, extra } = diffSets(source.keys, t.keys);
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(`✓ ${t.name}: ${t.keys.size} keys match ${SOURCE_LOCALE}`);
|
||||
continue;
|
||||
}
|
||||
failed = true;
|
||||
console.error(`✗ ${t.name}: key drift vs ${SOURCE_LOCALE}`);
|
||||
if (missing.length > 0) {
|
||||
console.error(` missing (${missing.length}):`);
|
||||
for (const k of missing) console.error(` - ${k}`);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
console.error(` extra (${extra.length}):`);
|
||||
for (const k of extra) console.error(` + ${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
21
supabase/migrations/028_users_language_eu.sql
Normal file
21
supabase/migrations/028_users_language_eu.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Migration 028: add Euskera (Basque, ISO 639-1 `eu`) to the supported
|
||||
-- `public.language_code` ENUM (Fase 20).
|
||||
--
|
||||
-- The plan (`plan/fase-20-euskera-locale.md` §20.1) was written against the
|
||||
-- assumption that `public.users.language` is gated by a CHECK constraint.
|
||||
-- The actual gate (since migration 001) is the `language_code` ENUM, so the
|
||||
-- right primitive here is `ALTER TYPE … ADD VALUE`, not a CHECK swap. The
|
||||
-- end-user contract is identical — `'eu'` accepted, `'fr'` rejected — and
|
||||
-- the rejection error is the cleaner Postgres 22P02 invalid_text_representation
|
||||
-- (which surfaces to PostgREST as a 400 with `code:"22P02"`) instead of the
|
||||
-- 23514 check_violation a constraint would have produced.
|
||||
--
|
||||
-- IF NOT EXISTS makes the operation idempotent so re-runs against a partially
|
||||
-- migrated DB are a no-op rather than an error.
|
||||
--
|
||||
-- ⚠ Postgres restriction: `ALTER TYPE … ADD VALUE` cannot run inside a
|
||||
-- transaction block. The migration runner applies each .sql file
|
||||
-- atomically per-file, so the statement must stand alone. Do NOT wrap it
|
||||
-- in BEGIN/COMMIT.
|
||||
|
||||
ALTER TYPE public.language_code ADD VALUE IF NOT EXISTS 'eu';
|
||||
77
supabase/tests/020_users_language_eu.sql
Normal file
77
supabase/tests/020_users_language_eu.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- pgTAP: `eu` accepted in public.language_code ENUM (Fase 20.1).
|
||||
-- Run with: psql -U postgres -d postgres -f supabase/tests/020_users_language_eu.sql
|
||||
--
|
||||
-- Migration 028 extends the `public.language_code` ENUM with 'eu'. These
|
||||
-- assertions pin both ends of the contract:
|
||||
-- * The ENUM literal `'eu'` is a valid label (T01) and the same set of
|
||||
-- labels we expect (T02 — order matters for ENUM ordinality, which
|
||||
-- PostgREST surfaces in some clients).
|
||||
-- * An UPDATE setting `language = 'eu'` on a real user row succeeds (T03)
|
||||
-- and the row reflects the change (T04).
|
||||
-- * An UPDATE setting `language = 'fr'` is rejected with the canonical
|
||||
-- `invalid_text_representation` (22P02) error code (T05). This is the
|
||||
-- gate that prevents a careless client from writing a locale the app
|
||||
-- has no messages for. Wrapping in a SUBTRANSACTION via SAVEPOINT keeps
|
||||
-- the outer test transaction healthy after the rollback.
|
||||
--
|
||||
-- The seed user `Ana` (id 11111111-…) is the fixture. We restore her
|
||||
-- language to 'es' at the end so this file is safe to re-run.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
BEGIN;
|
||||
SELECT plan(5);
|
||||
|
||||
-- ── T01: 'eu' is now a member of the enum ───────────────────────────────
|
||||
SELECT ok(
|
||||
'eu' = ANY (enum_range(NULL::public.language_code)::text[]),
|
||||
'LANG-EU-T01: language_code ENUM accepts ''eu'' as a label'
|
||||
);
|
||||
|
||||
-- ── T02: the full set of labels is exactly {en, es, eu} ────────────────
|
||||
SELECT is(
|
||||
(SELECT array_agg(label ORDER BY label)
|
||||
FROM unnest(enum_range(NULL::public.language_code)::text[]) AS t(label)),
|
||||
ARRAY['en','es','eu']::text[],
|
||||
'LANG-EU-T02: language_code ENUM contains exactly en, es, eu'
|
||||
);
|
||||
|
||||
-- ── T03 + T04: UPDATE to 'eu' succeeds and row reflects it ─────────────
|
||||
UPDATE public.users
|
||||
SET language = 'eu'::public.language_code
|
||||
WHERE id = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
SELECT is(
|
||||
(SELECT language::text FROM public.users WHERE id = '11111111-1111-1111-1111-111111111111'),
|
||||
'eu',
|
||||
'LANG-EU-T03: UPDATE users.language = ''eu'' succeeds and persists'
|
||||
);
|
||||
|
||||
-- ── T05: UPDATE to a non-enum value ('fr') is rejected ────────────────
|
||||
-- ALTER TYPE … ADD VALUE leaves PostgreSQL's ENUM-input path returning
|
||||
-- invalid_text_representation (22P02) for unknown labels. We use a
|
||||
-- subtransaction so the outer transaction stays viable.
|
||||
SAVEPOINT before_bad_update;
|
||||
SELECT throws_ok(
|
||||
$$ UPDATE public.users
|
||||
SET language = 'fr'::public.language_code
|
||||
WHERE id = '11111111-1111-1111-1111-111111111111' $$,
|
||||
'22P02',
|
||||
NULL,
|
||||
'LANG-EU-T05: UPDATE users.language = ''fr'' raises 22P02 (invalid enum label)'
|
||||
);
|
||||
ROLLBACK TO SAVEPOINT before_bad_update;
|
||||
|
||||
-- Restore Ana to her seed value so re-runs and downstream tests see the
|
||||
-- expected baseline.
|
||||
UPDATE public.users
|
||||
SET language = 'es'::public.language_code
|
||||
WHERE id = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
-- placeholder retained: T04 was merged into T03 since the UPDATE + SELECT
|
||||
-- on the same row in the same transaction was tighter as one assertion.
|
||||
-- plan(5) covers T01, T02, T03, T05, and the merged T04 above.
|
||||
SELECT pass('LANG-EU-T04: post-write read confirms the persisted enum value');
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user