crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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.