FriendColours.md (4502B)
1 # Plan: Manually set a colour for a friend 2 3 ## Status 4 5 Not implemented. This document records the research for a future pass — 6 estimated at roughly a day of work, with no architectural blockers. 7 8 ## How friend colours work today 9 10 A friend's colour is **derived, never stored**. The whole scheme rests on 11 `PlayerColor.stableColor(forAuthorID:reserved:)` (`Models/PlayerColor.swift`), 12 a pure function: FNV-1a hash of the `authorID` into the nine-colour palette, 13 then a linear probe forward to the first colour that is more than 14 `similarHueThreshold` (90°) of hue away from every reserved colour, degrading 15 to exact-match avoidance and finally to the raw hashed colour when the 16 palette is too crowded. 17 18 Two consumers: 19 20 1. **Avatars** — `FriendAvatar.avatar(for:)` (`Views/FriendAvatarView.swift`) 21 colours the friend-list/picker/share-sheet avatar with 22 `stableColor(forAuthorID:reserved: [])`. The symbol is hashed off a 23 distinct seed string (`"symbol-\(authorID)"`) so symbol and colour vary 24 independently. 25 2. **In-game roster** — `PlayerRoster.applyRoster` 26 (`Models/PlayerRoster.swift`) walks the participants in sorted-authorID 27 order, threading a running `taken` set seeded with the local user's 28 preferred colour (`PlayerPreferences.color`, synced across the user's 29 devices via iCloud KVS). The result is a pure function of 30 (participant set, preferred colour), both identical across the user's 31 devices — so every device derives the same colour for a friend **with no 32 persisted or synced mapping**, and a friend keeps their colour across 33 games unless a lower-sorted collaborator collides with them. 34 35 That "no persisted colour state" invariant is the one real cost of the 36 feature: once overrides exist, roster output depends on local data plus sync 37 timing, so two devices can briefly disagree after a pin until the sync lands. 38 39 ## The pieces 40 41 ### Easy 42 43 - **Storage.** Add an optional `colorID: String` attribute to `FriendEntity` 44 (`CrossmateModel.xcdatamodeld`). An added optional attribute is handled by 45 automatic lightweight migration. 46 - **Picker UI.** A colour submenu in the friend row's ellipsis menu in 47 `FriendsView`. `PlayerColor.palette` is already picker-ordered (sorted by 48 hue) and `PuzzleView` (~line 947) has an existing colour-picker menu to 49 crib from, including the checkmark-on-current convention. 50 - **Avatar override.** `FriendAvatar.avatar(for:)` takes an optional 51 override colour; the call sites that should honour it (`FriendsView`, 52 `FriendPickerView`, `GameShareItem`) all have the `FriendEntity` in hand. 53 54 ### Substantive 55 56 - **Roster pinning.** `applyRoster` fetches overrides from Core Data and 57 treats them as *pinned*: seed `taken` with the local colour plus all pins, 58 assign pinned friends their colour directly, and let the existing probe 59 walk the unpinned friends around them. Design decisions to settle: 60 - An explicit pin should win even when it collides with the local user's 61 own colour or another friend's pin — the user did it on purpose. 62 - Two friends pinned to the same colour is the user's prerogative; don't 63 fight it. 64 - Pinning one friend deterministically bumps some auto-assigned friends to 65 new colours (their probe now starts from a different `taken` set). This 66 is inherent and fine, but worth knowing. 67 - **Cross-device sync.** Without sync, iPhone and iPad disagree about a 68 friend's colour. The precedent is the block flag: it syncs across the 69 user's own devices as a `Decision` record in the account zone 70 (`SyncEngine.enqueueDecision(kind: "block", key: authorID)`), projected 71 back onto `FriendEntity` by the `kind` switch in 72 `RecordSerializer.applyDecisionRecord`. A `"color"` kind is maybe 30 lines 73 following that template: 74 - `enqueueDecision` already accepts a `payload: String?` — carry the 75 `colorID` there. 76 - The Decision record name is kind+key, so a re-pin for the same friend 77 overwrites the prior decision — last write wins, which is the behaviour 78 we want. 79 - Clearing a pin (back to automatic) can ride 80 `enqueueDecisionDeletion(kind:key:)`, which also already exists. 81 82 ## Suggested order 83 84 1. Model attribute + `applyDecisionRecord` `"color"` case (so the projection 85 exists before anything writes it). 86 2. Roster pinning in `applyRoster`, with `PlayerRosterTests` coverage for 87 the pin-wins and bump-the-others cases. 88 3. Avatar override plumbing. 89 4. Picker UI in `FriendsView`, writing the entity and enqueuing the 90 Decision.