Summary
You cannot edit your own display name or bio from the macOS app at all. UpdateUserRequest already carries displayName, bio and theme (UserDTO.swift:169–171) and nothing calls them. Several other documented Profile settings are equally absent.
The documented pane (/help/settings ▸ Profile settings)
| Setting |
Documented |
macOS today |
| Display Name |
"How you appear to others (defaults to your username if not set)" |
❌ |
| Bio |
"Short description shown on your public profile" |
❌ |
| Avatar |
"upload a file or set from a URL" |
❌ |
| Theme |
Light / dark / system |
❌ (deliberately deferred — values were unconfirmed) |
| Max message length |
"Character limit per message (default 666)" |
❌ |
Plus, from Security: Change password and Forgot password.
Live facts (probed 2026-09-07)
GET /api/user on the test account returns "theme":"light" and "maxMessageLength":666, so both are real, populated fields.
POST /api/user/avatar/from-url exists in UserEndpoint with no UserServicing method behind it.
POST /api/auth/forgot-password is already in Kit (AuthEndpoint.swift:19–26) and is not wired to any UI.
⚠️ There is no change-password route in the live spec. The only password routes are POST /api/auth/forgot-password and POST /api/auth/reset-password (plus an admin-only one). The web's "Change password: enter your current password and a new one" has no documented public endpoint. Probe before building — either find the route the web form posts to, or implement the reset-by-email flow and say so plainly in the pane. Do not guess a path.
Two things to get right
- Theme.
UserSettings deliberately omits it because the valid value set was unconfirmed. The web offers exactly three (System / Light / Dark) and the live account holds "light". Probe PATCH /api/user/update with each of the three before widening the typed surface, and keep an .unknown(String) case so a future value cannot fail the decode.
- Max message length is not the server ceiling.
GET /api/limits (shipped as G14) is the platform maximum; maxMessageLength on the account is a user-chosen cap underneath it. The composer must honour the lower of the two, and the pane must say which is which. Getting this backwards would let a user set a limit the server rejects, or silently raise their own ceiling past the platform's.
maxMessageLength is on UserDTO but absent from UpdateUserRequest — that is a Kit change, not just a UI one.
Division of labor
Kit
Domain
App
Tests
Acceptance criteria
- A user can set their display name, bio, and avatar without leaving the app.
- The composer's character budget matches whatever the account cap says.
- No password UI ships pointed at an unverified route.
- Full E2E gate green.
Notes
work-consolidation.md tracks this as G34. Size M.
Implementation plan (added 2026-09-15) — all four probes done
Every "probe before building" in this issue was resolved live on 2026-09-15.
1. Theme — the three work, and so does anything else
PATCH /api/user/update {"theme":"system"} → 200, stored "system"
{"theme":"dark"} → 200, stored "dark"
{"theme":"light"} → 200, stored "light"
{"theme":"auto"} → 200, stored "auto"
{"theme":"nonsense"} → 200, stored "nonsense"
The field is unvalidated. So .unknown(String) is not defensive padding — it is the documented behaviour, and the picker must offer the account's own value when it is unrecognised. Otherwise the Picker has a selection matching no tag (SwiftUI renders a blank control) and the first edit to any other field silently rewrites the theme. Pinned by a test that asserts 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.
So the pane ships reset-by-email and says so plainly. No password UI points at an unverified route, which is this issue's own acceptance criterion.
3. maxMessageLength — and the ceiling trap, confirmed reachable
GET /api/limits → message.maxContentLength: 5000 ← the platform
PATCH {"maxMessageLength":500} → 200
PATCH {"maxMessageLength":99999} → 400 "must be a positive integer between 1 and 10000"
PATCH {"maxMessageLength":0} → 400 same
The account range reaches 10000 while the platform stops at 5000 — so a user really can set a cap above the ceiling. This issue named the trap and the numbers make it reachable rather than theoretical.
ContentLimits.effectiveMessageLength(accountCap:) is the single place that decides, returning the lower of the two.
ComposerViewModel.refreshLimits() used the platform ceiling alone, ignoring a cap the user had deliberately set. Fixed here.
CurrentUser carries maxMessageLength, for the same reason it carries defaultPubliclyVisible: it is on the GET /api/user payload already, so the composer gets it session-cached with no extra round-trip.
- The setter clamps to
1...10000, so no caller can earn the 400.
- The pane shows both numbers and warns when the account cap is the one not in force.
4. Avatar — both halves exist
POST /api/user/avatar/upload and POST /api/user/avatar/from-url. The pane offers a file picker and a URL field, matching the web. uploadAvatar already had a service method; setAvatarFromURL did not.
The avatar is written by its own route, outside the change-gated body — so both the working copy and the saved snapshot are updated, or Save would light up claiming an unsaved change that does not exist.
Division of labour as built
Kit — UpdateUserRequest += maxMessageLength (it was on UserDTO and absent from the request, so the value was readable and unwritable).
Domain — ProfileSettings + AppTheme + ProfileLocation; ContentLimits.effectiveMessageLength(accountCap:); CurrentUser.maxMessageLength; UserServicing.profileSettings / updateProfileSettings(_:changedFrom:) / setAvatarFromURL / requestPasswordReset.
ProfileSettings is kept separate from UserSettings on purpose: same account, same PATCH route, different question — this is "who am I", that is "how does the app behave".
App — ProfileSettingsView + view model, registered as a Settings tab. Reuses PreferencesView's change-gated Save idiom rather than introducing a second save shape, as this issue asked.
Profile location — read-only, and #57 is why
Folded in from #57, which was re-scoped after its own probe: a location can be set through the API and cleared through nothing (#91). The pane therefore shows a published location — closing the "invisible" half of #57 — and offers no setter, because shipping set-without-clear would make macOS a way to publish an approximate home location that the user can never take back.
On the theme not driving appearance
This issue asked whether the account theme should override or seed the system appearance. Neither, for now: the value is stored on the account and applies on the web, and the Mac app follows the system appearance. Driving NSApp.appearance from a server preference is a behaviour change worth its own decision, and the pane says plainly what the setting does today rather than implying more.
Summary
You cannot edit your own display name or bio from the macOS app at all.
UpdateUserRequestalready carriesdisplayName,bioandtheme(UserDTO.swift:169–171) and nothing calls them. Several other documented Profile settings are equally absent.The documented pane (
/help/settings▸ Profile settings)Plus, from Security: Change password and Forgot password.
Live facts (probed 2026-09-07)
GET /api/useron the test account returns"theme":"light"and"maxMessageLength":666, so both are real, populated fields.POST /api/user/avatar/from-urlexists inUserEndpointwith noUserServicingmethod behind it.POST /api/auth/forgot-passwordis already in Kit (AuthEndpoint.swift:19–26) and is not wired to any UI.POST /api/auth/forgot-passwordandPOST /api/auth/reset-password(plus an admin-only one). The web's "Change password: enter your current password and a new one" has no documented public endpoint. Probe before building — either find the route the web form posts to, or implement the reset-by-email flow and say so plainly in the pane. Do not guess a path.Two things to get right
UserSettingsdeliberately omits it because the valid value set was unconfirmed. The web offers exactly three (System / Light / Dark) and the live account holds"light". ProbePATCH /api/user/updatewith each of the three before widening the typed surface, and keep an.unknown(String)case so a future value cannot fail the decode.GET /api/limits(shipped as G14) is the platform maximum;maxMessageLengthon the account is a user-chosen cap underneath it. The composer must honour the lower of the two, and the pane must say which is which. Getting this backwards would let a user set a limit the server rejects, or silently raise their own ceiling past the platform's.maxMessageLengthis onUserDTObut absent fromUpdateUserRequest— that is a Kit change, not just a UI one.Division of labor
Kit
maxMessageLengthtoUpdateUserRequest.avatarupload:POST /api/user/avatar/from-urlis present; check whether a multipart file-upload route also exists and add it if so.Domain
UserSettings(or a siblingProfileSettings) withdisplayName,bio,theme,maxMessageLength; keep the existing change-gated PATCH discipline so untouched fields are never clobbered.UserServicingmethods for avatar-from-URL and avatar-file-upload.ContentLimits+ account-cap reconciliation helper so the composer has one number to ask for.App
PreferencesView— do not introduce a second save idiom.Tests
maxMessageLengthat, below, and above theGET /api/limitsceiling; composer honours the lower of the twoAcceptance criteria
Notes
work-consolidation.mdtracks this as G34. Size M.Implementation plan (added 2026-09-15) — all four probes done
Every "probe before building" in this issue was resolved live on 2026-09-15.
1. Theme — the three work, and so does anything else
The field is unvalidated. So
.unknown(String)is not defensive padding — it is the documented behaviour, and the picker must offer the account's own value when it is unrecognised. Otherwise thePickerhas a selection matching no tag (SwiftUI renders a blank control) and the first edit to any other field silently rewrites the theme. Pinned by a test that asserts 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.So the pane ships reset-by-email and says so plainly. No password UI points at an unverified route, which is this issue's own acceptance criterion.
3.
maxMessageLength— and the ceiling trap, confirmed reachableThe account range reaches 10000 while the platform stops at 5000 — so a user really can set a cap above the ceiling. This issue named the trap and the numbers make it reachable rather than theoretical.
ContentLimits.effectiveMessageLength(accountCap:)is the single place that decides, returning the lower of the two.ComposerViewModel.refreshLimits()used the platform ceiling alone, ignoring a cap the user had deliberately set. Fixed here.CurrentUsercarriesmaxMessageLength, for the same reason it carriesdefaultPubliclyVisible: it is on theGET /api/userpayload already, so the composer gets it session-cached with no extra round-trip.1...10000, so no caller can earn the 400.4. Avatar — both halves exist
POST /api/user/avatar/uploadandPOST /api/user/avatar/from-url. The pane offers a file picker and a URL field, matching the web.uploadAvataralready had a service method;setAvatarFromURLdid not.The avatar is written by its own route, outside the change-gated body — so both the working copy and the saved snapshot are updated, or Save would light up claiming an unsaved change that does not exist.
Division of labour as built
Kit —
UpdateUserRequest+=maxMessageLength(it was onUserDTOand absent from the request, so the value was readable and unwritable).Domain —
ProfileSettings+AppTheme+ProfileLocation;ContentLimits.effectiveMessageLength(accountCap:);CurrentUser.maxMessageLength;UserServicing.profileSettings/updateProfileSettings(_:changedFrom:)/setAvatarFromURL/requestPasswordReset.ProfileSettingsis kept separate fromUserSettingson purpose: same account, same PATCH route, different question — this is "who am I", that is "how does the app behave".App —
ProfileSettingsView+ view model, registered as a Settings tab. ReusesPreferencesView's change-gated Save idiom rather than introducing a second save shape, as this issue asked.Profile location — read-only, and #57 is why
Folded in from #57, which was re-scoped after its own probe: a location can be set through the API and cleared through nothing (#91). The pane therefore shows a published location — closing the "invisible" half of #57 — and offers no setter, because shipping set-without-clear would make macOS a way to publish an approximate home location that the user can never take back.
On the theme not driving appearance
This issue asked whether the account theme should override or seed the system appearance. Neither, for now: the value is stored on the account and applies on the web, and the Mac app follows the system appearance. Driving
NSApp.appearancefrom a server preference is a behaviour change worth its own decision, and the pane says plainly what the setting does today rather than implying more.