feat(theme): apply the saved theme before the account loads - #101
Merged
Merged
Conversation
RootView read the theme from `authState.user`, which is nil until GET /api/user answers. The login screen therefore always used the OS appearance, a cold launch flashed the OS appearance before snapping to the preference, and an offline start lost it entirely. Mirror the theme into UserDefaults and read it with @AppStorage, which resolves synchronously at first render — no flash. The server value is still the authority the moment it arrives; ThemePreference.resolve pins that precedence, and both sides fall back to "system" for nil, empty, or unknown strings. The mirror is written in exactly one place, a didSet on AuthState.user, so every load/update path keeps it in step; logout leaves it alone so the login screen keeps the last choice. It's a display preference, not a credential — the Keychain-only rule is about tokens. Logo.imageset gains a dark appearance built from the brand kit's own dark artwork, so the cream #F4EEE2 backing square no longer floats as a bright block on black. The mark itself is untouched: 2x/3x are icons/ios/ AppIcon-dark-1024.png resampled to the sizes the light slots already use (they are AppIcon-120/180), and 1x is logo/logo-icon-master.png — the transparent master — flattened onto the same dark tile, mirroring how the light 1x is flattened onto its own background. Links rendered iOS system blue in both appearances despite AccentColor being wired, so tint RootView with ILColor.link. A sheet inherits the color-scheme override (a window-level trait) but not the tint, so the app-level deep-link sheet needs its own — measured on the simulator, not assumed. Tests cover parsing, the server-wins precedence, and a mirror surviving a "relaunch" through a fresh store instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
This was referenced Sep 16, 2026
Closed
Conflict confined to project.pbxproj, but a plain union of both sides was wrong here: this branch was stacked on #97 and so carries object definitions main had already picked up at a different position in the file. Taking both produced six duplicate UUID definitions — a corrupt project, not a merge. Resolved by unioning the hunks and then dropping any object definition whose UUID was already defined earlier in the file. Verified afterwards that ThemePreference.swift and ThemePreferenceTests.swift each still have exactly one PBXBuildFile, Sources entry, PBXFileReference and group entry, and that the mute files main contributed are likewise intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Partially addresses #80. Implements gaps 1, 2 and 3 and the verification pass from gap 5. It does
not close the issue: gap 4 (adopting the Strata dark palette across ~54 view files) is a design
decision being split into its own issue and is untouched here.
RootViewreadauthState.user?.theme, anduserisnil until
GET /api/useranswers, so the login screen always used the OS appearance, a cold launchflashed it, and an offline start lost the preference. The theme is now mirrored into
UserDefaultsand read by
RootViewthrough@AppStorage, which resolves synchronously at first render.Logo.imagesetnow has a dark appearance, so the cream#F4EEE2backingsquare stops floating as a bright block on black.
RootViewis tinted withILColor.link.What's included
Persistence mirror (
InterlinedList/Services/ThemePreference.swift, new)ThemePreference—system/light/dark, a lenientinit(stored:)(nil,"","Dark","chartreuse"all resolve sanely),colorScheme, and a pureresolve(serverTheme:mirrored:)that pins the precedence: the server value wins the moment itexists; the mirror only stands in while it doesn't. An unknown server value falls back to
"system" rather than to the mirror — the account is the authority even when it says something we
don't understand.
ThemePreferenceStore— the only writer,UserDefaultsinjected so tests use a throwaway suite.Keychain-only rule in
CLAUDE.mdis about tokens.One write point (
Services/AuthState.swift)didSetonAuthState.user, which every path that loads or updatesthe account already funnels through (
validateSession,login,completeOAuthLogin,refreshUser,handleUnauthorized,updateUser) — so it can't drift.logout()setsuser = niland deliberately leaves the mirror alone, so the login screen keeps the last choice.
Reading it (
Views/RootView.swift)@AppStorage(ThemePreference.storageKey)— readable synchronously at first render, and it alsopicks up the
UserDefaultswrite when the server value lands.Dark logo (
Assets.xcassets/Logo.imageset) — option (a), using artwork that was already inbrand-kit/, so no new brand artwork was invented and the mark is not altered:Logo-Dark@2x.png/Logo-Dark@3x.png—brand-kit/icons/ios/AppIcon-dark-1024.png(the brandkit's own near-black tile,
#14161A) resampled to 120 / 180 px. Those are exactly the sizes thelight slots use, because the existing
Logo@2x.png/Logo@3x.pngare byte-identical toAppIcon-120.png/AppIcon-180.png— the light variant is the sand app-icon tile, so the darkvariant is its shipped counterpart.
Logo-Dark.png(1x) —brand-kit/logo/logo-icon-master.png, the transparent master, flattenedonto that same
#14161A. This mirrors how the existing light 1x is the same master flattened ontoits own near-white background, so the two 1x slots stay geometrically identical. (The 1x slot is
not selected on any current device; it is filled for completeness.)
disappears instead of reading as a cream block, while the mark's colors are unchanged. Light is
untouched.
Brand tint
RootViewgets.tint(ILColor.link)— one place, not per-view.InterlinedListApp.swiftneeds its own.tint: a sheet inheritsthe color-scheme override but not the tint (measured, see below).
Tests —
InterlinedListTests/ServiceTests/ThemePreferenceTests.swift(14 tests): parsing and theunknown/nil/empty/mixed-case fallbacks,
colorSchememapping, all fourresolveprecedence cases,and the mirror surviving a "relaunch" by reading it back through a fresh store over the same suite.
Out of scope
is still Apple's dark for everything except the masthead;
ILColor.background/.surface/.surface2remain largely unused. Should be split into its own issue.UILaunchScreenis static and can't readUserDefaults, so a dark-theme user on a light-appearance device sees a white launch image for thefraction of a second before the first app frame. Not fixable from the app side without a custom
launch-screen hack; flagging rather than hiding it.
app-icon tiles. Pre-existing inconsistency; left as-is (new dark 1x mirrors it).
Testing
Unit suite:
1163 tests, 0 failures— pinned to simulator23BEF027-EF26-48AB-8806-177A348FCB7F(iPhone 16 Pro), own
-derivedDataPath,-parallel-testing-enabled NO,-skip-testing:InterlinedListTests/E2EReadOnlyTests. No new build warnings.Simulator pass — login screen, before and after. Screenshots are on the machine that produced this
branch (not uploadable here); paths given so they can be opened directly:
…/scratchpad/shots/before-login-light.png…/scratchpad/shots/before-login-dark.png…/scratchpad/shots/after-login-light.png…/scratchpad/shots/after-login-dark.png…/scratchpad/shots/after-login-mirror-dark-os-light.png…/scratchpad/shots/after-login-mirror-light-os-dark.png…/scratchpad/shots/launch-burst-*.png…/scratchpad/shots/probe-sheet-*.png(full prefix:
/private/tmp/claude-501/-Users-adron-Codez-interlinedlist-ios/6b42a4d1-9b8a-4311-904a-4798b8704eb2/scratchpad/shots/)What changed, precisely:
appearances — confirmed, despite
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColoralready pointing at a correct dual-value brand-green accent color. After: deep teal
#184860inlight,
#7FB8C4in dark — i.e.ILColor.link.#F4EEE2square, the single brightest object on an otherwiseblack screen. After, dark: a
#14161Atile that reads as flat background; the green/amber strokescarry the mark and the teal strokes sit dim exactly as they do in the brand kit's own dark icon.
Light is pixel-identical to before.
dark (masthead
#0C2C3A, black page, teal-accent links). With the OS in dark and the savedtheme light, it renders light (masthead
#184860,#F2F2F7page). Both directions verified ona build with no session — this is the login screen, before any
GET /api/user.simctl launch(saved theme dark, OSlight): frame 1 is the static launch screen, and every subsequent frame is already dark — there
is no intermediate light app frame. Structurally there can't be:
@AppStorageresolves during thefirst render pass, before any network call starts.
Not verified
round-trip were not exercised — that needs a keychain-seeded account and I had none. The picker →
PATCH /api/user/update→AuthState.updateUser→ mirror path is covered only by unit tests and byreading the code; nobody has watched it happen.
themePreferencedirectly into the app's defaultsdomain (
simctl spawn … defaults write), which proves the read side end to end but stands in fora real logged-in save.
The open question from #80 — does the deep-link sheet inherit the override?
Yes for the color scheme, no for the tint. Measured, not inferred: I temporarily set
router.pendingDeepLinkat launch in a throwaway build (not committed) to present the sheet withoutneeding a tap on SpringBoard's "Open in…" confirmation, with the OS in light and the saved theme dark.
preferredColorSchemeset insideRootViewreaches it, because itresolves to a window-level trait override rather than an environment value that would stop at the
presentation boundary.
.tintup to theRootView()instance inInterlinedListApp, above the.sheet, did not fix it either — tint does not cross into sheetcontent. Applying
.tint(ILColor.link)to the sheet's content does, and it now renders#7FB8C4.That is the one-line change in
InterlinedListApp.swift.🤖 Generated with Claude Code