Repository navigation
UI Consistency: 1 issue found
apps/web/src/components/settings/ThemeSettings.tsx (L837-840) — onUse on a published environment theme calls assignHalf(half, environmentTheme.id), persisting an id that only resolves while the machine publishes it. setThemeHalf merges over readStoredThemeHalves(), and parseThemeHalves drops halves whose id getThemeDefinition cannot resolve, then writes the merged result back — so a later change to the other half made before the published set has arrived (offline, or the gap between page load and the config subscription) silently erases the environment half from t3code:theme-halves. Suggested fix: have useTheme.setThemeHalf merge over the raw stored JSON instead of the pruned parse.
Previously flagged issues are resolved on this head: the published-theme card now follows the same one-appearance rule as custom cards (singleAppearanceOf + assignHalf), managed: true is limited to the pure seeded form, and default-theme adoption sets the matching appearance mode for a single-appearance theme.
Details
Note
Your check run agent prompt is: .macroscope/check-run-agents/ui-consistency.md
More information about how Check Run Agents work can be found in our Help Center.
Files examined in scope: apps/web/src/components/settings/ThemeSettings.tsx, apps/web/src/hooks/useEnvironmentTheme.ts, apps/web/src/hooks/useDefaultTheme.ts, apps/web/src/themePalette.ts, apps/web/src/state/server.ts, apps/web/src/routes/__root.tsx, with apps/web/src/hooks/useTheme.ts and packages/shared/src/themePalettes.ts read as context.
Checks performed and found clean:
- Reserved-id move to
@t3tools/sharedis equivalent to the previous local set (built-ins plus appearance keywords plus legacy aliases);installCustomTheme/updateCustomTheme/import validation still consult the same set. getThemeDefinitionresolves environment themes last, so a saved theme wins an id collision; reserved ids are filtered before cards are built.refreshThemeclearslastAppliedTheme, so a machine rewriting a published palette under the same id does repaint — the feature's headline claim holds.Equal.equalsstructural comparison on plain arrays is the pattern this codebase already relies on, so the reconnect dedupe inuseEnvironmentThemeSyncandsetEnvironmentThemesbehaves as documented.- Theme state is read from the primary environment only (
primaryServerEnvironmentThemesAtom,primaryServerSettingsAtom), matching the environment-scoping rule; no shared renderer gains an active-environment fallback. - No new raw controls, class-string overrides of primitive contracts, global CSS, or theme-variant changes were introduced.
getServerSnapshotreturning a fresh[]inuseEnvironmentThemeDefinitionsdiffers fromuseCustomThemes' cached constant, but the web app is client-rendered (createRoot, no hydration), so it is not reachable; not reported.
🔍 Agent activity — 21 tool calls
💭 I'm checking that the previously flagged issues were resolved: ThemeSettings.tsx now uses singleAppearanceOf and assignHalf, useEnvironmentTheme conditions the managed flag, and useDefaultTheme applies setAppearanceMode for single-appearance cases — all looking addressed in the current diff. Now I want to look at the actual head files to check for any new issues within apps/web/src/**.
1. browse_code — apps/web/src/hooks/useEnvironmentTheme.ts
2. browse_code — apps/web/src/hooks/useDefaultTheme.ts
💭 Now I'm checking the new block at the head of ThemeSettings.tsx.
3. git_tools — environmentThemes
💭 I want to compare the custom theme card's onUse behavior against the environment card's, and check whether the missing download option on environment cards is an intentional inconsistency. I should also look at how custom theme cards handle onUse, plus the assignHalf, pickedModesFor, and handlePairPick logic for comparison.
4. browse_code — apps/web/src/components/settings/ThemeSettings.tsx
5. browse_code — apps/web/src/components/settings/ThemeSettings.tsx
💭 The duplication of getThemeModes inline versus the new singleAppearanceOf helper looks pre-existing and cosmetic, not a real finding, and the missing Download option on the environment card is likely intentional rather than an inconsistency. I want to check whether ThemeLibraryCard actually requires onEdit/onRemove props before flagging their absence. I'm now looking at how useEnvironmentThemeDefinitions wires into useSyncExternalStore — the server snapshot returns a fresh empty array on every call, which could trip React's requirement that getServerSnapshot be cached, but this only matters if the app does SSR. Since this is a TanStack Router SPA, I need to verify whether SSR is actually in play here before treating this as a real bug.
6. git_tools — useSyncExternalStore
💭 I notice
useCustomThemesuses a module-level constant for its server snapshot to avoid creating a new array each call, butuseEnvironmentThemeDefinitionsuses an inline arrow function that creates a fresh array every time. I'm wondering if that's actually a problem—getServerSnapshot only matters during hydration, so I need to check whether the web app actually does SSR/hydration by looking for hydrateRoot usage.
7. git_tools — hydrateRoot|createRoot
💭 That's client-side only, so not a real issue. Moving on, I want to trace how useTheme's refreshTheme interacts with setEnvironmentThemes, and check how ThemeSettings and themePalette use singleAppearanceOf and getThemeModes. I also want to verify what useDefaultTheme does when setting the base theme preference. I recall a prior comment flagged that setting the base preference could flip a light client to dark unexpectedly, and the author's fix was to add setAppearanceMode(half), which is documented as intentional behavior. I still want to confirm whether setTheme clears theme halves for users with mixed light/dark setups — that seems to be an accepted, documented tradeoff rather than a bug. Beyond that, useDefaultThemeAdoption's dependency on module-level theme state via useSyncExternalStore looks fine, and I don't see a UI consistency problem even if a published environment theme later stops being published. Now I want to check themePalette.ts for correctness around reserved theme identifiers. Now I'm tracing the ID moved to the shared set — checking that BUILT_IN_THEME_IDS covers T3_CHAT_THEME_ID, GROVE, OCEAN, EMBER, and IRIS, and that the legacy dark theme ID alias matches correctly between the shared and web-side constant files.
8. browse_code — packages/shared/src/themePalettes.ts
9. git_tools — LEGACY_T3_CHAT_DARK_THEME_ID|RESERVED_THEME_IDS|isReservedThemeId
💭 The RESERVED_THEME_IDS change checks out. I'm also considering the inline boot script in index.html that resolves the stored theme before paint to avoid flashing — since environment themes are runtime-only and not persisted, a client whose stored preference points to a published theme id won't find it in custom themes at boot, so it falls back to default and repaints once env themes load. That flash seems like an accepted tradeoff of the design rather than a bug, so I want to check whether the boot script handles that case explicitly. I'm also tracing whether
setEnvironmentThemescorrectly notifies listeners souseTheme'srefreshThemereapplies the palette, and whether therefreshThemecallback identity is stable enough to avoid effect loops inuseEnvironmentThemeSync— the lastPublished ref guard seems to prevent redundant work either way. Then I'm verifying thatprimaryServerEnvironmentThemesAtomcorrectly scopes to the primary environment, consistent with how other environment-scoped atoms are routed. Now I'm checking howThemeLibraryCardpassesseedThemeIdthroughonDuplicatefor environment themes so the editor panel resolves them viagetThemeDefinition, and confirming that omittingonDownload,onEdit, oronRemovefor environment theme cards degrades gracefully rather than breaking rendering.
10. git_tools — function ThemeLibraryCard
💭 The environment cards look consistent with how others render, using the same isActive and activeModes patterns. I'm noticing a potential issue though: the environment themes list in ThemeSettings isn't memoized, which could cause unnecessary recalculations. Comparing singleAppearanceOf against getThemeModes in the custom collection's onUse handler, they behave consistently. The appearance-mode flip in useDefaultTheme was already flagged as an intentional design decision by the author, so I'll leave that alone. I'm shifting focus to the other files in scope — apps/web/src/state/server.ts, __root.tsx, and themePalette.ts — checking where EnvironmentThemeSync sits within the root route to see if it's properly wrapped by the atom runtime provider.
11. browse_code — apps/web/src/routes/__root.tsx
💭 I'm looking at the themePalette.ts refactor where parseStoredThemeColors now spreads default theme colors with lenient overrides instead of mutating defaults directly — functionally equivalent. I want to check how the normalization loop handles color roles, so I'm pulling up lines 125-160 to see if it iterates THEME_COLOR_ROLES or uses Object.entries.
12. browse_code — apps/web/src/themePalette.ts
💭 I'm double-checking how Effect's
Equal.equalsbehaves on plain arrays and objects — it likely falls back to reference equality unless they implement the Equal interface, rather than doing structural comparison automatically. Since this repo uses newer Effect v4 modules, I want to verify whether structural equality for plain objects/arrays was added in that version, and check existing usages ofEqual.equalsin the codebase to see what behavior it currently assumes. But tracing the atom logic further, theenvironmentThemesreference actually stays stable across reconnects since the projection carries over the same array reference, so reference equality would work fine there. The real question is whethersetEnvironmentThemesalways builds a fresh array, which would makeEqual.equalscomparisons meaningless if it's reference-only. That would triggerrefreshTheme()on every published change, but only after the guard passes, so the actual impact is likely limited. I should check how Effect'sEqual.equalsbehaves on plain arrays elsewhere in the codebase to confirm this.
13. git_tools — Equal.equals
💭 Structural equality via Equal.equals seems fine for plain records and arrays, so no issue there. I'm now thinking through a potential flash-of-wrong-theme issue: at boot, index.html's script checks localStorage for custom themes, but if the active theme is one published by the environment rather than stored locally, the script won't find it and will paint the default theme before the environment theme loads in. I'm also checking other cases—if the environment stops publishing a theme the user had selected, getThemeDefinition returns null and falls back gracefully, which seems documented. The useDefaultThemeAdoption hook writes per-environment localStorage entries correctly too. Now I want to verify the ordering in setEnvironmentThemes and getThemeDefinition—specifically whether built-in themes take precedence over environment-published ones, since reserved ids should be filtered out. I'm going to look at that section of code directly to confirm.
14. browse_code — apps/web/src/themePalette.ts
💭 I'm also considering theme halves - if an environment theme gets assigned to a half but the machine stops publishing, resolution would fall back gracefully, similar to how custom theme removal is handled. And I'm checking whether useTheme's persistence validates the theme id against known themes before setting it. If setTheme rejects ids that aren't in the "known" set and environment theme ids aren't included there, selecting a published environment theme card could silently fail with a toast error - a real bug worth confirming. I need to look at useTheme.ts to check isKnownThemePreference and how setThemeHalf handles validation.
15. browse_code — apps/web/src/hooks/useTheme.ts
💭 I need to check how
readThemePreferencevalidates stored values usingisKnownThemePreference— if it only checks built-in and custom themes, a saved environment theme id could get wrongly rejected on read, especially before environment themes load.
16. git_tools — export function isKnownThemePreference
💭 I'm realizing that since getThemeDefinition now includes environment themes, isKnownThemePreference could incorrectly flag a stored preference as unknown before those themes load, causing readThemePreference to fall back to "system". I need to check whether that fallback gets written back to storage, which could permanently wipe out a user's saved environment theme preference on reload—so I'm digging into getStored() and the load logic around lines 197-320 of useTheme.ts. I also suspect parseThemeHalves might drop and re-persist pruned halves referencing unknown themes, which would silently erase a user's environment-theme half assignment on every reload—a real regression since assigning env themes to halves is new. Let me trace through that logic.
17. browse_code — apps/web/src/hooks/useTheme.ts
💭 I see that getStored() falls back to "system" without writing when the stored value is unrecognized, so there's no data loss there — but the in-memory snapshot stays "system" until environment themes arrive, at which point useEnvironmentThemeSync triggers refreshTheme(). I want to check how refreshTheme and setTheme actually behave when that happens. Now I realize once the environment registers themes, getStored() re-reads localStorage and since the raw "nightfall" value was never overwritten, isKnownThemePreference now resolves true and the theme retints correctly — that's the intended "wait for the palette" behavior. I still need to check whether anything writes the fallback back to storage, and whether parseThemeHalves drops unrecognized halves at read time or persists the pruned result, so I'll look at parseThemeHalves and setThemeHalf next.
18. browse_code — apps/web/src/themePalette.ts
19. browse_code — apps/web/src/hooks/useTheme.ts
💭 I found a real data-loss path:
setThemeHalfreads throughparseThemeHalves, which silently drops any half whose theme id can't currently be resolved, then persists that pruned result back to storage. So if a user has assigned an environment theme like "nightfall" to a slot, and the page reloads before environment themes are available, that assignment could get wiped out permanently on write. Environment themes are the first async-resolvable theme source, so this pruning-on-write is a genuinely new hazard: assigning a half to an environment theme persists an id that only resolves while connected, and any later write while disconnected silently drops it. This does seem attributable to the changed lines—the new environment card'sonUsepath introduces transient resolvability that didn't exist before, since prior theme ids all resolved deterministically from localStorage or built-ins. The appearance mode preference path looks safe since it reads with a fallback but doesn't overwrite storage improperly, and the disconnected-fallback behavior for stored themes appears to be documented/expected. I think it's worth flagging theassignHalf(half, environmentTheme.id)call as a defensible regression comment, since persisting a published theme id into the halves storage creates silent data loss risk when the mode disconnects. This is a cross-module fix, and the rule says if I can't prove ownership or semantic equivalence, I should leave it unchanged rather than guess. My previous two runs already flagged and fixed two real issues, so let me check whether there's something else clearly actionable here — like the base-preference read path, which is a more common persistence risk than the halves case. I also notice a related flash issue: applyTheme runs on module load with the stored value, defaulting to "system" if the theme hasn't been published yet, and refreshTheme only fires later once environment themes actually register. Now I'm tracing what refreshTheme does when environment themes arrive — likely invalidating custom themes, reapplying the stored theme, and emitting a change. But there's a subtler bug here: the lastAppliedTheme early-return guard could suppress a needed repaint. If the environment rewrites the colors under the same theme id (the headline feature — a machine editing nightfall.json's palette without changing its id), setEnvironmentThemes would still detect a structural diff and call refreshTheme, but if refreshTheme just re-applies the same theme/mode with unchanged halves, the guard could see it as identical and skip repainting entirely — unless refreshTheme explicitly resets lastAppliedTheme to null first. I need to check refreshTheme's implementation to confirm whether it does that reset.
20. browse_code — apps/web/src/hooks/useTheme.ts
💭 Confirming refreshTheme resets lastAppliedTheme so retinting works fine. That leaves the halves-pruning persistence issue to evaluate—I want to check if it's actually reachable in practice and whether the same risk existed before this PR, like whether a half could reference a theme that's temporarily unresolvable if localStorage read fails. The environment themes make this transient unresolvability common, since every page load has a window before the WS config arrives, or whenever offline. The concrete issue:
setThemeHalfrewrites the mix usingparseThemeHalves, which drops unresolvable ids, so a half change made before the published theme set arrives silently erases the environment half from the mix. I'm leaning toward suggesting the write path preserve unknown halves rather than dropping them outright. The real fix would live in useTheme.ts's setThemeHalf, reading the raw stored JSON for the merge instead of the parsed/pruned halves — that file is technically in scope since it matches the included path patterns, even though the PR doesn't touch it. Since the patch introduces a theme source whose resolution is transient, it makes this existing prune-on-write behavior lossy in a way that's now directly relevant, so I'd flag it as a comment on the in-diff line in ThemeSettings.tsx that introduces environment ids into the halves, rather than failing the check outright. Tracing the path once more:assignHalfcallssetThemeHalf, which callsreadStoredThemeHalves(), which parses and prunes any halves that don't resolve to a known theme definition — confirming this is the only path and the pruning does happen as I described. I've also confirmed that at the moment the user clicks the environment card, the environment theme already exists since the card is rendered from the published set, so that initial write is safe — the data loss only happens on a later write while the theme is still unresolved. Checking the other potential issues: the appearance-mode flip is already handled, the single-appearance published theme card renders consistently with custom themes, the missing "Download" option for published themes is fine per docs, there's no shared primitive color indirection concern, and no CSS changes exist in the diff. So I'm left with just the one finding, and given the severity, I think posting it as a review comment rather than failing the check outright is the right call. Weighing confidence once more, I want to check whether pruned halves ever get re-persisted elsewhere at load time. I'm tracing all callers of setThemeHalf — handleRemoveTheme, assignHalf, handlePairPick, and the theme editor — and all appear to be user-driven actions, which narrows the failure scenario considerably: it only triggers if a user has an environment-theme half assigned, opens settings while the published set is temporarily unavailable, and then changes the other half. I'm also checking whether the theme editor host or any install/boot path calls setThemeHalf or clearThemeHalves automatically. Given how narrow this is, I'm reconsidering whether it's worth flagging at all since false positives carry real cost — but it's still a genuine silent data-loss path enabled by the changed line, so I'll surface it as a suggestion. Since the fix lives in useTheme.ts but review comments need to land on diff lines, I'll place the inline comment on ThemeSettings.tsx instead, at the relevant line in the new file version of the diff. Let me double-check the underlying logic once more, tracing how theme halves get pruned based on definitions, and confirming that env-based themes without explicit halves still resolve correctly through mode and card selection. Given this is a plausible but edge-case regression, I'm deciding whether to flag it as a hard failure or just a comment — leaning toward posting a single clear inline finding, though I'm weighing whether failure severity is really warranted here. The check's stated defaults point toward failure when there's any finding, so I'll respect that posture rather than downgrade it. I'm drafting the comment now, explaining that assigning a published theme to a half persists an id that only resolves while the machine is publishing it, and thatsetThemeHalfmerging over stored theme halves combined with howparseThemeHalvesdrops entries creates the inconsistency — since the actual fix lives in another file, I'll describe the needed change rather than provide a diff. I'm identifying the smallest fix: havingsetThemeHalfmerge over the raw stored JSON instead of the pruned parse, so unresolvable ids survive writes to the other half — then posting this at line 840.
21. modify_pr — One finding: assigning a published environment theme to a light/dark half persists an id whose resolvability is transient, and the existing half-write path prunes unresolvable ids before writing.
Agent Credits: 274 credits
Token usage: 36 uncached input · 2,228,320 cache-read input · 175,876 cache-write input · 15,786 output
Agent Credits may also include non-token charges from external tools such as web research.