feat(settings): Profile settings — display name, bio, avatar, theme and the message cap (G34) - #92
Conversation
… the message cap You could not edit your own display name or bio from the macOS app at all, even though UpdateUserRequest had carried displayName, bio and theme since it was written and nothing called them. This adds the pane, and answers the four questions the issue said to probe before building rather than guessing at any of them. Theme is unvalidated server-side. PATCH /api/user/update stored "system", "dark", "light", "auto" and "nonsense" alike. So `.unknown(String)` is not defensive padding, it is the documented behaviour — and the picker has to *offer* the account's own value when it is unrecognised, or the Picker has a selection matching no tag and the first edit to any other field silently rewrites the theme. There is a test asserting the PATCH body omits an untouched unknown theme. There is no change-password route. The live spec has only forgot-password, reset-password and an admin-only one; the web's "current password and a new one" form has no public endpoint. The pane ships reset-by-email and says so, rather than pointing a form at a route that does not exist. The message-cap trap is real, not theoretical. GET /api/limits reports a platform ceiling of 5000 while the account field accepts up to 10000, so a user can set a cap above the ceiling. `ContentLimits.effectiveMessageLength(accountCap:)` is now the single place that decides, returning the lower of the two. The composer was reading the platform ceiling alone and ignoring a cap the user had deliberately set; it now takes the account's cap from the session-cached CurrentUser, which carries it for the same reason it carries defaultPubliclyVisible — it is on the same payload. The setter clamps to 1...10000 so no caller can earn the server's 400, and the pane shows both numbers and says which one is really in force. Both avatar routes exist, so the pane offers a file picker and a URL field like the web. The avatar is written outside the change-gated body, so the saved snapshot is updated alongside the working copy — otherwise Save would light up claiming an unsaved change that does not exist. Profile location is shown and not editable, folded in from #57. That issue was re-scoped after its own probe found that a location can be set through this API and cleared through nothing (#91). Showing it closes the "invisible" half; offering a setter would make this client a way to publish an approximate home location on a public profile that the user can never take back. The pane states that plainly instead of hiding the field. ProfileSettings is deliberately separate from UserSettings: same account, same PATCH route, different question — this is "who am I", that is "how does the app behave". The save reuses PreferencesView's change-gated idiom rather than introducing a second one, so an untouched field is absent from the body and two windows on the same account cannot clobber each other. The account theme does not drive the app's appearance. Storing it and making the Mac follow the system is what ships; driving NSApp.appearance from a server preference is a behaviour change worth its own decision, and the pane says what the setting does today rather than implying more. Refs #46, #57 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9
…Xcode 27
`dev` stopped building when Xcode was updated on this machine mid-session. The
macOS 27 SDK adds a `Document` protocol to SwiftUI —
public protocol Document: ReadableDocument, WritableDocument
— which collides with the domain's `Document` struct in any file importing both.
That is every documents-feature view, and the same unchanged source went from
compiling to eleven `'Document' is ambiguous for type lookup` errors across eight
files.
Only the SwiftUI-importing files are affected, which is what makes the diagnosis
unambiguous: the view models import Foundation, Observation and InterlinedDomain
but not SwiftUI, and they compile untouched.
The fix is to qualify the type at the use sites. Three alternatives were
considered and rejected. Renaming the domain model is the tail wagging the dog —
`Document` is the right name, and it is correct across Kit, Domain, Persistence
and their tests. A module-level typealias would shorten the use sites at the cost
of giving one concept two names, so the next reader has to learn they are the
same thing. Dropping `import SwiftUI` is not available; these are views.
Every edit is a type position. No user-facing string, accessibility label or
other identifier containing the word Document is touched — the diff is eleven
lines, each one a `Document` that the compiler itself pointed at.
Worth knowing rather than fixing: this is a standing hazard. Any domain type
sharing a name with a SwiftUI symbol is one SDK update away from the same break,
and the diagnosis is written down at the top of DocumentsListView so the next
occurrence takes minutes.
Refs #98
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four files conflicted. Three were textual overlaps where both sides add distinct members; the fourth needed more than a mechanical resolution. AppTests/Support/StubUserService.swift was the only file git could not merge itself. Both sides add members to the same type — this branch's profile-settings stubs and #93's identity stubs — so the resolution is to keep both. Rebuilt from dev's version with this branch's four additions reapplied, rather than by concatenating the conflict hunks: the hunk boundaries crossed a closing brace, so a naive keep-both dropped the `}` of `enqueueRequestPasswordReset(failure:)` and nested the rest of the type inside it. It compiled far enough to look plausible and failed with "Attribute 'private' can only be used in a non-local scope" 300 lines later. UserDTO.swift, UserService.swift and SettingsRootView.swift auto-merged, and the first two are genuinely fine — both sides only added. SettingsRootView's auto-merge was textually clean and semantically wrong, which is the interesting part of this merge. PR #94 introduced `SettingsTab` and converted the pane into a `TabView(selection:)`, tagging every tab it knew about. It was written against a dev that predated both the Integrations tab (#93) and the Profile tab (this branch), so neither got a tag. Git had no reason to flag that — no line conflicts — but under `TabView(selection:)` an untagged tab cannot be selected programmatically at all. Both are tagged here, and the enum gains the two cases. The same gap had a second consequence: the sidebar's Integrations row set `settingsTab = .linkedAccounts`, which was the honest best available when no Integrations tab existed and is simply wrong now that one does. A row labelled Integrations opening Linked accounts is the kind of thing that survives review because it compiles. Retargeted. The tab-count assertion becomes a full set. A count was already there and did not catch this — the count was right, the tabs were unreachable — so it now pins the identities, which at least fails loudly when a pane arrives without one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict resolved — and the auto-merge was hiding two real bugsRebased onto current The one git couldn't merge
Worth knowing how I did it: I rebuilt the file from The three that auto-merged — and one that shouldn't have
PR #94 introduced Under The same gap, second consequenceThe sidebar's Integrations row set Both of these are pre-existing on Test strengthenedThe Verification — on Xcode 27, after the merge
Note on the second commitThis branch also merges #99 (the Xcode 27 |
Two files conflicted. One was a true duplicate, and resolving it by keeping both sides would have compiled and been wrong. StubAPIClient.swift: this branch and #87 (now on dev) independently added request- body recording to the test stub, for the same reason, within days of each other. Near-identical code, different local names, different comments. The resolution is one implementation, not two — but each side had a piece the other lacked, so it is not simply "take one". dev's encode also handles `.raw` bodies, which this branch's did not; strictly more complete, so that is the one kept. This branch's `bodyJSON` accessor is what its own AppSettingsServiceTests read (four call sites), and dev has no equivalent, so that is kept too. Both motivating examples stay in the comment because both are real shipped defects: a schema sent as a string where the server demands an object (#85), and `{"name":…}` where the server demands `{"deviceName":…}` (#56). In both cases every path-and-method assertion passed the whole time, which is the argument for recording bodies at all. SettingsRootView.swift auto-merged, and this time the auto-merge is genuinely correct — verified rather than assumed, because the same file merged cleanly and wrongly on #92. All eleven tabs carry a `.tag(SettingsTab...)`, including this branch's rename of Devices to Applications, which kept its `.devices` tag. Under `TabView(selection:)` an untagged tab cannot be selected at all, so the check is worth making by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Closes #46 (G34). Also lands the read-only half of #57.
You could not edit your own display name or bio from the macOS app at all — even though
UpdateUserRequesthad carrieddisplayName,bioandthemesince it was written, and nothing called them.This issue listed four things to probe before building. All four were probed live on 2026-09-15, and two of them changed the design.
1. Theme is unvalidated server-side
So
.unknown(String)is not defensive padding — it is the documented behaviour of the field. The picker must offer the account's own value when it is unrecognised, or SwiftUI renders aPickerwhose selection matches no tag (a blank control) and the first edit to any other field silently rewrites the theme.There is a test asserting the PATCH body omits an untouched unknown theme.
2. There is no change-password route
The live spec carries only
/api/auth/forgot-password,/api/auth/reset-password, and an admin-only/api/admin/users/{userId}/password. The web's "enter your current password and a new one" form has no public endpoint.The pane ships reset-by-email and says so plainly — meeting this issue's own criterion that "no password UI ships pointed at an unverified route".
3. The message-cap trap is reachable, not theoretical
The account range reaches 10000 where the platform stops at 5000 — so a user really can set a cap above the ceiling.
ContentLimits.effectiveMessageLength(accountCap:)is the single place that decides, returning the lower of the two.ComposerViewModel.refreshLimits()was reading the platform ceiling alone, ignoring a cap the user had deliberately set. Fixed.CurrentUsercarriesmaxMessageLengthfor the same reason it carriesdefaultPubliclyVisible— it arrives on the payload the model already maps, so the composer gets it session-cached with no extra round-trip.1...10000, so no caller can earn the 400.4. Both avatar routes exist
POST /api/user/avatar/uploadandPOST /api/user/avatar/from-url, so the pane offers a file picker and a URL field, matching the web.The avatar is written by its own route, outside the change-gated body — so the saved snapshot is updated alongside the working copy, or Save would light up claiming an unsaved change that does not exist.
Profile location: shown, not editable (#57 / #91)
#57 was re-scoped after its own probe found that a location can be set through this API and cleared through nothing (#91 —
null,"",falseand out-of-range are all400; every speculative clear key returns200and changes nothing).The pane therefore shows a published location — closing the "invisible" half of #57 — and offers no setter. Shipping set-without-clear would make macOS a way to publish an approximate home location on a public profile that the user can never take back. A test asserts no coordinate ever reaches a PATCH body.
Design notes
ProfileSettingsis deliberately separate fromUserSettings. Same account, same PATCH route, different question: this is "who am I", that is "how does the app behave".The save reuses
PreferencesView's change-gated idiom rather than introducing a second one, as the issue asked. An untouched field is absent from the body, so two windows on the same account cannot clobber each other's edits.The account theme does not drive the app's appearance. Storing it (it applies on the web) and having the Mac follow the system is what ships. Driving
NSApp.appearancefrom a server preference is a behaviour change deserving its own decision, and the pane says what the setting does today rather than implying more.An empty display name is valid — the server falls back to the username, which is what
/help/settingsdocuments. Rejecting it would invent a rule.Verification
xcodebuild build→** BUILD SUCCEEDED **xcodebuild test(App) →Executed 984 tests, with 0 failures·** TEST SUCCEEDED **(was 968)swift test InterlinedDomain→Executed 1024 tests, with 0 failures(was 1012)swift test InterlinedPersistence→Executed 140 tests, with 0 failuresswift test InterlinedKit --skip ContractTests→Executed 477 tests, with 0 failures— the liveContractTestsare skipped because the account is rate-limited from this session's recon; unmodified by this PR.New tests:
ProfileSettingsViewModelTests(16),ProfileSettingsTests(12).Acceptance
🤖 Generated with Claude Code
https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9