Skip to content

feat(settings): Profile settings — display name, bio, avatar, theme and the message cap (G34) - #92

Merged
Adron merged 4 commits into
devfrom
feat/profile-settings-g34
Sep 17, 2026
Merged

Adron merged 4 commits into
devfrom
feat/profile-settings-g34

Conversation

@Adron

@Adron Adron commented Sep 15, 2026

Copy link
Copy Markdown
Member

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 UpdateUserRequest had carried displayName, bio and theme since 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

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"

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 a Picker whose 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

GET  /api/limits                  → message.maxContentLength: 5000   ← platform
PATCH {"maxMessageLength":500}    → 200
PATCH {"maxMessageLength":99999}  → 400 "must be a positive integer between 1 and 10000"

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.
  • CurrentUser carries maxMessageLength for the same reason it carries defaultPubliclyVisible — it arrives on the payload the model already maps, 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. Both avatar routes exist

POST /api/user/avatar/upload and POST /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, "", false and out-of-range are all 400; every speculative clear key returns 200 and 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

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, 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.appearance from 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/settings documents. 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 failures
  • swift test InterlinedKit --skip ContractTests → Executed 477 tests, with 0 failures — the live ContractTests are skipped because the account is rate-limited from this session's recon; unmodified by this PR.
  • Decision 0003 (anchored) → zero hits

New tests: ProfileSettingsViewModelTests (16), ProfileSettingsTests (12).

Acceptance

  • ✅ 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 — the lower of it and the platform's.
  • ✅ No password UI ships pointed at an unverified route.
  • ✅ Full gate green.

🤖 Generated with Claude Code

https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9

… 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>
Adron and others added 2 commits September 16, 2026 20:32
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>
dev does not compile on Xcode 27 without it. Drops out of this diff once #99
lands on dev.
@Adron

Adron commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

Conflict resolved — and the auto-merge was hiding two real bugs

Rebased onto current dev (which now has #86, #87, #88, #89, #90, #93, #94). Four files conflicted.

The one git couldn't merge

AppTests/Support/StubUserService.swift — both sides add distinct members to the same type: this branch's profile-settings stubs, and #93's identity stubs. Keep both.

Worth knowing how I did it: I rebuilt the file from dev's version with this branch's four additions reapplied, rather than concatenating the conflict hunks. The hunk boundaries crossed a closing brace — 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 then failed 300 lines later with Attribute 'private' can only be used in a non-local scope.

The three that auto-merged — and one that shouldn't have

UserDTO.swift and UserService.swift are genuinely fine; both sides only added, and I verified every member from both survived.

SettingsRootView.swift auto-merged textually clean and semantically wrong. This is the part worth reviewing.

PR #94 introduced SettingsTab and converted the pane to 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 it; there were no conflicting lines.

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, second consequence

The sidebar's Integrations row set settingsTab = .linkedAccounts. That wasn't a typo — it was the honest best available when #94 was written and no Integrations tab existed. #93 has since added one, so a row labelled Integrations that opens Linked accounts is now simply wrong. Retargeted.

Both of these are pre-existing on dev, not introduced by this merge. I fixed them here because they're in the two files this merge touches, each is one line, and the Profile tab I'm adding has the identical cause — shipping a correctly-tagged tab beside two broken ones would be strange.

Test strengthened

The SettingsTab.allCases.count == 9 assertion becomes a full set of raw values. A count was already there and did not catch this — the count was right, the tabs were unreachable. A set at least fails loudly when a pane arrives without an identity.

Verification — on Xcode 27, after the merge

  • xcodebuild build → ** BUILD SUCCEEDED **
  • xcodebuild test (App) → Executed 1034 tests, with 0 failures · ** TEST SUCCEEDED **
  • swift test InterlinedDomain → Executed 1048 tests, with 0 failures
  • swift test InterlinedKit --skip ContractTests → Executed 491 tests, with 0 failures
  • swift test InterlinedPersistence → Executed 140 tests, with 0 failures
  • Decision 0003 (anchored) → zero hits

⚠️ The live ContractTests were skipped, not run — the account is still rate-limited from this session's recon. Unmodified by this PR.

Note on the second commit

This branch also merges #99 (the Xcode 27 Document disambiguation). dev does not compile on Xcode 27 without it, so the App-target gate above could not otherwise have run at all. That commit drops out of this diff the moment #99 lands on dev — merge #99 first and this reduces to the Profile pane plus the two tag fixes.

@Adron
Adron merged commit fc78cf2 into dev Sep 17, 2026
8 checks passed
@Adron
Adron deleted the feat/profile-settings-g34 branch September 17, 2026 04:12
Adron added a commit that referenced this pull request Sep 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant