Skip to content

Let each account carry its own theme - #303

Merged
dmnyc merged 5 commits into
mainfrom
feat/per-account-themes
Sep 11, 2026
Merged

Let each account carry its own theme#303
dmnyc merged 5 commits into
mainfrom
feat/per-account-themes

Conversation

@dmnyc

@dmnyc dmnyc commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Closes #266. Stacked on #302, which established the storage shape.

With several accounts the panel looked identical whether you were in your main identity or a throwaway, and the account name was the only thing telling them apart. A theme answers "which account am I in" before you read anything.

The asymmetry is the design

Three surfaces read the theme and they do not all follow the account:

Surface Follows the account?
Panel Yes. The obvious one.
Approval window Yes, and it's the valuable one. That window is about a specific identity, and it's where picking the wrong one costs money.
Pay card No, and must not.

content.js reads settings through a deliberately clamped path so a visited site can't fingerprint config. Handing it a per-account theme would tell the site which account is active on it. The file is untouched, and there's a test that fails if themeBy ever appears in it.

Storage

Same map-with-fallback as nip65OnlyBy and defaultClientBy, including its own message for the same documented reason: SIDECAR_SET_SETTINGS merges shallowly, so a panel sending the whole map would clobber another account's theme and racing panels would lose one. Clearing deletes the key rather than storing an empty string.

The two edges #266 asked about

Locked falls back to the global. The lock screen paints before the panel knows who's unlocking.

When the switch applies: on the switch itself. Every account switch already calls refresh(), which re-runs the boot apply, so nothing extra was needed. No preview, per the issue's own instinct that it'd be a distraction.

The way back

The card grid has no "off" position, so an account could take a theme and never give it back. A line under the heading says which state you're in and carries the exit:

  • Following the global: "Following the default for all accounts."
  • Own theme: "This account has its own theme. Use the default" with the last part as a button.

Onboarding still writes the global, since before any account exists there's nobody to attribute the choice to.

Verification

12 unit tests, plus exercised in a real panel end to end: picking Nixie wrote themeBy[pubkey] and left the global unset, and "Use the default" cleared the key and reverted the panel to Speakeasy.

trust-nudge.test.js pinned the exact old payload expression, so it's updated to assert the account resolution and the global fallback separately rather than weakened.

With several accounts the panel looked identical whether you were in your
main identity or a throwaway, and the account name was the only thing
telling them apart. A theme answers "which account am I in" before you read
anything.

Same map-with-fallback shape as nip65OnlyBy and defaultClientBy, including
its own message: SIDECAR_SET_SETTINGS merges shallowly, so a panel sending
the whole map would clobber another account's theme and racing panels would
lose one. Clearing deletes the key rather than storing an empty string.

THREE SURFACES READ THE THEME AND THEY DO NOT ALL FOLLOW THE ACCOUNT. That
asymmetry is the design, not an oversight:

- The panel follows it. The obvious one.
- The approval window follows it, and is the valuable one. That window is
  about a specific identity, and it is where picking the wrong one costs
  money — so it is the surface a per-account theme most needs to reach,
  not an afterthought.
- The pay card DOES NOT. content.js reads settings through a deliberately
  clamped path so a visited site cannot fingerprint config. Handing it a
  per-account theme would tell the site WHICH ACCOUNT IS ACTIVE ON IT.
  content.js is untouched and there is a test that fails if themeBy ever
  appears in it.

Two edges the issue asked about. Locked falls back to the global, because
the lock screen paints before the panel knows who is unlocking. And the
theme changes on the switch itself: every account switch already calls
refresh(), which re-runs the boot apply, so nothing extra was needed.

The card grid has no "off" position, so a line under the heading says which
state the account is in and carries the way back to the global. Without it
an account could take a theme and never give it back.

Onboarding still sets the global, since before any account exists there is
nobody to attribute the choice to and the global is what every new account
inherits.

trust-nudge.test.js pinned the exact old payload expression; updated to
assert the account resolution and the global fallback separately.
"Following the default for all accounts." was explaining the mechanism when
the only thing the line needs to say is which theme you are looking at.
"Using default theme" says it in three words.
Regression from the previous commit. Once the picker wrote only themeBy,
NOTHING wrote settings.theme any more except the onboarding branch — so the
moment a user had an account the global froze, and every surface reading it
froze with it. The pay card in content.js reads the global deliberately (it
must not track the active account, or it tells a page which account that
is), so it stayed on whatever theme was set before the first account
existed, forever.

The picker now writes both. The global is demoted to what it should have
been from the start: the default a new account inherits, and what the
surfaces that cannot follow the account read.

