Skip to content

feat(theme): apply the saved theme before the account loads - #101

Merged
Adron merged 2 commits into
mainfrom
feat/theme-preauth-and-branding
Sep 17, 2026
Merged

Adron merged 2 commits into
mainfrom
feat/theme-preauth-and-branding

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stacked on #97 (fix/theme-system-persists). #97 must merge first — this branch is based on
it and targets it, not main. Without #97 the picker never persists "System", so the mirror added
here would faithfully mirror a value the server never stored.

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.

  • Gap 1 — the preference was server-only. RootView read authState.user?.theme, and user is
    nil until GET /api/user answers, so the login screen always used the OS appearance, a cold launch
    flashed it, and an offline start lost the preference. The theme is now mirrored into UserDefaults
    and read by RootView through @AppStorage, which resolves synchronously at first render.
  • Gap 2 — no dark logo. Logo.imageset now has a dark appearance, so the cream #F4EEE2 backing
    square stops floating as a bright block on black.
  • Gap 3 — system-blue links. RootView is tinted with ILColor.link.

What's included

Persistence mirror (InterlinedList/Services/ThemePreference.swift, new)

  • ThemePreference — system / light / dark, a lenient init(stored:) (nil, "", "Dark",
    "chartreuse" all resolve sanely), colorScheme, and a pure
    resolve(serverTheme:mirrored:) that pins the precedence: the server value wins the moment it
    exists; 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, UserDefaults injected so tests use a throwaway suite.
  • This is a display preference, not a credential, so it is deliberately not in the Keychain. The
    Keychain-only rule in CLAUDE.md is about tokens.

One write point (Services/AuthState.swift)

  • The mirror is written from a didSet on AuthState.user, which every path that loads or updates
    the account already funnels through (validateSession, login, completeOAuthLogin,
    refreshUser, handleUnauthorized, updateUser) — so it can't drift. logout() sets user = nil
    and 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 also
    picks up the UserDefaults write when the server value lands.

Dark logo (Assets.xcassets/Logo.imageset) — option (a), using artwork that was already in
brand-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 brand
    kit's own near-black tile, #14161A) resampled to 120 / 180 px. Those are exactly the sizes the
    light slots use, because the existing Logo@2x.png / Logo@3x.png are byte-identical to
    AppIcon-120.png / AppIcon-180.png — the light variant is the sand app-icon tile, so the dark
    variant is its shipped counterpart.
  • Logo-Dark.png (1x) — brand-kit/logo/logo-icon-master.png, the transparent master, flattened
    onto that same #14161A. This mirrors how the existing light 1x is the same master flattened onto
    its 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.)
  • Result in dark: the tile is within ~2% luminance of the page background, so the square effectively
    disappears instead of reading as a cream block, while the mark's colors are unchanged. Light is
    untouched.

Brand tint

  • RootView gets .tint(ILColor.link) — one place, not per-view.
  • The app-level deep-link sheet in InterlinedListApp.swift needs its own .tint: a sheet inherits
    the color-scheme override but not the tint (measured, see below).

Tests — InterlinedListTests/ServiceTests/ThemePreferenceTests.swift (14 tests): parsing and the
unknown/nil/empty/mixed-case fallbacks, colorScheme mapping, all four resolve precedence cases,
and the mirror surviving a "relaunch" by reading it back through a fresh store over the same suite.

Out of scope

  • Gap 4 — the Strata dark palette across the view layer. Not started, by instruction. Dark mode
    is still Apple's dark for everything except the masthead; ILColor.background / .surface /
    .surface2 remain largely unused. Should be split into its own issue.
  • The launch screen still renders in the OS appearance — UILaunchScreen is static and can't read
    UserDefaults, so a dark-theme user on a light-appearance device sees a white launch image for the
    fraction 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.
  • The light 1x logo asset is a 321 px near-white flatten while light 2x/3x are the 120/180 cream
    app-icon tiles. Pre-existing inconsistency; left as-is (new dark 1x mirrors it).

Testing

Unit suite: 1163 tests, 0 failures — pinned to simulator 23BEF027-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:

What Path
before, OS light …/scratchpad/shots/before-login-light.png
before, OS dark …/scratchpad/shots/before-login-dark.png
after, OS light …/scratchpad/shots/after-login-light.png
after, OS dark …/scratchpad/shots/after-login-dark.png
after, saved theme dark + OS light …/scratchpad/shots/after-login-mirror-dark-os-light.png
after, saved theme light + OS dark …/scratchpad/shots/after-login-mirror-light-os-dark.png
launch burst frames 1–6 …/scratchpad/shots/launch-burst-*.png
deep-link sheet, before/after tint …/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:

  • Links. Before: "Forgot password?" and "Create account" rendered iOS system blue in both
    appearances — confirmed, despite ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor
    already pointing at a correct dual-value brand-green accent color. After: deep teal #184860 in
    light, #7FB8C4 in dark — i.e. ILColor.link.
  • Logo. Before, dark: a solid cream #F4EEE2 square, the single brightest object on an otherwise
    black screen. After, dark: a #14161A tile that reads as flat background; the green/amber strokes
    carry 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.
  • Pre-auth theme. With the OS in light and the saved theme dark, the login screen renders
    dark (masthead #0C2C3A, black page, teal-accent links). With the OS in dark and the saved
    theme light, it renders light (masthead #184860, #F2F2F7 page). Both directions verified on
    a build with no session — this is the login screen, before any GET /api/user.
  • No flash. A burst of screenshots taken immediately after simctl launch (saved theme dark, OS
    light): 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: @AppStorage resolves during the
    first render pass, before any network call starts.

Not verified

  • Every logged-in screen. Feed, tab bar, lists, documents, settings, and the theme picker
    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 by
    reading the code; nobody has watched it happen.
  • The mirror write itself was exercised by seeding themePreference directly into the app's defaults
    domain (simctl spawn … defaults write), which proves the read side end to end but stands in for
    a real logged-in save.
  • Physical device, iPad, and Dynamic Type were not checked.

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.pendingDeepLink at launch in a throwaway build (not committed) to present the sheet without
needing a tap on SpringBoard's "Open in…" confirmation, with the OS in light and the saved theme dark.

  • The sheet rendered dark — preferredColorScheme set inside RootView reaches it, because it
    resolves to a window-level trait override rather than an environment value that would stop at the
    presentation boundary.
  • Its "Cancel" button rendered system blue. Moving .tint up to the RootView() instance in
    InterlinedListApp, above the .sheet, did not fix it either — tint does not cross into sheet
    content. 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

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
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
@Adron
Adron merged commit 0438f0f into main Sep 17, 2026
1 check passed
@Adron
Adron deleted the feat/theme-preauth-and-branding branch September 17, 2026 08:59
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