This does move accounts that have never chosen one, which is the same rule
the client picker already follows — a default is a default, and changing it
moves everyone still following it.

It does not reintroduce the leak #266 was protecting against. The card
changes when a theme is PICKED, never when accounts are SWITCHED, so a page
still cannot learn which account is active on it.
Reverts the previous commit. Writing settings.theme on every pick meant one
account's choice became the default, so a second account that had never
chosen showed the first account's theme instead of the built-in default.
That is the opposite of what per-account themes are for, and it is worse
than the problem it was solving.

The cost is back: settings.theme is only writable during onboarding, so the
pay card shows the default rather than tracking picks. content.js reads the
global deliberately — it must not follow the active account or it tells a
page which account that is — so a stable card is the lesser problem here.

Making the default settable again needs its own design, not a side effect of
the picker. Noted on #266.
Two reported bugs, one cause: settings.theme was doing two jobs at once,
the pay card's palette AND the default an account that never chose
inherits, so every arrangement of that one field failed differently. A
pick that wrote only themeBy froze the card on whatever was set before
the first account existed. Writing both handed one account's pick to
every account that had never chosen. Letting the account's theme win over
the gallery made all twelve cards inert for any account that had one, and
a separate cardTheme written per pick matched only the account picked for
most recently.

So the gallery goes back to meaning what #266 asked for: it dresses the
account you are in, writing themeBy and leaving settings.theme alone as
the default a new account inherits. Onboarding still writes the default,
since before any account exists there is nobody to attribute a choice to.
The account's own menu keeps a dropdown for dressing any other account,
and it is the only way back to the default now that every card is a
theme.

The card is the part that took five tries, because it renders into a page
that can see it. It cannot follow the active account: a color change on a
switch is a switch detector a page can poll by cycling its own invoice,
and the active identity may be one the site has never seen. It follows
the account THAT SITE IS BOUND TO instead, whose pubkey the site already
holds, so the palette tells it nothing it does not know. background.js
resolves it in the clamped GET_SETTINGS, where the binding and the
settings can be joined, and themeBy still never crosses into a content
script. It is also less of a fingerprint than the single global it
replaces, which showed two sites bound to two accounts one shared value.

Marking the gallery moved out of applyTheme into paintThemePicker, since
applyTheme runs on every render and was mounting twelve preview documents
on the lock screen to mark a card nobody was looking at. Settings mounts
them when it opens. The account modal also lost a race where the value
arriving from the background could overwrite a pick made in that instant,
and its buttons now sit in the shared full-width actions row instead of
loose in the sheet.
dmnyc added a commit that referenced this pull request Sep 9, 2026
A theme belongs to an account, not to the install (themeBy, #266/#303), so
reading only settings.theme would have handed these pages the default that
an account which never chose inherits. That is this branch's own bug back
again for anyone using per-account themes: on Par Avion, opening the guide
would still give you a dark page out of nowhere.

theme-boot now resolves sidecar_active_pubkey against themeBy and falls
back to settings.theme. Both keys are a plain chrome.storage.local read,
so nothing has to be asked of the background.

RESOLVED HERE, unlike the pay card, and the difference is worth stating
because they look like the same problem. That card renders into a web page
that can see it, so it must never learn which account is active and takes
the theme of the account its own site is bound to instead. help.html,
welcome.html and wallets.html are chrome-extension:// documents that
nothing on the web can read, so the account on screen is simply the right
answer.

The change is a no-op until #303 lands: with no themeBy in storage the
first term is undefined and it falls through to settings.theme, which is
exactly what shipped before. So this does not care which of the two merges
first, and the two branches share no files, meaning they would otherwise
have merged clean and been silently wrong.

The storage listener now watches sidecar_active_pubkey as well, and
re-reads rather than pulling a value out of the change record, because the
answer needs both keys and each one moves alone. Switching account now
repaints a guide already open in another tab, the way a theme change
already did.

Verified in a browser across all four states: no themeBy at all wears the
global and keeps .theme-light; giving the active account Nixie repainted an
already-open help.html live and dropped the class; switching to an account
on Bauhaus followed and restored it; clearing that account's theme fell
back to the default. Suite: 1,034 pass, with search-entity and web-comment
still red on the pre-existing missing nostr-tools that #296 fixes.
Base automatically changed from feat/per-account-client to main September 11, 2026 14:19
@dmnyc
dmnyc merged commit 7372dfe into main Sep 11, 2026
2 checks passed
@dmnyc
dmnyc deleted the feat/per-account-themes branch September 11, 2026 14:19
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.

Per-account themes: let each account carry its own, stored locally

1 participant