refactor(mobile): compile semantic themes for Uniwind - #138

Merged
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes
Aug 29, 2026
Merged

refactor(mobile): compile semantic themes for Uniwind#138
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes

Conversation

@rynfar

@rynfarrynfar commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and all 67 of its call
sites.

Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327). 110 files.

Why now rather than deferred

This is a foundation change, not a cosmetic one, and deferring it was the
strictly worst option. Upstream is entirely on the far side of it — 67 call
sites before, 0 after — so every later mobile commit Pylon adopts would need
hand-translating back into useThemeColor, a tax paid repeatedly with the
benefit never arriving. It was already happening inside this same batch: #7327
rewrites GitActionProgressOverlay.tsx, the exact file upstream #8399 touches.

Adaptation — six conflicts

T3Wordmark stays deleted. Pylon removed it in 2929153bd feat: establish Pylon fork and renders PylonMark instead. Adopting upstream's converted
version would have restored a T3 brand component, which AGENTS.md forbids.
PylonMark instead gains the same withUniwind / colorClassName treatment
upstream applied to its own wordmark, so Pylon's mark follows the new idiom
without inheriting T3 artwork. Its mask paths keep literal fills, since only the
visible silhouette is themed. Verified T3Wordmark has no remaining references
anywhere in apps/mobile/src.

CompactBrandTitle / HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon". Confirmed
those resolve the same variable — the accent- prefix is Uniwind's convention,
not an accent colour, so accent-icon--color-icon.

ThreadComposer had two import conflicts, resolved by what the merged file
actually references rather than by picking a side: dropped StyleSheet and
themeColorWithAlpha as genuinely unused, kept Pylon's Alert,
KeyboardAvoidingView, and Modal.

GitActionProgressOverlay takes upstream's version wholesale, which is
correct here: it already carries #8399's glass restyle, and Pylon's only
divergence was the error-phase palette, which upstream expresses identically in
the new adaptive classes. The default merge had produced a hybrid whose
shadowColor was computed and never used — caught by lint, not by tests.

The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts.
All 65 tokens were compared before resolving: value-identical, so no Pylon
palette customisation is lost.

Second commit: the part upstream could not do

The adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
at error severity. Upstream converted its own files; three Pylon files it has
never seen still violated it 24 times, so this could not land green.
SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx are
Pylon-only, and the single ThreadFeed.tsx violation sits on a Pylon-added line
(upstream's copy of that file has no dark:/light: utilities at all).

28 light/dark pairs merged into adaptive tokens; 7 new tokens registered in the
generator. This is colour-preserving rather than lint-silencing — each token is
built from the same Tailwind family, shade, and opacity as the pair it replaced,
and the emitted values were checked numerically against tailwindcss/colors
(e.g. adaptive-rose-800-200oklch(45.5% 0.188 13.697) light,
oklch(89.2% 0.058 10.001) dark, exactly rose-800 and rose-200). Pairs merge
only where both halves are on one line, so a light-only utility is never
rewritten into an appearance-sensitive token.

Verification

  • vp test run apps/mobile841 passed
  • vp run -F @t3tools/mobile typecheck — clean
  • vp lint apps/mobile/src — clean, including the new rule (24 → 0)
  • vp test run oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts — 25 passed
  • vp run icons:check — "Pylon brand assets are current (40 files)", so no brand
    asset regression from the mark change
  • Generated files are reproducible: hashing generated-uniwind-themes.css,
    -theme-names.json, and -default-theme-variables.json across a regeneration
    gives an identical digest

Not verified

This has had no run on a simulator or device. It is a ~100-file theming change,
so it wants a test-pylon-mobile pass before merge — tests and lint cannot see
a colour that resolves to the wrong thing at runtime.

Claude Opus 5 via Claude Code.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

juliusmarmingeand others added 2 commits August 28, 2026 14:39
Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and its 67 call sites.
Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327).
Taken now rather than deferred because it is a foundation change, not a
cosmetic one. Upstream is entirely on the far side of it, so every later mobile
commit Pylon adopts would otherwise need hand-translating back into
useThemeColor - a tax paid repeatedly with the benefit never arriving.
Adaptation, six conflicts:
T3Wordmark stays deleted. Pylon removed it when the fork was established and
renders PylonMark instead; adopting upstream's converted version would have
restored a T3 brand component. PylonMark gains the same withUniwind
colorClassName treatment upstream applied to its own wordmark, so Pylon's mark
follows the new idiom without inheriting T3 artwork. Its mask geometry keeps
literal fills, since only the visible silhouette is themed.
CompactBrandTitle and HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon", which resolves
the same variable.
ThreadComposer's two import conflicts were resolved by what the merged file
actually references rather than by picking a side, dropping StyleSheet and
themeColorWithAlpha as genuinely unused and keeping Pylon's Alert,
KeyboardAvoidingView and Modal.
GitActionProgressOverlay takes upstream's version wholesale. It already carries
upstream #8399's glass restyle, and Pylon's only divergence there was the
error-phase palette, which upstream expresses identically in the new adaptive
classes. The default merge left a hybrid whose shadowColor was computed and
never used.
The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts. All 65
tokens were compared first and are value-identical, so no Pylon palette
customisation is lost.
The Uniwind adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
which rejects dark:/light: utilities because they do not follow registered custom
themes. Upstream converted its own files; three Pylon files it has never seen
still violated it, at error severity, so the adoption could not land green:
- SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx exist
only in Pylon;
- the one violation in ThreadFeed.tsx is on a Pylon-added line, confirmed by
upstream's copy of that file containing no dark:/light: utilities at all.
Merges 28 light/dark utility pairs into adaptive tokens across those three
files. Twelve of the tokens needed already existed; seven did not and are
registered in the generator's ADAPTIVE_COLORS map:
amber-100-500-a18, amber-900-200, neutral-100-a95-900-a95, neutral-800-200,
rose-800-200, sky-100-500-a18, and sky-800-200.
The conversion is colour-preserving, not merely lint-silencing. Each new token
is defined from the same Tailwind family, shade, and opacity as the pair it
replaces, and the emitted values were checked against tailwindcss/colors: for
example adaptive-rose-800-200 resolves to oklch(45.5% 0.188 13.697) in light and
oklch(89.2% 0.058 10.001) in dark, which are rose-800 and rose-200 exactly.
Pairs are merged only where both halves appear on the same line, so a light-only
utility is never rewritten into an appearance-sensitive token.
@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 28, 2026
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.2 KiB−117 B (−0.9%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+4 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.3 KiB−121 B (−1.9%)7.8 KiB
CodexLive turn WebSocket decoded55.5 KiB54.7 KiB−822 B (−1.4%)66.4 KiB
CodexLive turn messages990 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB−5 B (−0.0%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−5 B (−0.1%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB0 B (0.0%)7.8 KiB
ClaudeLive turn WebSocket decoded56.3 KiB56.3 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages990 (0.0%)21

Baseline: 3ed6520 · PR result: e3b4bd2 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…alette checks
Review follow-ups on the Uniwind adoption. All three are upstream regressions the
refactor introduced, not Pylon adaptations.
The thread composer lost its shadow colour. Upstream replaced an explicit
shadowColor plus 0.12/0.35 opacity with shadowOpacity: 1 and a bare
shadow-adaptive-black-a15-a35 class. Compiling that class with the repo's own
tailwindcss 4.3 shows it emits only --tw-shadow-color and no box-shadow, and
Uniwind's native store only maps a style when result.boxShadow is defined, so the
class reached React Native as nothing at all. With no shadowColor and full
opacity, iOS falls back to opaque black. The colour is set explicitly again, the
way GitActionProgressOverlay does it in this same refactor. Of the two
shadow-adaptive usages, the other one is paired with shadow-lg and is fine.
PendingUserInputCard drifted a shade pair: text-neutral-700 dark:text-neutral-200
became adaptive-neutral-600-300, lighter in light mode and darker in dark mode,
so contrast dropped in both. Every other conversion in that file is 1:1.
Registers adaptive-neutral-700-200 and uses it.
The readability test stopped covering the default palette when its loop narrowed
to BUILT_IN_THEME_IDS. Restoring MOBILE_THEME_IDS does not fix it: t3-code is
absent from BUILT_IN_THEMES, so getMobileThemeVariables falls back to
BUILT_IN_THEMES[0] and would check t3-chat twice, and it does not typecheck. The
default palette is asserted through readDefaultMobileThemeVariables, which reads
global.css. Verified by making the default placeholder unreadable: the check now
fails at a 1.02 contrast ratio, where before it passed.
@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Independent review pass found seven issues, all upstream regressions rather than
Pylon adaptations. Three fixed in a23ada2e5, four still open.

Fixed — the thread composer rendered an opaque black shadow.

Upstream replaced an explicit shadowColor plus 0.12/0.35 opacity with
shadowOpacity: 1 and a bare shadow-adaptive-black-a15-a35 class. I compiled
that class with the repo's own tailwindcss 4.3:

.shadow-adaptive-black-a15-a35 { --tw-shadow-color: … } ← no box-shadow
.shadow-lg { …; box-shadow: … } ← real box-shadow

Uniwind's native store only maps a style when result.boxShadow is defined
(core/native/store.js:126), so the class reached React Native as nothing.
With no shadowColor and full opacity, iOS falls back to opaque black on every
thread composer and the new-task draft screen. Colour is set explicitly again,
matching how GitActionProgressOverlay does it in this same refactor. The other
shadow-adaptive-* usage is paired with shadow-lg and is fine.

Fixed — a shade pair drifted.text-neutral-700 dark:text-neutral-200 became
adaptive-neutral-600-300: lighter in light, darker in dark, so contrast dropped
both ways. Every other conversion in that file is 1:1. Registered
adaptive-neutral-700-200.

Fixed — the default palette lost readability coverage. Note that the obvious
fix does not work: restoring MOBILE_THEME_IDS fails to typecheck and would
silently check t3-chat twice, because t3-code is absent from
BUILT_IN_THEMES and getMobileThemeVariables falls back to
BUILT_IN_THEMES[0]. I tried that first and my probe caught it. Now asserted
through readDefaultMobileThemeVariables, which reads global.css — verified by
making the default placeholder unreadable and watching the check fail at a 1.02
contrast ratio.

Still open — flagged, not fixed, because they want the device pass this PR
already needs:

  • ComposerToolbar.tsxshadow-lg now supplies a real boxShadow, so the
    surviving shadowOpacity: props.disabled ? 0 : 1 may no longer remove the
    shadow on disabled toolbar buttons.
  • App.tsx / LoadingScreen.tsxStatusBar backgroundColor removed while
    translucent stays true, so the Android status-bar scrim is gone and
    --color-status-bar has no consumer.
  • ComposerToolbar.tsx — toolbar fades interpolate to transparent rather than
    the surface colour at alpha 0, which reintroduces the grey-smudge artifact the
    removed themeColorWithAlpha(surface, 0) existed to avoid.
  • SourceControlIcon.tsx — the GitHub glyph lost its #24292F default fill.
    Latent: the only caller passes colorClassName.

@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Mobile pass — partial

Ran this branch's bundle on a booted iPhone 17 Pro (iOS 26.3) against a
disposable backend seeded from real data. No native rebuild: the only
native-adjacent change is a Metro config line, so an installed PylonDev.app
plus expo start --clear was the right launch path.

What the refactor gets right, confirmed on device. The compiled Uniwind
themes render correctly across the Home list and the glass sheet — text
hierarchy, muted secondary labels, separators, the blue "Working" accent, the
settled section, and the sheet's own surface and shadow all resolve.

Home screen on the Uniwind branch

Choose project glass sheet

The environment connected rather than rendering an empty disconnected state —
the third settled row picks up the Pylon environment icon after pairing.

What I could not verify, and why. The three things most worth a device pass
are still unverified: the composer shadow fix in a23ada2e5, the amber warning
row, and the three converted Pylon-only screens. All of them live behind a thread
or a project row, and neither is exposed to accessibility. A runtime snapshot
lists only chrome — compose, filter, Open settings, search, the settled
toggle — and wait_for_ui for any cell times out with none present. The
skill's guidance is to report that as an accessibility blocker rather than guess
coordinates, so I stopped there.

Worth noting on its own: thread and project rows having no accessible role is a
VoiceOver problem independent of this PR, and I did not confirm whether it
predates the refactor.

So the four visual findings from the review — disabled-button shadow, Android
status-bar scrim, toolbar fade smudge, GitHub glyph fill — remain open, and the
composer fix remains verified only by the Tailwind/Uniwind mechanism analysis and
not by eye.

…tatus bar scrim
Two more review follow-ups on the Uniwind adoption.
Disabled toolbar buttons kept their shadow. The refactor moved the shadow to a
shadow-lg class, which Uniwind maps to React Native's boxShadow, and boxShadow
wins over the legacy shadowOpacity the style still set to 0 when disabled. The
class is now dropped for disabled buttons and the dead shadowOpacity line is
gone.
The Android status bar lost its scrim. backgroundColor was removed while
translucent stayed true, so content draws under the bar with nothing behind it,
and --color-status-bar was left with no consumer at all. Restores the same
variable origin/pylon read, now through useUniwindTheme. App.tsx joins the lint
rule's interop allowlist because StatusBar's Android backgroundColor is a native
prop that cannot take a className.
Two findings from the same review are deliberately not fixed. The toolbar fade
ramps to transparent black rather than the surface at alpha 0; the obvious
class-level fix compiles to color-mix(..., 0%, transparent), which is not clearly
the surface hue either, and I cannot tell the two apart without a device. The
GitHub glyph lost its #24292F default, which is latent because the only caller
passes colorClassName, and hardcoding a brand hex back would fight the direction
of the refactor. Both are recorded on the pull request.
Pylon's 92ec009 landed after this branch and pursues the same goal by another
route: it replaces the status indicators' dark:/light: pairs with semantic
warning tokens and deletes the raw iconColor/iconBackground fields entirely.
That is the later Pylon decision, so threadPresentation and both thread-list
files take Pylon's version wholesale rather than this branch's adaptive-token
conversion of the same lines.
Integrating them needed four things. Pylon's files still imported useThemeColor,
which this branch deletes, so 25 call sites across the two thread-list files move
to useUniwindTheme. Pylon's nine remaining dark:/light: pairs are converted to
adaptive tokens the generator already defines, because the new lint rule rejects
them at error severity. The generated theme files are regenerated to pick up
Pylon's five new variables, and the theme test's hardcoded variable count moves
from 65 to 70 to match.
mobileTheme.test.ts keeps this branch's structure rather than Pylon's: Pylon's
version calls getMobileThemeVariables with DEFAULT_MOBILE_THEME_ID, which does
not typecheck here and silently resolves to BUILT_IN_THEMES[0] anyway.
#131 landed on pylon with the mobile warning colour split this branch had already
converted to adaptive tokens. Keeps both: the destructive/warning split stays,
expressed as adaptive-rose-600-400 and adaptive-amber-600-400 so the new lint
rule accepts it. Registers adaptive-amber-600-400, which did not exist yet.
@rynfar
rynfar merged commit eafef85 into pylonAug 29, 2026
15 checks passed
@rynfar
rynfar deleted the upstream/2026-08-28-mobile-uniwind-themes branch August 29, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXLvouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rynfar@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

refactor(mobile): compile semantic themes for Uniwind - #138

Merged
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes
Aug 29, 2026
Merged

refactor(mobile): compile semantic themes for Uniwind#138
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes

Conversation

@rynfar

@rynfarrynfar commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and all 67 of its call
sites.

Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327). 110 files.

Why now rather than deferred

This is a foundation change, not a cosmetic one, and deferring it was the
strictly worst option. Upstream is entirely on the far side of it — 67 call
sites before, 0 after — so every later mobile commit Pylon adopts would need
hand-translating back into useThemeColor, a tax paid repeatedly with the
benefit never arriving. It was already happening inside this same batch: #7327
rewrites GitActionProgressOverlay.tsx, the exact file upstream #8399 touches.

Adaptation — six conflicts

T3Wordmark stays deleted. Pylon removed it in 2929153bd feat: establish Pylon fork and renders PylonMark instead. Adopting upstream's converted
version would have restored a T3 brand component, which AGENTS.md forbids.
PylonMark instead gains the same withUniwind / colorClassName treatment
upstream applied to its own wordmark, so Pylon's mark follows the new idiom
without inheriting T3 artwork. Its mask paths keep literal fills, since only the
visible silhouette is themed. Verified T3Wordmark has no remaining references
anywhere in apps/mobile/src.

CompactBrandTitle / HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon". Confirmed
those resolve the same variable — the accent- prefix is Uniwind's convention,
not an accent colour, so accent-icon--color-icon.

ThreadComposer had two import conflicts, resolved by what the merged file
actually references rather than by picking a side: dropped StyleSheet and
themeColorWithAlpha as genuinely unused, kept Pylon's Alert,
KeyboardAvoidingView, and Modal.

GitActionProgressOverlay takes upstream's version wholesale, which is
correct here: it already carries #8399's glass restyle, and Pylon's only
divergence was the error-phase palette, which upstream expresses identically in
the new adaptive classes. The default merge had produced a hybrid whose
shadowColor was computed and never used — caught by lint, not by tests.

The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts.
All 65 tokens were compared before resolving: value-identical, so no Pylon
palette customisation is lost.

Second commit: the part upstream could not do

The adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
at error severity. Upstream converted its own files; three Pylon files it has
never seen still violated it 24 times, so this could not land green.
SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx are
Pylon-only, and the single ThreadFeed.tsx violation sits on a Pylon-added line
(upstream's copy of that file has no dark:/light: utilities at all).

28 light/dark pairs merged into adaptive tokens; 7 new tokens registered in the
generator. This is colour-preserving rather than lint-silencing — each token is
built from the same Tailwind family, shade, and opacity as the pair it replaced,
and the emitted values were checked numerically against tailwindcss/colors
(e.g. adaptive-rose-800-200oklch(45.5% 0.188 13.697) light,
oklch(89.2% 0.058 10.001) dark, exactly rose-800 and rose-200). Pairs merge
only where both halves are on one line, so a light-only utility is never
rewritten into an appearance-sensitive token.

Verification

  • vp test run apps/mobile841 passed
  • vp run -F @t3tools/mobile typecheck — clean
  • vp lint apps/mobile/src — clean, including the new rule (24 → 0)
  • vp test run oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts — 25 passed
  • vp run icons:check — "Pylon brand assets are current (40 files)", so no brand
    asset regression from the mark change
  • Generated files are reproducible: hashing generated-uniwind-themes.css,
    -theme-names.json, and -default-theme-variables.json across a regeneration
    gives an identical digest

Not verified

This has had no run on a simulator or device. It is a ~100-file theming change,
so it wants a test-pylon-mobile pass before merge — tests and lint cannot see
a colour that resolves to the wrong thing at runtime.

Claude Opus 5 via Claude Code.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

juliusmarmingeand others added 2 commits August 28, 2026 14:39
Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and its 67 call sites.
Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327).
Taken now rather than deferred because it is a foundation change, not a
cosmetic one. Upstream is entirely on the far side of it, so every later mobile
commit Pylon adopts would otherwise need hand-translating back into
useThemeColor - a tax paid repeatedly with the benefit never arriving.
Adaptation, six conflicts:
T3Wordmark stays deleted. Pylon removed it when the fork was established and
renders PylonMark instead; adopting upstream's converted version would have
restored a T3 brand component. PylonMark gains the same withUniwind
colorClassName treatment upstream applied to its own wordmark, so Pylon's mark
follows the new idiom without inheriting T3 artwork. Its mask geometry keeps
literal fills, since only the visible silhouette is themed.
CompactBrandTitle and HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon", which resolves
the same variable.
ThreadComposer's two import conflicts were resolved by what the merged file
actually references rather than by picking a side, dropping StyleSheet and
themeColorWithAlpha as genuinely unused and keeping Pylon's Alert,
KeyboardAvoidingView and Modal.
GitActionProgressOverlay takes upstream's version wholesale. It already carries
upstream #8399's glass restyle, and Pylon's only divergence there was the
error-phase palette, which upstream expresses identically in the new adaptive
classes. The default merge left a hybrid whose shadowColor was computed and
never used.
The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts. All 65
tokens were compared first and are value-identical, so no Pylon palette
customisation is lost.
The Uniwind adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
which rejects dark:/light: utilities because they do not follow registered custom
themes. Upstream converted its own files; three Pylon files it has never seen
still violated it, at error severity, so the adoption could not land green:
- SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx exist
only in Pylon;
- the one violation in ThreadFeed.tsx is on a Pylon-added line, confirmed by
upstream's copy of that file containing no dark:/light: utilities at all.
Merges 28 light/dark utility pairs into adaptive tokens across those three
files. Twelve of the tokens needed already existed; seven did not and are
registered in the generator's ADAPTIVE_COLORS map:
amber-100-500-a18, amber-900-200, neutral-100-a95-900-a95, neutral-800-200,
rose-800-200, sky-100-500-a18, and sky-800-200.
The conversion is colour-preserving, not merely lint-silencing. Each new token
is defined from the same Tailwind family, shade, and opacity as the pair it
replaces, and the emitted values were checked against tailwindcss/colors: for
example adaptive-rose-800-200 resolves to oklch(45.5% 0.188 13.697) in light and
oklch(89.2% 0.058 10.001) in dark, which are rose-800 and rose-200 exactly.
Pairs are merged only where both halves appear on the same line, so a light-only
utility is never rewritten into an appearance-sensitive token.
@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 28, 2026
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.2 KiB−117 B (−0.9%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+4 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.3 KiB−121 B (−1.9%)7.8 KiB
CodexLive turn WebSocket decoded55.5 KiB54.7 KiB−822 B (−1.4%)66.4 KiB
CodexLive turn messages990 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB−5 B (−0.0%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−5 B (−0.1%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB0 B (0.0%)7.8 KiB
ClaudeLive turn WebSocket decoded56.3 KiB56.3 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages990 (0.0%)21

Baseline: 3ed6520 · PR result: e3b4bd2 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…alette checks
Review follow-ups on the Uniwind adoption. All three are upstream regressions the
refactor introduced, not Pylon adaptations.
The thread composer lost its shadow colour. Upstream replaced an explicit
shadowColor plus 0.12/0.35 opacity with shadowOpacity: 1 and a bare
shadow-adaptive-black-a15-a35 class. Compiling that class with the repo's own
tailwindcss 4.3 shows it emits only --tw-shadow-color and no box-shadow, and
Uniwind's native store only maps a style when result.boxShadow is defined, so the
class reached React Native as nothing at all. With no shadowColor and full
opacity, iOS falls back to opaque black. The colour is set explicitly again, the
way GitActionProgressOverlay does it in this same refactor. Of the two
shadow-adaptive usages, the other one is paired with shadow-lg and is fine.
PendingUserInputCard drifted a shade pair: text-neutral-700 dark:text-neutral-200
became adaptive-neutral-600-300, lighter in light mode and darker in dark mode,
so contrast dropped in both. Every other conversion in that file is 1:1.
Registers adaptive-neutral-700-200 and uses it.
The readability test stopped covering the default palette when its loop narrowed
to BUILT_IN_THEME_IDS. Restoring MOBILE_THEME_IDS does not fix it: t3-code is
absent from BUILT_IN_THEMES, so getMobileThemeVariables falls back to
BUILT_IN_THEMES[0] and would check t3-chat twice, and it does not typecheck. The
default palette is asserted through readDefaultMobileThemeVariables, which reads
global.css. Verified by making the default placeholder unreadable: the check now
fails at a 1.02 contrast ratio, where before it passed.
@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Independent review pass found seven issues, all upstream regressions rather than
Pylon adaptations. Three fixed in a23ada2e5, four still open.

Fixed — the thread composer rendered an opaque black shadow.

Upstream replaced an explicit shadowColor plus 0.12/0.35 opacity with
shadowOpacity: 1 and a bare shadow-adaptive-black-a15-a35 class. I compiled
that class with the repo's own tailwindcss 4.3:

.shadow-adaptive-black-a15-a35 { --tw-shadow-color: … } ← no box-shadow
.shadow-lg { …; box-shadow: … } ← real box-shadow

Uniwind's native store only maps a style when result.boxShadow is defined
(core/native/store.js:126), so the class reached React Native as nothing.
With no shadowColor and full opacity, iOS falls back to opaque black on every
thread composer and the new-task draft screen. Colour is set explicitly again,
matching how GitActionProgressOverlay does it in this same refactor. The other
shadow-adaptive-* usage is paired with shadow-lg and is fine.

Fixed — a shade pair drifted.text-neutral-700 dark:text-neutral-200 became
adaptive-neutral-600-300: lighter in light, darker in dark, so contrast dropped
both ways. Every other conversion in that file is 1:1. Registered
adaptive-neutral-700-200.

Fixed — the default palette lost readability coverage. Note that the obvious
fix does not work: restoring MOBILE_THEME_IDS fails to typecheck and would
silently check t3-chat twice, because t3-code is absent from
BUILT_IN_THEMES and getMobileThemeVariables falls back to
BUILT_IN_THEMES[0]. I tried that first and my probe caught it. Now asserted
through readDefaultMobileThemeVariables, which reads global.css — verified by
making the default placeholder unreadable and watching the check fail at a 1.02
contrast ratio.

Still open — flagged, not fixed, because they want the device pass this PR
already needs:

  • ComposerToolbar.tsxshadow-lg now supplies a real boxShadow, so the
    surviving shadowOpacity: props.disabled ? 0 : 1 may no longer remove the
    shadow on disabled toolbar buttons.
  • App.tsx / LoadingScreen.tsxStatusBar backgroundColor removed while
    translucent stays true, so the Android status-bar scrim is gone and
    --color-status-bar has no consumer.
  • ComposerToolbar.tsx — toolbar fades interpolate to transparent rather than
    the surface colour at alpha 0, which reintroduces the grey-smudge artifact the
    removed themeColorWithAlpha(surface, 0) existed to avoid.
  • SourceControlIcon.tsx — the GitHub glyph lost its #24292F default fill.
    Latent: the only caller passes colorClassName.

@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Mobile pass — partial

Ran this branch's bundle on a booted iPhone 17 Pro (iOS 26.3) against a
disposable backend seeded from real data. No native rebuild: the only
native-adjacent change is a Metro config line, so an installed PylonDev.app
plus expo start --clear was the right launch path.

What the refactor gets right, confirmed on device. The compiled Uniwind
themes render correctly across the Home list and the glass sheet — text
hierarchy, muted secondary labels, separators, the blue "Working" accent, the
settled section, and the sheet's own surface and shadow all resolve.

Home screen on the Uniwind branch

Choose project glass sheet

The environment connected rather than rendering an empty disconnected state —
the third settled row picks up the Pylon environment icon after pairing.

What I could not verify, and why. The three things most worth a device pass
are still unverified: the composer shadow fix in a23ada2e5, the amber warning
row, and the three converted Pylon-only screens. All of them live behind a thread
or a project row, and neither is exposed to accessibility. A runtime snapshot
lists only chrome — compose, filter, Open settings, search, the settled
toggle — and wait_for_ui for any cell times out with none present. The
skill's guidance is to report that as an accessibility blocker rather than guess
coordinates, so I stopped there.

Worth noting on its own: thread and project rows having no accessible role is a
VoiceOver problem independent of this PR, and I did not confirm whether it
predates the refactor.

So the four visual findings from the review — disabled-button shadow, Android
status-bar scrim, toolbar fade smudge, GitHub glyph fill — remain open, and the
composer fix remains verified only by the Tailwind/Uniwind mechanism analysis and
not by eye.

…tatus bar scrim
Two more review follow-ups on the Uniwind adoption.
Disabled toolbar buttons kept their shadow. The refactor moved the shadow to a
shadow-lg class, which Uniwind maps to React Native's boxShadow, and boxShadow
wins over the legacy shadowOpacity the style still set to 0 when disabled. The
class is now dropped for disabled buttons and the dead shadowOpacity line is
gone.
The Android status bar lost its scrim. backgroundColor was removed while
translucent stayed true, so content draws under the bar with nothing behind it,
and --color-status-bar was left with no consumer at all. Restores the same
variable origin/pylon read, now through useUniwindTheme. App.tsx joins the lint
rule's interop allowlist because StatusBar's Android backgroundColor is a native
prop that cannot take a className.
Two findings from the same review are deliberately not fixed. The toolbar fade
ramps to transparent black rather than the surface at alpha 0; the obvious
class-level fix compiles to color-mix(..., 0%, transparent), which is not clearly
the surface hue either, and I cannot tell the two apart without a device. The
GitHub glyph lost its #24292F default, which is latent because the only caller
passes colorClassName, and hardcoding a brand hex back would fight the direction
of the refactor. Both are recorded on the pull request.
Pylon's 92ec009 landed after this branch and pursues the same goal by another
route: it replaces the status indicators' dark:/light: pairs with semantic
warning tokens and deletes the raw iconColor/iconBackground fields entirely.
That is the later Pylon decision, so threadPresentation and both thread-list
files take Pylon's version wholesale rather than this branch's adaptive-token
conversion of the same lines.
Integrating them needed four things. Pylon's files still imported useThemeColor,
which this branch deletes, so 25 call sites across the two thread-list files move
to useUniwindTheme. Pylon's nine remaining dark:/light: pairs are converted to
adaptive tokens the generator already defines, because the new lint rule rejects
them at error severity. The generated theme files are regenerated to pick up
Pylon's five new variables, and the theme test's hardcoded variable count moves
from 65 to 70 to match.
mobileTheme.test.ts keeps this branch's structure rather than Pylon's: Pylon's
version calls getMobileThemeVariables with DEFAULT_MOBILE_THEME_ID, which does
not typecheck here and silently resolves to BUILT_IN_THEMES[0] anyway.
#131 landed on pylon with the mobile warning colour split this branch had already
converted to adaptive tokens. Keeps both: the destructive/warning split stays,
expressed as adaptive-rose-600-400 and adaptive-amber-600-400 so the new lint
rule accepts it. Registers adaptive-amber-600-400, which did not exist yet.
@rynfar
rynfar merged commit eafef85 into pylonAug 29, 2026
15 checks passed
@rynfar
rynfar deleted the upstream/2026-08-28-mobile-uniwind-themes branch August 29, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXLvouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rynfar@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(mobile): compile semantic themes for Uniwind - #138

Merged
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes
Aug 29, 2026
Merged

refactor(mobile): compile semantic themes for Uniwind#138
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes

Conversation

@rynfar

@rynfarrynfar commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and all 67 of its call
sites.

Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327). 110 files.

Why now rather than deferred

This is a foundation change, not a cosmetic one, and deferring it was the
strictly worst option. Upstream is entirely on the far side of it — 67 call
sites before, 0 after — so every later mobile commit Pylon adopts would need
hand-translating back into useThemeColor, a tax paid repeatedly with the
benefit never arriving. It was already happening inside this same batch: #7327
rewrites GitActionProgressOverlay.tsx, the exact file upstream #8399 touches.

Adaptation — six conflicts

T3Wordmark stays deleted. Pylon removed it in 2929153bd feat: establish Pylon fork and renders PylonMark instead. Adopting upstream's converted
version would have restored a T3 brand component, which AGENTS.md forbids.
PylonMark instead gains the same withUniwind / colorClassName treatment
upstream applied to its own wordmark, so Pylon's mark follows the new idiom
without inheriting T3 artwork. Its mask paths keep literal fills, since only the
visible silhouette is themed. Verified T3Wordmark has no remaining references
anywhere in apps/mobile/src.

CompactBrandTitle / HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon". Confirmed
those resolve the same variable — the accent- prefix is Uniwind's convention,
not an accent colour, so accent-icon--color-icon.

ThreadComposer had two import conflicts, resolved by what the merged file
actually references rather than by picking a side: dropped StyleSheet and
themeColorWithAlpha as genuinely unused, kept Pylon's Alert,
KeyboardAvoidingView, and Modal.

GitActionProgressOverlay takes upstream's version wholesale, which is
correct here: it already carries #8399's glass restyle, and Pylon's only
divergence was the error-phase palette, which upstream expresses identically in
the new adaptive classes. The default merge had produced a hybrid whose
shadowColor was computed and never used — caught by lint, not by tests.

The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts.
All 65 tokens were compared before resolving: value-identical, so no Pylon
palette customisation is lost.

Second commit: the part upstream could not do

The adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
at error severity. Upstream converted its own files; three Pylon files it has
never seen still violated it 24 times, so this could not land green.
SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx are
Pylon-only, and the single ThreadFeed.tsx violation sits on a Pylon-added line
(upstream's copy of that file has no dark:/light: utilities at all).

28 light/dark pairs merged into adaptive tokens; 7 new tokens registered in the
generator. This is colour-preserving rather than lint-silencing — each token is
built from the same Tailwind family, shade, and opacity as the pair it replaced,
and the emitted values were checked numerically against tailwindcss/colors
(e.g. adaptive-rose-800-200oklch(45.5% 0.188 13.697) light,
oklch(89.2% 0.058 10.001) dark, exactly rose-800 and rose-200). Pairs merge
only where both halves are on one line, so a light-only utility is never
rewritten into an appearance-sensitive token.

Verification

  • vp test run apps/mobile841 passed
  • vp run -F @t3tools/mobile typecheck — clean
  • vp lint apps/mobile/src — clean, including the new rule (24 → 0)
  • vp test run oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts — 25 passed
  • vp run icons:check — "Pylon brand assets are current (40 files)", so no brand
    asset regression from the mark change
  • Generated files are reproducible: hashing generated-uniwind-themes.css,
    -theme-names.json, and -default-theme-variables.json across a regeneration
    gives an identical digest

Not verified

This has had no run on a simulator or device. It is a ~100-file theming change,
so it wants a test-pylon-mobile pass before merge — tests and lint cannot see
a colour that resolves to the wrong thing at runtime.

Claude Opus 5 via Claude Code.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

juliusmarmingeand others added 2 commits August 28, 2026 14:39
Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and its 67 call sites.
Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327).
Taken now rather than deferred because it is a foundation change, not a
cosmetic one. Upstream is entirely on the far side of it, so every later mobile
commit Pylon adopts would otherwise need hand-translating back into
useThemeColor - a tax paid repeatedly with the benefit never arriving.
Adaptation, six conflicts:
T3Wordmark stays deleted. Pylon removed it when the fork was established and
renders PylonMark instead; adopting upstream's converted version would have
restored a T3 brand component. PylonMark gains the same withUniwind
colorClassName treatment upstream applied to its own wordmark, so Pylon's mark
follows the new idiom without inheriting T3 artwork. Its mask geometry keeps
literal fills, since only the visible silhouette is themed.
CompactBrandTitle and HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon", which resolves
the same variable.
ThreadComposer's two import conflicts were resolved by what the merged file
actually references rather than by picking a side, dropping StyleSheet and
themeColorWithAlpha as genuinely unused and keeping Pylon's Alert,
KeyboardAvoidingView and Modal.
GitActionProgressOverlay takes upstream's version wholesale. It already carries
upstream #8399's glass restyle, and Pylon's only divergence there was the
error-phase palette, which upstream expresses identically in the new adaptive
classes. The default merge left a hybrid whose shadowColor was computed and
never used.
The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts. All 65
tokens were compared first and are value-identical, so no Pylon palette
customisation is lost.
The Uniwind adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
which rejects dark:/light: utilities because they do not follow registered custom
themes. Upstream converted its own files; three Pylon files it has never seen
still violated it, at error severity, so the adoption could not land green:
- SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx exist
only in Pylon;
- the one violation in ThreadFeed.tsx is on a Pylon-added line, confirmed by
upstream's copy of that file containing no dark:/light: utilities at all.
Merges 28 light/dark utility pairs into adaptive tokens across those three
files. Twelve of the tokens needed already existed; seven did not and are
registered in the generator's ADAPTIVE_COLORS map:
amber-100-500-a18, amber-900-200, neutral-100-a95-900-a95, neutral-800-200,
rose-800-200, sky-100-500-a18, and sky-800-200.
The conversion is colour-preserving, not merely lint-silencing. Each new token
is defined from the same Tailwind family, shade, and opacity as the pair it
replaces, and the emitted values were checked against tailwindcss/colors: for
example adaptive-rose-800-200 resolves to oklch(45.5% 0.188 13.697) in light and
oklch(89.2% 0.058 10.001) in dark, which are rose-800 and rose-200 exactly.
Pairs are merged only where both halves appear on the same line, so a light-only
utility is never rewritten into an appearance-sensitive token.
@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 28, 2026
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.2 KiB−117 B (−0.9%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+4 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.3 KiB−121 B (−1.9%)7.8 KiB
CodexLive turn WebSocket decoded55.5 KiB54.7 KiB−822 B (−1.4%)66.4 KiB
CodexLive turn messages990 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB−5 B (−0.0%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−5 B (−0.1%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB0 B (0.0%)7.8 KiB
ClaudeLive turn WebSocket decoded56.3 KiB56.3 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages990 (0.0%)21

Baseline: 3ed6520 · PR result: e3b4bd2 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…alette checks
Review follow-ups on the Uniwind adoption. All three are upstream regressions the
refactor introduced, not Pylon adaptations.
The thread composer lost its shadow colour. Upstream replaced an explicit
shadowColor plus 0.12/0.35 opacity with shadowOpacity: 1 and a bare
shadow-adaptive-black-a15-a35 class. Compiling that class with the repo's own
tailwindcss 4.3 shows it emits only --tw-shadow-color and no box-shadow, and
Uniwind's native store only maps a style when result.boxShadow is defined, so the
class reached React Native as nothing at all. With no shadowColor and full
opacity, iOS falls back to opaque black. The colour is set explicitly again, the
way GitActionProgressOverlay does it in this same refactor. Of the two
shadow-adaptive usages, the other one is paired with shadow-lg and is fine.
PendingUserInputCard drifted a shade pair: text-neutral-700 dark:text-neutral-200
became adaptive-neutral-600-300, lighter in light mode and darker in dark mode,
so contrast dropped in both. Every other conversion in that file is 1:1.
Registers adaptive-neutral-700-200 and uses it.
The readability test stopped covering the default palette when its loop narrowed
to BUILT_IN_THEME_IDS. Restoring MOBILE_THEME_IDS does not fix it: t3-code is
absent from BUILT_IN_THEMES, so getMobileThemeVariables falls back to
BUILT_IN_THEMES[0] and would check t3-chat twice, and it does not typecheck. The
default palette is asserted through readDefaultMobileThemeVariables, which reads
global.css. Verified by making the default placeholder unreadable: the check now
fails at a 1.02 contrast ratio, where before it passed.
@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Independent review pass found seven issues, all upstream regressions rather than
Pylon adaptations. Three fixed in a23ada2e5, four still open.

Fixed — the thread composer rendered an opaque black shadow.

Upstream replaced an explicit shadowColor plus 0.12/0.35 opacity with
shadowOpacity: 1 and a bare shadow-adaptive-black-a15-a35 class. I compiled
that class with the repo's own tailwindcss 4.3:

.shadow-adaptive-black-a15-a35 { --tw-shadow-color: … } ← no box-shadow
.shadow-lg { …; box-shadow: … } ← real box-shadow

Uniwind's native store only maps a style when result.boxShadow is defined
(core/native/store.js:126), so the class reached React Native as nothing.
With no shadowColor and full opacity, iOS falls back to opaque black on every
thread composer and the new-task draft screen. Colour is set explicitly again,
matching how GitActionProgressOverlay does it in this same refactor. The other
shadow-adaptive-* usage is paired with shadow-lg and is fine.

Fixed — a shade pair drifted.text-neutral-700 dark:text-neutral-200 became
adaptive-neutral-600-300: lighter in light, darker in dark, so contrast dropped
both ways. Every other conversion in that file is 1:1. Registered
adaptive-neutral-700-200.

Fixed — the default palette lost readability coverage. Note that the obvious
fix does not work: restoring MOBILE_THEME_IDS fails to typecheck and would
silently check t3-chat twice, because t3-code is absent from
BUILT_IN_THEMES and getMobileThemeVariables falls back to
BUILT_IN_THEMES[0]. I tried that first and my probe caught it. Now asserted
through readDefaultMobileThemeVariables, which reads global.css — verified by
making the default placeholder unreadable and watching the check fail at a 1.02
contrast ratio.

Still open — flagged, not fixed, because they want the device pass this PR
already needs:

  • ComposerToolbar.tsxshadow-lg now supplies a real boxShadow, so the
    surviving shadowOpacity: props.disabled ? 0 : 1 may no longer remove the
    shadow on disabled toolbar buttons.
  • App.tsx / LoadingScreen.tsxStatusBar backgroundColor removed while
    translucent stays true, so the Android status-bar scrim is gone and
    --color-status-bar has no consumer.
  • ComposerToolbar.tsx — toolbar fades interpolate to transparent rather than
    the surface colour at alpha 0, which reintroduces the grey-smudge artifact the
    removed themeColorWithAlpha(surface, 0) existed to avoid.
  • SourceControlIcon.tsx — the GitHub glyph lost its #24292F default fill.
    Latent: the only caller passes colorClassName.

@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Mobile pass — partial

Ran this branch's bundle on a booted iPhone 17 Pro (iOS 26.3) against a
disposable backend seeded from real data. No native rebuild: the only
native-adjacent change is a Metro config line, so an installed PylonDev.app
plus expo start --clear was the right launch path.

What the refactor gets right, confirmed on device. The compiled Uniwind
themes render correctly across the Home list and the glass sheet — text
hierarchy, muted secondary labels, separators, the blue "Working" accent, the
settled section, and the sheet's own surface and shadow all resolve.

Home screen on the Uniwind branch

Choose project glass sheet

The environment connected rather than rendering an empty disconnected state —
the third settled row picks up the Pylon environment icon after pairing.

What I could not verify, and why. The three things most worth a device pass
are still unverified: the composer shadow fix in a23ada2e5, the amber warning
row, and the three converted Pylon-only screens. All of them live behind a thread
or a project row, and neither is exposed to accessibility. A runtime snapshot
lists only chrome — compose, filter, Open settings, search, the settled
toggle — and wait_for_ui for any cell times out with none present. The
skill's guidance is to report that as an accessibility blocker rather than guess
coordinates, so I stopped there.

Worth noting on its own: thread and project rows having no accessible role is a
VoiceOver problem independent of this PR, and I did not confirm whether it
predates the refactor.

So the four visual findings from the review — disabled-button shadow, Android
status-bar scrim, toolbar fade smudge, GitHub glyph fill — remain open, and the
composer fix remains verified only by the Tailwind/Uniwind mechanism analysis and
not by eye.

…tatus bar scrim
Two more review follow-ups on the Uniwind adoption.
Disabled toolbar buttons kept their shadow. The refactor moved the shadow to a
shadow-lg class, which Uniwind maps to React Native's boxShadow, and boxShadow
wins over the legacy shadowOpacity the style still set to 0 when disabled. The
class is now dropped for disabled buttons and the dead shadowOpacity line is
gone.
The Android status bar lost its scrim. backgroundColor was removed while
translucent stayed true, so content draws under the bar with nothing behind it,
and --color-status-bar was left with no consumer at all. Restores the same
variable origin/pylon read, now through useUniwindTheme. App.tsx joins the lint
rule's interop allowlist because StatusBar's Android backgroundColor is a native
prop that cannot take a className.
Two findings from the same review are deliberately not fixed. The toolbar fade
ramps to transparent black rather than the surface at alpha 0; the obvious
class-level fix compiles to color-mix(..., 0%, transparent), which is not clearly
the surface hue either, and I cannot tell the two apart without a device. The
GitHub glyph lost its #24292F default, which is latent because the only caller
passes colorClassName, and hardcoding a brand hex back would fight the direction
of the refactor. Both are recorded on the pull request.
Pylon's 92ec009 landed after this branch and pursues the same goal by another
route: it replaces the status indicators' dark:/light: pairs with semantic
warning tokens and deletes the raw iconColor/iconBackground fields entirely.
That is the later Pylon decision, so threadPresentation and both thread-list
files take Pylon's version wholesale rather than this branch's adaptive-token
conversion of the same lines.
Integrating them needed four things. Pylon's files still imported useThemeColor,
which this branch deletes, so 25 call sites across the two thread-list files move
to useUniwindTheme. Pylon's nine remaining dark:/light: pairs are converted to
adaptive tokens the generator already defines, because the new lint rule rejects
them at error severity. The generated theme files are regenerated to pick up
Pylon's five new variables, and the theme test's hardcoded variable count moves
from 65 to 70 to match.
mobileTheme.test.ts keeps this branch's structure rather than Pylon's: Pylon's
version calls getMobileThemeVariables with DEFAULT_MOBILE_THEME_ID, which does
not typecheck here and silently resolves to BUILT_IN_THEMES[0] anyway.
#131 landed on pylon with the mobile warning colour split this branch had already
converted to adaptive tokens. Keeps both: the destructive/warning split stays,
expressed as adaptive-rose-600-400 and adaptive-amber-600-400 so the new lint
rule accepts it. Registers adaptive-amber-600-400, which did not exist yet.
@rynfar
rynfar merged commit eafef85 into pylonAug 29, 2026
15 checks passed
@rynfar
rynfar deleted the upstream/2026-08-28-mobile-uniwind-themes branch August 29, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXLvouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rynfar@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(mobile): compile semantic themes for Uniwind - #138

Merged
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes
Aug 29, 2026
Merged

refactor(mobile): compile semantic themes for Uniwind#138
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes

Conversation

@rynfar

@rynfarrynfar commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and all 67 of its call
sites.

Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327). 110 files.

Why now rather than deferred

This is a foundation change, not a cosmetic one, and deferring it was the
strictly worst option. Upstream is entirely on the far side of it — 67 call
sites before, 0 after — so every later mobile commit Pylon adopts would need
hand-translating back into useThemeColor, a tax paid repeatedly with the
benefit never arriving. It was already happening inside this same batch: #7327
rewrites GitActionProgressOverlay.tsx, the exact file upstream #8399 touches.

Adaptation — six conflicts

T3Wordmark stays deleted. Pylon removed it in 2929153bd feat: establish Pylon fork and renders PylonMark instead. Adopting upstream's converted
version would have restored a T3 brand component, which AGENTS.md forbids.
PylonMark instead gains the same withUniwind / colorClassName treatment
upstream applied to its own wordmark, so Pylon's mark follows the new idiom
without inheriting T3 artwork. Its mask paths keep literal fills, since only the
visible silhouette is themed. Verified T3Wordmark has no remaining references
anywhere in apps/mobile/src.

CompactBrandTitle / HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon". Confirmed
those resolve the same variable — the accent- prefix is Uniwind's convention,
not an accent colour, so accent-icon--color-icon.

ThreadComposer had two import conflicts, resolved by what the merged file
actually references rather than by picking a side: dropped StyleSheet and
themeColorWithAlpha as genuinely unused, kept Pylon's Alert,
KeyboardAvoidingView, and Modal.

GitActionProgressOverlay takes upstream's version wholesale, which is
correct here: it already carries #8399's glass restyle, and Pylon's only
divergence was the error-phase palette, which upstream expresses identically in
the new adaptive classes. The default merge had produced a hybrid whose
shadowColor was computed and never used — caught by lint, not by tests.

The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts.
All 65 tokens were compared before resolving: value-identical, so no Pylon
palette customisation is lost.

Second commit: the part upstream could not do

The adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
at error severity. Upstream converted its own files; three Pylon files it has
never seen still violated it 24 times, so this could not land green.
SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx are
Pylon-only, and the single ThreadFeed.tsx violation sits on a Pylon-added line
(upstream's copy of that file has no dark:/light: utilities at all).

28 light/dark pairs merged into adaptive tokens; 7 new tokens registered in the
generator. This is colour-preserving rather than lint-silencing — each token is
built from the same Tailwind family, shade, and opacity as the pair it replaced,
and the emitted values were checked numerically against tailwindcss/colors
(e.g. adaptive-rose-800-200oklch(45.5% 0.188 13.697) light,
oklch(89.2% 0.058 10.001) dark, exactly rose-800 and rose-200). Pairs merge
only where both halves are on one line, so a light-only utility is never
rewritten into an appearance-sensitive token.

Verification

  • vp test run apps/mobile841 passed
  • vp run -F @t3tools/mobile typecheck — clean
  • vp lint apps/mobile/src — clean, including the new rule (24 → 0)
  • vp test run oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts — 25 passed
  • vp run icons:check — "Pylon brand assets are current (40 files)", so no brand
    asset regression from the mark change
  • Generated files are reproducible: hashing generated-uniwind-themes.css,
    -theme-names.json, and -default-theme-variables.json across a regeneration
    gives an identical digest

Not verified

This has had no run on a simulator or device. It is a ~100-file theming change,
so it wants a test-pylon-mobile pass before merge — tests and lint cannot see
a colour that resolves to the wrong thing at runtime.

Claude Opus 5 via Claude Code.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

juliusmarmingeand others added 2 commits August 28, 2026 14:39
Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and its 67 call sites.
Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327).
Taken now rather than deferred because it is a foundation change, not a
cosmetic one. Upstream is entirely on the far side of it, so every later mobile
commit Pylon adopts would otherwise need hand-translating back into
useThemeColor - a tax paid repeatedly with the benefit never arriving.
Adaptation, six conflicts:
T3Wordmark stays deleted. Pylon removed it when the fork was established and
renders PylonMark instead; adopting upstream's converted version would have
restored a T3 brand component. PylonMark gains the same withUniwind
colorClassName treatment upstream applied to its own wordmark, so Pylon's mark
follows the new idiom without inheriting T3 artwork. Its mask geometry keeps
literal fills, since only the visible silhouette is themed.
CompactBrandTitle and HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon", which resolves
the same variable.
ThreadComposer's two import conflicts were resolved by what the merged file
actually references rather than by picking a side, dropping StyleSheet and
themeColorWithAlpha as genuinely unused and keeping Pylon's Alert,
KeyboardAvoidingView and Modal.
GitActionProgressOverlay takes upstream's version wholesale. It already carries
upstream #8399's glass restyle, and Pylon's only divergence there was the
error-phase palette, which upstream expresses identically in the new adaptive
classes. The default merge left a hybrid whose shadowColor was computed and
never used.
The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts. All 65
tokens were compared first and are value-identical, so no Pylon palette
customisation is lost.
The Uniwind adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
which rejects dark:/light: utilities because they do not follow registered custom
themes. Upstream converted its own files; three Pylon files it has never seen
still violated it, at error severity, so the adoption could not land green:
- SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx exist
only in Pylon;
- the one violation in ThreadFeed.tsx is on a Pylon-added line, confirmed by
upstream's copy of that file containing no dark:/light: utilities at all.
Merges 28 light/dark utility pairs into adaptive tokens across those three
files. Twelve of the tokens needed already existed; seven did not and are
registered in the generator's ADAPTIVE_COLORS map:
amber-100-500-a18, amber-900-200, neutral-100-a95-900-a95, neutral-800-200,
rose-800-200, sky-100-500-a18, and sky-800-200.
The conversion is colour-preserving, not merely lint-silencing. Each new token
is defined from the same Tailwind family, shade, and opacity as the pair it
replaces, and the emitted values were checked against tailwindcss/colors: for
example adaptive-rose-800-200 resolves to oklch(45.5% 0.188 13.697) in light and
oklch(89.2% 0.058 10.001) in dark, which are rose-800 and rose-200 exactly.
Pairs are merged only where both halves appear on the same line, so a light-only
utility is never rewritten into an appearance-sensitive token.
@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 28, 2026
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.2 KiB−117 B (−0.9%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+4 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.3 KiB−121 B (−1.9%)7.8 KiB
CodexLive turn WebSocket decoded55.5 KiB54.7 KiB−822 B (−1.4%)66.4 KiB
CodexLive turn messages990 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB−5 B (−0.0%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−5 B (−0.1%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB0 B (0.0%)7.8 KiB
ClaudeLive turn WebSocket decoded56.3 KiB56.3 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages990 (0.0%)21

Baseline: 3ed6520 · PR result: e3b4bd2 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…alette checks
Review follow-ups on the Uniwind adoption. All three are upstream regressions the
refactor introduced, not Pylon adaptations.
The thread composer lost its shadow colour. Upstream replaced an explicit
shadowColor plus 0.12/0.35 opacity with shadowOpacity: 1 and a bare
shadow-adaptive-black-a15-a35 class. Compiling that class with the repo's own
tailwindcss 4.3 shows it emits only --tw-shadow-color and no box-shadow, and
Uniwind's native store only maps a style when result.boxShadow is defined, so the
class reached React Native as nothing at all. With no shadowColor and full
opacity, iOS falls back to opaque black. The colour is set explicitly again, the
way GitActionProgressOverlay does it in this same refactor. Of the two
shadow-adaptive usages, the other one is paired with shadow-lg and is fine.
PendingUserInputCard drifted a shade pair: text-neutral-700 dark:text-neutral-200
became adaptive-neutral-600-300, lighter in light mode and darker in dark mode,
so contrast dropped in both. Every other conversion in that file is 1:1.
Registers adaptive-neutral-700-200 and uses it.
The readability test stopped covering the default palette when its loop narrowed
to BUILT_IN_THEME_IDS. Restoring MOBILE_THEME_IDS does not fix it: t3-code is
absent from BUILT_IN_THEMES, so getMobileThemeVariables falls back to
BUILT_IN_THEMES[0] and would check t3-chat twice, and it does not typecheck. The
default palette is asserted through readDefaultMobileThemeVariables, which reads
global.css. Verified by making the default placeholder unreadable: the check now
fails at a 1.02 contrast ratio, where before it passed.
@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Independent review pass found seven issues, all upstream regressions rather than
Pylon adaptations. Three fixed in a23ada2e5, four still open.

Fixed — the thread composer rendered an opaque black shadow.

Upstream replaced an explicit shadowColor plus 0.12/0.35 opacity with
shadowOpacity: 1 and a bare shadow-adaptive-black-a15-a35 class. I compiled
that class with the repo's own tailwindcss 4.3:

.shadow-adaptive-black-a15-a35 { --tw-shadow-color: … } ← no box-shadow
.shadow-lg { …; box-shadow: … } ← real box-shadow

Uniwind's native store only maps a style when result.boxShadow is defined
(core/native/store.js:126), so the class reached React Native as nothing.
With no shadowColor and full opacity, iOS falls back to opaque black on every
thread composer and the new-task draft screen. Colour is set explicitly again,
matching how GitActionProgressOverlay does it in this same refactor. The other
shadow-adaptive-* usage is paired with shadow-lg and is fine.

Fixed — a shade pair drifted.text-neutral-700 dark:text-neutral-200 became
adaptive-neutral-600-300: lighter in light, darker in dark, so contrast dropped
both ways. Every other conversion in that file is 1:1. Registered
adaptive-neutral-700-200.

Fixed — the default palette lost readability coverage. Note that the obvious
fix does not work: restoring MOBILE_THEME_IDS fails to typecheck and would
silently check t3-chat twice, because t3-code is absent from
BUILT_IN_THEMES and getMobileThemeVariables falls back to
BUILT_IN_THEMES[0]. I tried that first and my probe caught it. Now asserted
through readDefaultMobileThemeVariables, which reads global.css — verified by
making the default placeholder unreadable and watching the check fail at a 1.02
contrast ratio.

Still open — flagged, not fixed, because they want the device pass this PR
already needs:

  • ComposerToolbar.tsxshadow-lg now supplies a real boxShadow, so the
    surviving shadowOpacity: props.disabled ? 0 : 1 may no longer remove the
    shadow on disabled toolbar buttons.
  • App.tsx / LoadingScreen.tsxStatusBar backgroundColor removed while
    translucent stays true, so the Android status-bar scrim is gone and
    --color-status-bar has no consumer.
  • ComposerToolbar.tsx — toolbar fades interpolate to transparent rather than
    the surface colour at alpha 0, which reintroduces the grey-smudge artifact the
    removed themeColorWithAlpha(surface, 0) existed to avoid.
  • SourceControlIcon.tsx — the GitHub glyph lost its #24292F default fill.
    Latent: the only caller passes colorClassName.

@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Mobile pass — partial

Ran this branch's bundle on a booted iPhone 17 Pro (iOS 26.3) against a
disposable backend seeded from real data. No native rebuild: the only
native-adjacent change is a Metro config line, so an installed PylonDev.app
plus expo start --clear was the right launch path.

What the refactor gets right, confirmed on device. The compiled Uniwind
themes render correctly across the Home list and the glass sheet — text
hierarchy, muted secondary labels, separators, the blue "Working" accent, the
settled section, and the sheet's own surface and shadow all resolve.

Home screen on the Uniwind branch

Choose project glass sheet

The environment connected rather than rendering an empty disconnected state —
the third settled row picks up the Pylon environment icon after pairing.

What I could not verify, and why. The three things most worth a device pass
are still unverified: the composer shadow fix in a23ada2e5, the amber warning
row, and the three converted Pylon-only screens. All of them live behind a thread
or a project row, and neither is exposed to accessibility. A runtime snapshot
lists only chrome — compose, filter, Open settings, search, the settled
toggle — and wait_for_ui for any cell times out with none present. The
skill's guidance is to report that as an accessibility blocker rather than guess
coordinates, so I stopped there.

Worth noting on its own: thread and project rows having no accessible role is a
VoiceOver problem independent of this PR, and I did not confirm whether it
predates the refactor.

So the four visual findings from the review — disabled-button shadow, Android
status-bar scrim, toolbar fade smudge, GitHub glyph fill — remain open, and the
composer fix remains verified only by the Tailwind/Uniwind mechanism analysis and
not by eye.

…tatus bar scrim
Two more review follow-ups on the Uniwind adoption.
Disabled toolbar buttons kept their shadow. The refactor moved the shadow to a
shadow-lg class, which Uniwind maps to React Native's boxShadow, and boxShadow
wins over the legacy shadowOpacity the style still set to 0 when disabled. The
class is now dropped for disabled buttons and the dead shadowOpacity line is
gone.
The Android status bar lost its scrim. backgroundColor was removed while
translucent stayed true, so content draws under the bar with nothing behind it,
and --color-status-bar was left with no consumer at all. Restores the same
variable origin/pylon read, now through useUniwindTheme. App.tsx joins the lint
rule's interop allowlist because StatusBar's Android backgroundColor is a native
prop that cannot take a className.
Two findings from the same review are deliberately not fixed. The toolbar fade
ramps to transparent black rather than the surface at alpha 0; the obvious
class-level fix compiles to color-mix(..., 0%, transparent), which is not clearly
the surface hue either, and I cannot tell the two apart without a device. The
GitHub glyph lost its #24292F default, which is latent because the only caller
passes colorClassName, and hardcoding a brand hex back would fight the direction
of the refactor. Both are recorded on the pull request.
Pylon's 92ec009 landed after this branch and pursues the same goal by another
route: it replaces the status indicators' dark:/light: pairs with semantic
warning tokens and deletes the raw iconColor/iconBackground fields entirely.
That is the later Pylon decision, so threadPresentation and both thread-list
files take Pylon's version wholesale rather than this branch's adaptive-token
conversion of the same lines.
Integrating them needed four things. Pylon's files still imported useThemeColor,
which this branch deletes, so 25 call sites across the two thread-list files move
to useUniwindTheme. Pylon's nine remaining dark:/light: pairs are converted to
adaptive tokens the generator already defines, because the new lint rule rejects
them at error severity. The generated theme files are regenerated to pick up
Pylon's five new variables, and the theme test's hardcoded variable count moves
from 65 to 70 to match.
mobileTheme.test.ts keeps this branch's structure rather than Pylon's: Pylon's
version calls getMobileThemeVariables with DEFAULT_MOBILE_THEME_ID, which does
not typecheck here and silently resolves to BUILT_IN_THEMES[0] anyway.
#131 landed on pylon with the mobile warning colour split this branch had already
converted to adaptive tokens. Keeps both: the destructive/warning split stays,
expressed as adaptive-rose-600-400 and adaptive-amber-600-400 so the new lint
rule accepts it. Registers adaptive-amber-600-400, which did not exist yet.
@rynfar
rynfar merged commit eafef85 into pylonAug 29, 2026
15 checks passed
@rynfar
rynfar deleted the upstream/2026-08-28-mobile-uniwind-themes branch August 29, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXLvouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rynfar@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

refactor(mobile): compile semantic themes for Uniwind - #138

Merged
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes
Aug 29, 2026
Merged

refactor(mobile): compile semantic themes for Uniwind#138
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes

Conversation

@rynfar

@rynfarrynfar commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and all 67 of its call
sites.

Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327). 110 files.

Why now rather than deferred

This is a foundation change, not a cosmetic one, and deferring it was the
strictly worst option. Upstream is entirely on the far side of it — 67 call
sites before, 0 after — so every later mobile commit Pylon adopts would need
hand-translating back into useThemeColor, a tax paid repeatedly with the
benefit never arriving. It was already happening inside this same batch: #7327
rewrites GitActionProgressOverlay.tsx, the exact file upstream #8399 touches.

Adaptation — six conflicts

T3Wordmark stays deleted. Pylon removed it in 2929153bd feat: establish Pylon fork and renders PylonMark instead. Adopting upstream's converted
version would have restored a T3 brand component, which AGENTS.md forbids.
PylonMark instead gains the same withUniwind / colorClassName treatment
upstream applied to its own wordmark, so Pylon's mark follows the new idiom
without inheriting T3 artwork. Its mask paths keep literal fills, since only the
visible silhouette is themed. Verified T3Wordmark has no remaining references
anywhere in apps/mobile/src.

CompactBrandTitle / HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon". Confirmed
those resolve the same variable — the accent- prefix is Uniwind's convention,
not an accent colour, so accent-icon--color-icon.

ThreadComposer had two import conflicts, resolved by what the merged file
actually references rather than by picking a side: dropped StyleSheet and
themeColorWithAlpha as genuinely unused, kept Pylon's Alert,
KeyboardAvoidingView, and Modal.

GitActionProgressOverlay takes upstream's version wholesale, which is
correct here: it already carries #8399's glass restyle, and Pylon's only
divergence was the error-phase palette, which upstream expresses identically in
the new adaptive classes. The default merge had produced a hybrid whose
shadowColor was computed and never used — caught by lint, not by tests.

The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts.
All 65 tokens were compared before resolving: value-identical, so no Pylon
palette customisation is lost.

Second commit: the part upstream could not do

The adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
at error severity. Upstream converted its own files; three Pylon files it has
never seen still violated it 24 times, so this could not land green.
SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx are
Pylon-only, and the single ThreadFeed.tsx violation sits on a Pylon-added line
(upstream's copy of that file has no dark:/light: utilities at all).

28 light/dark pairs merged into adaptive tokens; 7 new tokens registered in the
generator. This is colour-preserving rather than lint-silencing — each token is
built from the same Tailwind family, shade, and opacity as the pair it replaced,
and the emitted values were checked numerically against tailwindcss/colors
(e.g. adaptive-rose-800-200oklch(45.5% 0.188 13.697) light,
oklch(89.2% 0.058 10.001) dark, exactly rose-800 and rose-200). Pairs merge
only where both halves are on one line, so a light-only utility is never
rewritten into an appearance-sensitive token.

Verification

  • vp test run apps/mobile841 passed
  • vp run -F @t3tools/mobile typecheck — clean
  • vp lint apps/mobile/src — clean, including the new rule (24 → 0)
  • vp test run oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts — 25 passed
  • vp run icons:check — "Pylon brand assets are current (40 files)", so no brand
    asset regression from the mark change
  • Generated files are reproducible: hashing generated-uniwind-themes.css,
    -theme-names.json, and -default-theme-variables.json across a regeneration
    gives an identical digest

Not verified

This has had no run on a simulator or device. It is a ~100-file theming change,
so it wants a test-pylon-mobile pass before merge — tests and lint cannot see
a colour that resolves to the wrong thing at runtime.

Claude Opus 5 via Claude Code.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

juliusmarmingeand others added 2 commits August 28, 2026 14:39
Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and its 67 call sites.
Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327).
Taken now rather than deferred because it is a foundation change, not a
cosmetic one. Upstream is entirely on the far side of it, so every later mobile
commit Pylon adopts would otherwise need hand-translating back into
useThemeColor - a tax paid repeatedly with the benefit never arriving.
Adaptation, six conflicts:
T3Wordmark stays deleted. Pylon removed it when the fork was established and
renders PylonMark instead; adopting upstream's converted version would have
restored a T3 brand component. PylonMark gains the same withUniwind
colorClassName treatment upstream applied to its own wordmark, so Pylon's mark
follows the new idiom without inheriting T3 artwork. Its mask geometry keeps
literal fills, since only the visible silhouette is themed.
CompactBrandTitle and HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon", which resolves
the same variable.
ThreadComposer's two import conflicts were resolved by what the merged file
actually references rather than by picking a side, dropping StyleSheet and
themeColorWithAlpha as genuinely unused and keeping Pylon's Alert,
KeyboardAvoidingView and Modal.
GitActionProgressOverlay takes upstream's version wholesale. It already carries
upstream #8399's glass restyle, and Pylon's only divergence there was the
error-phase palette, which upstream expresses identically in the new adaptive
classes. The default merge left a hybrid whose shadowColor was computed and
never used.
The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts. All 65
tokens were compared first and are value-identical, so no Pylon palette
customisation is lost.
The Uniwind adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
which rejects dark:/light: utilities because they do not follow registered custom
themes. Upstream converted its own files; three Pylon files it has never seen
still violated it, at error severity, so the adoption could not land green:
- SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx exist
only in Pylon;
- the one violation in ThreadFeed.tsx is on a Pylon-added line, confirmed by
upstream's copy of that file containing no dark:/light: utilities at all.
Merges 28 light/dark utility pairs into adaptive tokens across those three
files. Twelve of the tokens needed already existed; seven did not and are
registered in the generator's ADAPTIVE_COLORS map:
amber-100-500-a18, amber-900-200, neutral-100-a95-900-a95, neutral-800-200,
rose-800-200, sky-100-500-a18, and sky-800-200.
The conversion is colour-preserving, not merely lint-silencing. Each new token
is defined from the same Tailwind family, shade, and opacity as the pair it
replaces, and the emitted values were checked against tailwindcss/colors: for
example adaptive-rose-800-200 resolves to oklch(45.5% 0.188 13.697) in light and
oklch(89.2% 0.058 10.001) in dark, which are rose-800 and rose-200 exactly.
Pairs are merged only where both halves appear on the same line, so a light-only
utility is never rewritten into an appearance-sensitive token.
@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 28, 2026
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.2 KiB−117 B (−0.9%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+4 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.3 KiB−121 B (−1.9%)7.8 KiB
CodexLive turn WebSocket decoded55.5 KiB54.7 KiB−822 B (−1.4%)66.4 KiB
CodexLive turn messages990 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB−5 B (−0.0%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−5 B (−0.1%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB0 B (0.0%)7.8 KiB
ClaudeLive turn WebSocket decoded56.3 KiB56.3 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages990 (0.0%)21

Baseline: 3ed6520 · PR result: e3b4bd2 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…alette checks
Review follow-ups on the Uniwind adoption. All three are upstream regressions the
refactor introduced, not Pylon adaptations.
The thread composer lost its shadow colour. Upstream replaced an explicit
shadowColor plus 0.12/0.35 opacity with shadowOpacity: 1 and a bare
shadow-adaptive-black-a15-a35 class. Compiling that class with the repo's own
tailwindcss 4.3 shows it emits only --tw-shadow-color and no box-shadow, and
Uniwind's native store only maps a style when result.boxShadow is defined, so the
class reached React Native as nothing at all. With no shadowColor and full
opacity, iOS falls back to opaque black. The colour is set explicitly again, the
way GitActionProgressOverlay does it in this same refactor. Of the two
shadow-adaptive usages, the other one is paired with shadow-lg and is fine.
PendingUserInputCard drifted a shade pair: text-neutral-700 dark:text-neutral-200
became adaptive-neutral-600-300, lighter in light mode and darker in dark mode,
so contrast dropped in both. Every other conversion in that file is 1:1.
Registers adaptive-neutral-700-200 and uses it.
The readability test stopped covering the default palette when its loop narrowed
to BUILT_IN_THEME_IDS. Restoring MOBILE_THEME_IDS does not fix it: t3-code is
absent from BUILT_IN_THEMES, so getMobileThemeVariables falls back to
BUILT_IN_THEMES[0] and would check t3-chat twice, and it does not typecheck. The
default palette is asserted through readDefaultMobileThemeVariables, which reads
global.css. Verified by making the default placeholder unreadable: the check now
fails at a 1.02 contrast ratio, where before it passed.
@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Independent review pass found seven issues, all upstream regressions rather than
Pylon adaptations. Three fixed in a23ada2e5, four still open.

Fixed — the thread composer rendered an opaque black shadow.

Upstream replaced an explicit shadowColor plus 0.12/0.35 opacity with
shadowOpacity: 1 and a bare shadow-adaptive-black-a15-a35 class. I compiled
that class with the repo's own tailwindcss 4.3:

.shadow-adaptive-black-a15-a35 { --tw-shadow-color: … } ← no box-shadow
.shadow-lg { …; box-shadow: … } ← real box-shadow

Uniwind's native store only maps a style when result.boxShadow is defined
(core/native/store.js:126), so the class reached React Native as nothing.
With no shadowColor and full opacity, iOS falls back to opaque black on every
thread composer and the new-task draft screen. Colour is set explicitly again,
matching how GitActionProgressOverlay does it in this same refactor. The other
shadow-adaptive-* usage is paired with shadow-lg and is fine.

Fixed — a shade pair drifted.text-neutral-700 dark:text-neutral-200 became
adaptive-neutral-600-300: lighter in light, darker in dark, so contrast dropped
both ways. Every other conversion in that file is 1:1. Registered
adaptive-neutral-700-200.

Fixed — the default palette lost readability coverage. Note that the obvious
fix does not work: restoring MOBILE_THEME_IDS fails to typecheck and would
silently check t3-chat twice, because t3-code is absent from
BUILT_IN_THEMES and getMobileThemeVariables falls back to
BUILT_IN_THEMES[0]. I tried that first and my probe caught it. Now asserted
through readDefaultMobileThemeVariables, which reads global.css — verified by
making the default placeholder unreadable and watching the check fail at a 1.02
contrast ratio.

Still open — flagged, not fixed, because they want the device pass this PR
already needs:

  • ComposerToolbar.tsxshadow-lg now supplies a real boxShadow, so the
    surviving shadowOpacity: props.disabled ? 0 : 1 may no longer remove the
    shadow on disabled toolbar buttons.
  • App.tsx / LoadingScreen.tsxStatusBar backgroundColor removed while
    translucent stays true, so the Android status-bar scrim is gone and
    --color-status-bar has no consumer.
  • ComposerToolbar.tsx — toolbar fades interpolate to transparent rather than
    the surface colour at alpha 0, which reintroduces the grey-smudge artifact the
    removed themeColorWithAlpha(surface, 0) existed to avoid.
  • SourceControlIcon.tsx — the GitHub glyph lost its #24292F default fill.
    Latent: the only caller passes colorClassName.

@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Mobile pass — partial

Ran this branch's bundle on a booted iPhone 17 Pro (iOS 26.3) against a
disposable backend seeded from real data. No native rebuild: the only
native-adjacent change is a Metro config line, so an installed PylonDev.app
plus expo start --clear was the right launch path.

What the refactor gets right, confirmed on device. The compiled Uniwind
themes render correctly across the Home list and the glass sheet — text
hierarchy, muted secondary labels, separators, the blue "Working" accent, the
settled section, and the sheet's own surface and shadow all resolve.

Home screen on the Uniwind branch

Choose project glass sheet

The environment connected rather than rendering an empty disconnected state —
the third settled row picks up the Pylon environment icon after pairing.

What I could not verify, and why. The three things most worth a device pass
are still unverified: the composer shadow fix in a23ada2e5, the amber warning
row, and the three converted Pylon-only screens. All of them live behind a thread
or a project row, and neither is exposed to accessibility. A runtime snapshot
lists only chrome — compose, filter, Open settings, search, the settled
toggle — and wait_for_ui for any cell times out with none present. The
skill's guidance is to report that as an accessibility blocker rather than guess
coordinates, so I stopped there.

Worth noting on its own: thread and project rows having no accessible role is a
VoiceOver problem independent of this PR, and I did not confirm whether it
predates the refactor.

So the four visual findings from the review — disabled-button shadow, Android
status-bar scrim, toolbar fade smudge, GitHub glyph fill — remain open, and the
composer fix remains verified only by the Tailwind/Uniwind mechanism analysis and
not by eye.

…tatus bar scrim
Two more review follow-ups on the Uniwind adoption.
Disabled toolbar buttons kept their shadow. The refactor moved the shadow to a
shadow-lg class, which Uniwind maps to React Native's boxShadow, and boxShadow
wins over the legacy shadowOpacity the style still set to 0 when disabled. The
class is now dropped for disabled buttons and the dead shadowOpacity line is
gone.
The Android status bar lost its scrim. backgroundColor was removed while
translucent stayed true, so content draws under the bar with nothing behind it,
and --color-status-bar was left with no consumer at all. Restores the same
variable origin/pylon read, now through useUniwindTheme. App.tsx joins the lint
rule's interop allowlist because StatusBar's Android backgroundColor is a native
prop that cannot take a className.
Two findings from the same review are deliberately not fixed. The toolbar fade
ramps to transparent black rather than the surface at alpha 0; the obvious
class-level fix compiles to color-mix(..., 0%, transparent), which is not clearly
the surface hue either, and I cannot tell the two apart without a device. The
GitHub glyph lost its #24292F default, which is latent because the only caller
passes colorClassName, and hardcoding a brand hex back would fight the direction
of the refactor. Both are recorded on the pull request.
Pylon's 92ec009 landed after this branch and pursues the same goal by another
route: it replaces the status indicators' dark:/light: pairs with semantic
warning tokens and deletes the raw iconColor/iconBackground fields entirely.
That is the later Pylon decision, so threadPresentation and both thread-list
files take Pylon's version wholesale rather than this branch's adaptive-token
conversion of the same lines.
Integrating them needed four things. Pylon's files still imported useThemeColor,
which this branch deletes, so 25 call sites across the two thread-list files move
to useUniwindTheme. Pylon's nine remaining dark:/light: pairs are converted to
adaptive tokens the generator already defines, because the new lint rule rejects
them at error severity. The generated theme files are regenerated to pick up
Pylon's five new variables, and the theme test's hardcoded variable count moves
from 65 to 70 to match.
mobileTheme.test.ts keeps this branch's structure rather than Pylon's: Pylon's
version calls getMobileThemeVariables with DEFAULT_MOBILE_THEME_ID, which does
not typecheck here and silently resolves to BUILT_IN_THEMES[0] anyway.
#131 landed on pylon with the mobile warning colour split this branch had already
converted to adaptive tokens. Keeps both: the destructive/warning split stays,
expressed as adaptive-rose-600-400 and adaptive-amber-600-400 so the new lint
rule accepts it. Registers adaptive-amber-600-400, which did not exist yet.
@rynfar
rynfar merged commit eafef85 into pylonAug 29, 2026
15 checks passed
@rynfar
rynfar deleted the upstream/2026-08-28-mobile-uniwind-themes branch August 29, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXLvouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rynfar@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(mobile): compile semantic themes for Uniwind - #138

Merged
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes
Aug 29, 2026
Merged

refactor(mobile): compile semantic themes for Uniwind#138
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes

Conversation

@rynfar

@rynfarrynfar commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and all 67 of its call
sites.

Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327). 110 files.

Why now rather than deferred

This is a foundation change, not a cosmetic one, and deferring it was the
strictly worst option. Upstream is entirely on the far side of it — 67 call
sites before, 0 after — so every later mobile commit Pylon adopts would need
hand-translating back into useThemeColor, a tax paid repeatedly with the
benefit never arriving. It was already happening inside this same batch: #7327
rewrites GitActionProgressOverlay.tsx, the exact file upstream #8399 touches.

Adaptation — six conflicts

T3Wordmark stays deleted. Pylon removed it in 2929153bd feat: establish Pylon fork and renders PylonMark instead. Adopting upstream's converted
version would have restored a T3 brand component, which AGENTS.md forbids.
PylonMark instead gains the same withUniwind / colorClassName treatment
upstream applied to its own wordmark, so Pylon's mark follows the new idiom
without inheriting T3 artwork. Its mask paths keep literal fills, since only the
visible silhouette is themed. Verified T3Wordmark has no remaining references
anywhere in apps/mobile/src.

CompactBrandTitle / HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon". Confirmed
those resolve the same variable — the accent- prefix is Uniwind's convention,
not an accent colour, so accent-icon--color-icon.

ThreadComposer had two import conflicts, resolved by what the merged file
actually references rather than by picking a side: dropped StyleSheet and
themeColorWithAlpha as genuinely unused, kept Pylon's Alert,
KeyboardAvoidingView, and Modal.

GitActionProgressOverlay takes upstream's version wholesale, which is
correct here: it already carries #8399's glass restyle, and Pylon's only
divergence was the error-phase palette, which upstream expresses identically in
the new adaptive classes. The default merge had produced a hybrid whose
shadowColor was computed and never used — caught by lint, not by tests.

The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts.
All 65 tokens were compared before resolving: value-identical, so no Pylon
palette customisation is lost.

Second commit: the part upstream could not do

The adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
at error severity. Upstream converted its own files; three Pylon files it has
never seen still violated it 24 times, so this could not land green.
SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx are
Pylon-only, and the single ThreadFeed.tsx violation sits on a Pylon-added line
(upstream's copy of that file has no dark:/light: utilities at all).

28 light/dark pairs merged into adaptive tokens; 7 new tokens registered in the
generator. This is colour-preserving rather than lint-silencing — each token is
built from the same Tailwind family, shade, and opacity as the pair it replaced,
and the emitted values were checked numerically against tailwindcss/colors
(e.g. adaptive-rose-800-200oklch(45.5% 0.188 13.697) light,
oklch(89.2% 0.058 10.001) dark, exactly rose-800 and rose-200). Pairs merge
only where both halves are on one line, so a light-only utility is never
rewritten into an appearance-sensitive token.

Verification

  • vp test run apps/mobile841 passed
  • vp run -F @t3tools/mobile typecheck — clean
  • vp lint apps/mobile/src — clean, including the new rule (24 → 0)
  • vp test run oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts — 25 passed
  • vp run icons:check — "Pylon brand assets are current (40 files)", so no brand
    asset regression from the mark change
  • Generated files are reproducible: hashing generated-uniwind-themes.css,
    -theme-names.json, and -default-theme-variables.json across a regeneration
    gives an identical digest

Not verified

This has had no run on a simulator or device. It is a ~100-file theming change,
so it wants a test-pylon-mobile pass before merge — tests and lint cannot see
a colour that resolves to the wrong thing at runtime.

Claude Opus 5 via Claude Code.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

juliusmarmingeand others added 2 commits August 28, 2026 14:39
Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and its 67 call sites.
Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327).
Taken now rather than deferred because it is a foundation change, not a
cosmetic one. Upstream is entirely on the far side of it, so every later mobile
commit Pylon adopts would otherwise need hand-translating back into
useThemeColor - a tax paid repeatedly with the benefit never arriving.
Adaptation, six conflicts:
T3Wordmark stays deleted. Pylon removed it when the fork was established and
renders PylonMark instead; adopting upstream's converted version would have
restored a T3 brand component. PylonMark gains the same withUniwind
colorClassName treatment upstream applied to its own wordmark, so Pylon's mark
follows the new idiom without inheriting T3 artwork. Its mask geometry keeps
literal fills, since only the visible silhouette is themed.
CompactBrandTitle and HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon", which resolves
the same variable.
ThreadComposer's two import conflicts were resolved by what the merged file
actually references rather than by picking a side, dropping StyleSheet and
themeColorWithAlpha as genuinely unused and keeping Pylon's Alert,
KeyboardAvoidingView and Modal.
GitActionProgressOverlay takes upstream's version wholesale. It already carries
upstream #8399's glass restyle, and Pylon's only divergence there was the
error-phase palette, which upstream expresses identically in the new adaptive
classes. The default merge left a hybrid whose shadowColor was computed and
never used.
The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts. All 65
tokens were compared first and are value-identical, so no Pylon palette
customisation is lost.
The Uniwind adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
which rejects dark:/light: utilities because they do not follow registered custom
themes. Upstream converted its own files; three Pylon files it has never seen
still violated it, at error severity, so the adoption could not land green:
- SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx exist
only in Pylon;
- the one violation in ThreadFeed.tsx is on a Pylon-added line, confirmed by
upstream's copy of that file containing no dark:/light: utilities at all.
Merges 28 light/dark utility pairs into adaptive tokens across those three
files. Twelve of the tokens needed already existed; seven did not and are
registered in the generator's ADAPTIVE_COLORS map:
amber-100-500-a18, amber-900-200, neutral-100-a95-900-a95, neutral-800-200,
rose-800-200, sky-100-500-a18, and sky-800-200.
The conversion is colour-preserving, not merely lint-silencing. Each new token
is defined from the same Tailwind family, shade, and opacity as the pair it
replaces, and the emitted values were checked against tailwindcss/colors: for
example adaptive-rose-800-200 resolves to oklch(45.5% 0.188 13.697) in light and
oklch(89.2% 0.058 10.001) in dark, which are rose-800 and rose-200 exactly.
Pairs are merged only where both halves appear on the same line, so a light-only
utility is never rewritten into an appearance-sensitive token.
@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 28, 2026
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.2 KiB−117 B (−0.9%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+4 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.3 KiB−121 B (−1.9%)7.8 KiB
CodexLive turn WebSocket decoded55.5 KiB54.7 KiB−822 B (−1.4%)66.4 KiB
CodexLive turn messages990 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB−5 B (−0.0%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−5 B (−0.1%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB0 B (0.0%)7.8 KiB
ClaudeLive turn WebSocket decoded56.3 KiB56.3 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages990 (0.0%)21

Baseline: 3ed6520 · PR result: e3b4bd2 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…alette checks
Review follow-ups on the Uniwind adoption. All three are upstream regressions the
refactor introduced, not Pylon adaptations.
The thread composer lost its shadow colour. Upstream replaced an explicit
shadowColor plus 0.12/0.35 opacity with shadowOpacity: 1 and a bare
shadow-adaptive-black-a15-a35 class. Compiling that class with the repo's own
tailwindcss 4.3 shows it emits only --tw-shadow-color and no box-shadow, and
Uniwind's native store only maps a style when result.boxShadow is defined, so the
class reached React Native as nothing at all. With no shadowColor and full
opacity, iOS falls back to opaque black. The colour is set explicitly again, the
way GitActionProgressOverlay does it in this same refactor. Of the two
shadow-adaptive usages, the other one is paired with shadow-lg and is fine.
PendingUserInputCard drifted a shade pair: text-neutral-700 dark:text-neutral-200
became adaptive-neutral-600-300, lighter in light mode and darker in dark mode,
so contrast dropped in both. Every other conversion in that file is 1:1.
Registers adaptive-neutral-700-200 and uses it.
The readability test stopped covering the default palette when its loop narrowed
to BUILT_IN_THEME_IDS. Restoring MOBILE_THEME_IDS does not fix it: t3-code is
absent from BUILT_IN_THEMES, so getMobileThemeVariables falls back to
BUILT_IN_THEMES[0] and would check t3-chat twice, and it does not typecheck. The
default palette is asserted through readDefaultMobileThemeVariables, which reads
global.css. Verified by making the default placeholder unreadable: the check now
fails at a 1.02 contrast ratio, where before it passed.
@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Independent review pass found seven issues, all upstream regressions rather than
Pylon adaptations. Three fixed in a23ada2e5, four still open.

Fixed — the thread composer rendered an opaque black shadow.

Upstream replaced an explicit shadowColor plus 0.12/0.35 opacity with
shadowOpacity: 1 and a bare shadow-adaptive-black-a15-a35 class. I compiled
that class with the repo's own tailwindcss 4.3:

.shadow-adaptive-black-a15-a35 { --tw-shadow-color: … } ← no box-shadow
.shadow-lg { …; box-shadow: … } ← real box-shadow

Uniwind's native store only maps a style when result.boxShadow is defined
(core/native/store.js:126), so the class reached React Native as nothing.
With no shadowColor and full opacity, iOS falls back to opaque black on every
thread composer and the new-task draft screen. Colour is set explicitly again,
matching how GitActionProgressOverlay does it in this same refactor. The other
shadow-adaptive-* usage is paired with shadow-lg and is fine.

Fixed — a shade pair drifted.text-neutral-700 dark:text-neutral-200 became
adaptive-neutral-600-300: lighter in light, darker in dark, so contrast dropped
both ways. Every other conversion in that file is 1:1. Registered
adaptive-neutral-700-200.

Fixed — the default palette lost readability coverage. Note that the obvious
fix does not work: restoring MOBILE_THEME_IDS fails to typecheck and would
silently check t3-chat twice, because t3-code is absent from
BUILT_IN_THEMES and getMobileThemeVariables falls back to
BUILT_IN_THEMES[0]. I tried that first and my probe caught it. Now asserted
through readDefaultMobileThemeVariables, which reads global.css — verified by
making the default placeholder unreadable and watching the check fail at a 1.02
contrast ratio.

Still open — flagged, not fixed, because they want the device pass this PR
already needs:

  • ComposerToolbar.tsxshadow-lg now supplies a real boxShadow, so the
    surviving shadowOpacity: props.disabled ? 0 : 1 may no longer remove the
    shadow on disabled toolbar buttons.
  • App.tsx / LoadingScreen.tsxStatusBar backgroundColor removed while
    translucent stays true, so the Android status-bar scrim is gone and
    --color-status-bar has no consumer.
  • ComposerToolbar.tsx — toolbar fades interpolate to transparent rather than
    the surface colour at alpha 0, which reintroduces the grey-smudge artifact the
    removed themeColorWithAlpha(surface, 0) existed to avoid.
  • SourceControlIcon.tsx — the GitHub glyph lost its #24292F default fill.
    Latent: the only caller passes colorClassName.

@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Mobile pass — partial

Ran this branch's bundle on a booted iPhone 17 Pro (iOS 26.3) against a
disposable backend seeded from real data. No native rebuild: the only
native-adjacent change is a Metro config line, so an installed PylonDev.app
plus expo start --clear was the right launch path.

What the refactor gets right, confirmed on device. The compiled Uniwind
themes render correctly across the Home list and the glass sheet — text
hierarchy, muted secondary labels, separators, the blue "Working" accent, the
settled section, and the sheet's own surface and shadow all resolve.

Home screen on the Uniwind branch

Choose project glass sheet

The environment connected rather than rendering an empty disconnected state —
the third settled row picks up the Pylon environment icon after pairing.

What I could not verify, and why. The three things most worth a device pass
are still unverified: the composer shadow fix in a23ada2e5, the amber warning
row, and the three converted Pylon-only screens. All of them live behind a thread
or a project row, and neither is exposed to accessibility. A runtime snapshot
lists only chrome — compose, filter, Open settings, search, the settled
toggle — and wait_for_ui for any cell times out with none present. The
skill's guidance is to report that as an accessibility blocker rather than guess
coordinates, so I stopped there.

Worth noting on its own: thread and project rows having no accessible role is a
VoiceOver problem independent of this PR, and I did not confirm whether it
predates the refactor.

So the four visual findings from the review — disabled-button shadow, Android
status-bar scrim, toolbar fade smudge, GitHub glyph fill — remain open, and the
composer fix remains verified only by the Tailwind/Uniwind mechanism analysis and
not by eye.

…tatus bar scrim
Two more review follow-ups on the Uniwind adoption.
Disabled toolbar buttons kept their shadow. The refactor moved the shadow to a
shadow-lg class, which Uniwind maps to React Native's boxShadow, and boxShadow
wins over the legacy shadowOpacity the style still set to 0 when disabled. The
class is now dropped for disabled buttons and the dead shadowOpacity line is
gone.
The Android status bar lost its scrim. backgroundColor was removed while
translucent stayed true, so content draws under the bar with nothing behind it,
and --color-status-bar was left with no consumer at all. Restores the same
variable origin/pylon read, now through useUniwindTheme. App.tsx joins the lint
rule's interop allowlist because StatusBar's Android backgroundColor is a native
prop that cannot take a className.
Two findings from the same review are deliberately not fixed. The toolbar fade
ramps to transparent black rather than the surface at alpha 0; the obvious
class-level fix compiles to color-mix(..., 0%, transparent), which is not clearly
the surface hue either, and I cannot tell the two apart without a device. The
GitHub glyph lost its #24292F default, which is latent because the only caller
passes colorClassName, and hardcoding a brand hex back would fight the direction
of the refactor. Both are recorded on the pull request.
Pylon's 92ec009 landed after this branch and pursues the same goal by another
route: it replaces the status indicators' dark:/light: pairs with semantic
warning tokens and deletes the raw iconColor/iconBackground fields entirely.
That is the later Pylon decision, so threadPresentation and both thread-list
files take Pylon's version wholesale rather than this branch's adaptive-token
conversion of the same lines.
Integrating them needed four things. Pylon's files still imported useThemeColor,
which this branch deletes, so 25 call sites across the two thread-list files move
to useUniwindTheme. Pylon's nine remaining dark:/light: pairs are converted to
adaptive tokens the generator already defines, because the new lint rule rejects
them at error severity. The generated theme files are regenerated to pick up
Pylon's five new variables, and the theme test's hardcoded variable count moves
from 65 to 70 to match.
mobileTheme.test.ts keeps this branch's structure rather than Pylon's: Pylon's
version calls getMobileThemeVariables with DEFAULT_MOBILE_THEME_ID, which does
not typecheck here and silently resolves to BUILT_IN_THEMES[0] anyway.
#131 landed on pylon with the mobile warning colour split this branch had already
converted to adaptive tokens. Keeps both: the destructive/warning split stays,
expressed as adaptive-rose-600-400 and adaptive-amber-600-400 so the new lint
rule accepts it. Registers adaptive-amber-600-400, which did not exist yet.
@rynfar
rynfar merged commit eafef85 into pylonAug 29, 2026
15 checks passed
@rynfar
rynfar deleted the upstream/2026-08-28-mobile-uniwind-themes branch August 29, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXLvouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rynfar@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(mobile): compile semantic themes for Uniwind - #138

Merged
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes
Aug 29, 2026
Merged

refactor(mobile): compile semantic themes for Uniwind#138
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes

Conversation

@rynfar

@rynfarrynfar commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and all 67 of its call
sites.

Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327). 110 files.

Why now rather than deferred

This is a foundation change, not a cosmetic one, and deferring it was the
strictly worst option. Upstream is entirely on the far side of it — 67 call
sites before, 0 after — so every later mobile commit Pylon adopts would need
hand-translating back into useThemeColor, a tax paid repeatedly with the
benefit never arriving. It was already happening inside this same batch: #7327
rewrites GitActionProgressOverlay.tsx, the exact file upstream #8399 touches.

Adaptation — six conflicts

T3Wordmark stays deleted. Pylon removed it in 2929153bd feat: establish Pylon fork and renders PylonMark instead. Adopting upstream's converted
version would have restored a T3 brand component, which AGENTS.md forbids.
PylonMark instead gains the same withUniwind / colorClassName treatment
upstream applied to its own wordmark, so Pylon's mark follows the new idiom
without inheriting T3 artwork. Its mask paths keep literal fills, since only the
visible silhouette is themed. Verified T3Wordmark has no remaining references
anywhere in apps/mobile/src.

CompactBrandTitle / HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon". Confirmed
those resolve the same variable — the accent- prefix is Uniwind's convention,
not an accent colour, so accent-icon--color-icon.

ThreadComposer had two import conflicts, resolved by what the merged file
actually references rather than by picking a side: dropped StyleSheet and
themeColorWithAlpha as genuinely unused, kept Pylon's Alert,
KeyboardAvoidingView, and Modal.

GitActionProgressOverlay takes upstream's version wholesale, which is
correct here: it already carries #8399's glass restyle, and Pylon's only
divergence was the error-phase palette, which upstream expresses identically in
the new adaptive classes. The default merge had produced a hybrid whose
shadowColor was computed and never used — caught by lint, not by tests.

The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts.
All 65 tokens were compared before resolving: value-identical, so no Pylon
palette customisation is lost.

Second commit: the part upstream could not do

The adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
at error severity. Upstream converted its own files; three Pylon files it has
never seen still violated it 24 times, so this could not land green.
SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx are
Pylon-only, and the single ThreadFeed.tsx violation sits on a Pylon-added line
(upstream's copy of that file has no dark:/light: utilities at all).

28 light/dark pairs merged into adaptive tokens; 7 new tokens registered in the
generator. This is colour-preserving rather than lint-silencing — each token is
built from the same Tailwind family, shade, and opacity as the pair it replaced,
and the emitted values were checked numerically against tailwindcss/colors
(e.g. adaptive-rose-800-200oklch(45.5% 0.188 13.697) light,
oklch(89.2% 0.058 10.001) dark, exactly rose-800 and rose-200). Pairs merge
only where both halves are on one line, so a light-only utility is never
rewritten into an appearance-sensitive token.

Verification

  • vp test run apps/mobile841 passed
  • vp run -F @t3tools/mobile typecheck — clean
  • vp lint apps/mobile/src — clean, including the new rule (24 → 0)
  • vp test run oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts — 25 passed
  • vp run icons:check — "Pylon brand assets are current (40 files)", so no brand
    asset regression from the mark change
  • Generated files are reproducible: hashing generated-uniwind-themes.css,
    -theme-names.json, and -default-theme-variables.json across a regeneration
    gives an identical digest

Not verified

This has had no run on a simulator or device. It is a ~100-file theming change,
so it wants a test-pylon-mobile pass before merge — tests and lint cannot see
a colour that resolves to the wrong thing at runtime.

Claude Opus 5 via Claude Code.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

juliusmarmingeand others added 2 commits August 28, 2026 14:39
Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and its 67 call sites.
Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327).
Taken now rather than deferred because it is a foundation change, not a
cosmetic one. Upstream is entirely on the far side of it, so every later mobile
commit Pylon adopts would otherwise need hand-translating back into
useThemeColor - a tax paid repeatedly with the benefit never arriving.
Adaptation, six conflicts:
T3Wordmark stays deleted. Pylon removed it when the fork was established and
renders PylonMark instead; adopting upstream's converted version would have
restored a T3 brand component. PylonMark gains the same withUniwind
colorClassName treatment upstream applied to its own wordmark, so Pylon's mark
follows the new idiom without inheriting T3 artwork. Its mask geometry keeps
literal fills, since only the visible silhouette is themed.
CompactBrandTitle and HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon", which resolves
the same variable.
ThreadComposer's two import conflicts were resolved by what the merged file
actually references rather than by picking a side, dropping StyleSheet and
themeColorWithAlpha as genuinely unused and keeping Pylon's Alert,
KeyboardAvoidingView and Modal.
GitActionProgressOverlay takes upstream's version wholesale. It already carries
upstream #8399's glass restyle, and Pylon's only divergence there was the
error-phase palette, which upstream expresses identically in the new adaptive
classes. The default merge left a hybrid whose shadowColor was computed and
never used.
The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts. All 65
tokens were compared first and are value-identical, so no Pylon palette
customisation is lost.
The Uniwind adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
which rejects dark:/light: utilities because they do not follow registered custom
themes. Upstream converted its own files; three Pylon files it has never seen
still violated it, at error severity, so the adoption could not land green:
- SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx exist
only in Pylon;
- the one violation in ThreadFeed.tsx is on a Pylon-added line, confirmed by
upstream's copy of that file containing no dark:/light: utilities at all.
Merges 28 light/dark utility pairs into adaptive tokens across those three
files. Twelve of the tokens needed already existed; seven did not and are
registered in the generator's ADAPTIVE_COLORS map:
amber-100-500-a18, amber-900-200, neutral-100-a95-900-a95, neutral-800-200,
rose-800-200, sky-100-500-a18, and sky-800-200.
The conversion is colour-preserving, not merely lint-silencing. Each new token
is defined from the same Tailwind family, shade, and opacity as the pair it
replaces, and the emitted values were checked against tailwindcss/colors: for
example adaptive-rose-800-200 resolves to oklch(45.5% 0.188 13.697) in light and
oklch(89.2% 0.058 10.001) in dark, which are rose-800 and rose-200 exactly.
Pairs are merged only where both halves appear on the same line, so a light-only
utility is never rewritten into an appearance-sensitive token.
@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 28, 2026
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.2 KiB−117 B (−0.9%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+4 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.3 KiB−121 B (−1.9%)7.8 KiB
CodexLive turn WebSocket decoded55.5 KiB54.7 KiB−822 B (−1.4%)66.4 KiB
CodexLive turn messages990 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB−5 B (−0.0%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−5 B (−0.1%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB0 B (0.0%)7.8 KiB
ClaudeLive turn WebSocket decoded56.3 KiB56.3 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages990 (0.0%)21

Baseline: 3ed6520 · PR result: e3b4bd2 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…alette checks
Review follow-ups on the Uniwind adoption. All three are upstream regressions the
refactor introduced, not Pylon adaptations.
The thread composer lost its shadow colour. Upstream replaced an explicit
shadowColor plus 0.12/0.35 opacity with shadowOpacity: 1 and a bare
shadow-adaptive-black-a15-a35 class. Compiling that class with the repo's own
tailwindcss 4.3 shows it emits only --tw-shadow-color and no box-shadow, and
Uniwind's native store only maps a style when result.boxShadow is defined, so the
class reached React Native as nothing at all. With no shadowColor and full
opacity, iOS falls back to opaque black. The colour is set explicitly again, the
way GitActionProgressOverlay does it in this same refactor. Of the two
shadow-adaptive usages, the other one is paired with shadow-lg and is fine.
PendingUserInputCard drifted a shade pair: text-neutral-700 dark:text-neutral-200
became adaptive-neutral-600-300, lighter in light mode and darker in dark mode,
so contrast dropped in both. Every other conversion in that file is 1:1.
Registers adaptive-neutral-700-200 and uses it.
The readability test stopped covering the default palette when its loop narrowed
to BUILT_IN_THEME_IDS. Restoring MOBILE_THEME_IDS does not fix it: t3-code is
absent from BUILT_IN_THEMES, so getMobileThemeVariables falls back to
BUILT_IN_THEMES[0] and would check t3-chat twice, and it does not typecheck. The
default palette is asserted through readDefaultMobileThemeVariables, which reads
global.css. Verified by making the default placeholder unreadable: the check now
fails at a 1.02 contrast ratio, where before it passed.
@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Independent review pass found seven issues, all upstream regressions rather than
Pylon adaptations. Three fixed in a23ada2e5, four still open.

Fixed — the thread composer rendered an opaque black shadow.

Upstream replaced an explicit shadowColor plus 0.12/0.35 opacity with
shadowOpacity: 1 and a bare shadow-adaptive-black-a15-a35 class. I compiled
that class with the repo's own tailwindcss 4.3:

.shadow-adaptive-black-a15-a35 { --tw-shadow-color: … } ← no box-shadow
.shadow-lg { …; box-shadow: … } ← real box-shadow

Uniwind's native store only maps a style when result.boxShadow is defined
(core/native/store.js:126), so the class reached React Native as nothing.
With no shadowColor and full opacity, iOS falls back to opaque black on every
thread composer and the new-task draft screen. Colour is set explicitly again,
matching how GitActionProgressOverlay does it in this same refactor. The other
shadow-adaptive-* usage is paired with shadow-lg and is fine.

Fixed — a shade pair drifted.text-neutral-700 dark:text-neutral-200 became
adaptive-neutral-600-300: lighter in light, darker in dark, so contrast dropped
both ways. Every other conversion in that file is 1:1. Registered
adaptive-neutral-700-200.

Fixed — the default palette lost readability coverage. Note that the obvious
fix does not work: restoring MOBILE_THEME_IDS fails to typecheck and would
silently check t3-chat twice, because t3-code is absent from
BUILT_IN_THEMES and getMobileThemeVariables falls back to
BUILT_IN_THEMES[0]. I tried that first and my probe caught it. Now asserted
through readDefaultMobileThemeVariables, which reads global.css — verified by
making the default placeholder unreadable and watching the check fail at a 1.02
contrast ratio.

Still open — flagged, not fixed, because they want the device pass this PR
already needs:

  • ComposerToolbar.tsxshadow-lg now supplies a real boxShadow, so the
    surviving shadowOpacity: props.disabled ? 0 : 1 may no longer remove the
    shadow on disabled toolbar buttons.
  • App.tsx / LoadingScreen.tsxStatusBar backgroundColor removed while
    translucent stays true, so the Android status-bar scrim is gone and
    --color-status-bar has no consumer.
  • ComposerToolbar.tsx — toolbar fades interpolate to transparent rather than
    the surface colour at alpha 0, which reintroduces the grey-smudge artifact the
    removed themeColorWithAlpha(surface, 0) existed to avoid.
  • SourceControlIcon.tsx — the GitHub glyph lost its #24292F default fill.
    Latent: the only caller passes colorClassName.

@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Mobile pass — partial

Ran this branch's bundle on a booted iPhone 17 Pro (iOS 26.3) against a
disposable backend seeded from real data. No native rebuild: the only
native-adjacent change is a Metro config line, so an installed PylonDev.app
plus expo start --clear was the right launch path.

What the refactor gets right, confirmed on device. The compiled Uniwind
themes render correctly across the Home list and the glass sheet — text
hierarchy, muted secondary labels, separators, the blue "Working" accent, the
settled section, and the sheet's own surface and shadow all resolve.

Home screen on the Uniwind branch

Choose project glass sheet

The environment connected rather than rendering an empty disconnected state —
the third settled row picks up the Pylon environment icon after pairing.

What I could not verify, and why. The three things most worth a device pass
are still unverified: the composer shadow fix in a23ada2e5, the amber warning
row, and the three converted Pylon-only screens. All of them live behind a thread
or a project row, and neither is exposed to accessibility. A runtime snapshot
lists only chrome — compose, filter, Open settings, search, the settled
toggle — and wait_for_ui for any cell times out with none present. The
skill's guidance is to report that as an accessibility blocker rather than guess
coordinates, so I stopped there.

Worth noting on its own: thread and project rows having no accessible role is a
VoiceOver problem independent of this PR, and I did not confirm whether it
predates the refactor.

So the four visual findings from the review — disabled-button shadow, Android
status-bar scrim, toolbar fade smudge, GitHub glyph fill — remain open, and the
composer fix remains verified only by the Tailwind/Uniwind mechanism analysis and
not by eye.

…tatus bar scrim
Two more review follow-ups on the Uniwind adoption.
Disabled toolbar buttons kept their shadow. The refactor moved the shadow to a
shadow-lg class, which Uniwind maps to React Native's boxShadow, and boxShadow
wins over the legacy shadowOpacity the style still set to 0 when disabled. The
class is now dropped for disabled buttons and the dead shadowOpacity line is
gone.
The Android status bar lost its scrim. backgroundColor was removed while
translucent stayed true, so content draws under the bar with nothing behind it,
and --color-status-bar was left with no consumer at all. Restores the same
variable origin/pylon read, now through useUniwindTheme. App.tsx joins the lint
rule's interop allowlist because StatusBar's Android backgroundColor is a native
prop that cannot take a className.
Two findings from the same review are deliberately not fixed. The toolbar fade
ramps to transparent black rather than the surface at alpha 0; the obvious
class-level fix compiles to color-mix(..., 0%, transparent), which is not clearly
the surface hue either, and I cannot tell the two apart without a device. The
GitHub glyph lost its #24292F default, which is latent because the only caller
passes colorClassName, and hardcoding a brand hex back would fight the direction
of the refactor. Both are recorded on the pull request.
Pylon's 92ec009 landed after this branch and pursues the same goal by another
route: it replaces the status indicators' dark:/light: pairs with semantic
warning tokens and deletes the raw iconColor/iconBackground fields entirely.
That is the later Pylon decision, so threadPresentation and both thread-list
files take Pylon's version wholesale rather than this branch's adaptive-token
conversion of the same lines.
Integrating them needed four things. Pylon's files still imported useThemeColor,
which this branch deletes, so 25 call sites across the two thread-list files move
to useUniwindTheme. Pylon's nine remaining dark:/light: pairs are converted to
adaptive tokens the generator already defines, because the new lint rule rejects
them at error severity. The generated theme files are regenerated to pick up
Pylon's five new variables, and the theme test's hardcoded variable count moves
from 65 to 70 to match.
mobileTheme.test.ts keeps this branch's structure rather than Pylon's: Pylon's
version calls getMobileThemeVariables with DEFAULT_MOBILE_THEME_ID, which does
not typecheck here and silently resolves to BUILT_IN_THEMES[0] anyway.
#131 landed on pylon with the mobile warning colour split this branch had already
converted to adaptive tokens. Keeps both: the destructive/warning split stays,
expressed as adaptive-rose-600-400 and adaptive-amber-600-400 so the new lint
rule accepts it. Registers adaptive-amber-600-400, which did not exist yet.
@rynfar
rynfar merged commit eafef85 into pylonAug 29, 2026
15 checks passed
@rynfar
rynfar deleted the upstream/2026-08-28-mobile-uniwind-themes branch August 29, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXLvouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rynfar@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

refactor(mobile): compile semantic themes for Uniwind - #138

Merged
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes
Aug 29, 2026
Merged

refactor(mobile): compile semantic themes for Uniwind#138
rynfar merged 6 commits into
pylonfrom
upstream/2026-08-28-mobile-uniwind-themes

Conversation

@rynfar

@rynfarrynfar commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and all 67 of its call
sites.

Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327). 110 files.

Why now rather than deferred

This is a foundation change, not a cosmetic one, and deferring it was the
strictly worst option. Upstream is entirely on the far side of it — 67 call
sites before, 0 after — so every later mobile commit Pylon adopts would need
hand-translating back into useThemeColor, a tax paid repeatedly with the
benefit never arriving. It was already happening inside this same batch: #7327
rewrites GitActionProgressOverlay.tsx, the exact file upstream #8399 touches.

Adaptation — six conflicts

T3Wordmark stays deleted. Pylon removed it in 2929153bd feat: establish Pylon fork and renders PylonMark instead. Adopting upstream's converted
version would have restored a T3 brand component, which AGENTS.md forbids.
PylonMark instead gains the same withUniwind / colorClassName treatment
upstream applied to its own wordmark, so Pylon's mark follows the new idiom
without inheriting T3 artwork. Its mask paths keep literal fills, since only the
visible silhouette is themed. Verified T3Wordmark has no remaining references
anywhere in apps/mobile/src.

CompactBrandTitle / HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon". Confirmed
those resolve the same variable — the accent- prefix is Uniwind's convention,
not an accent colour, so accent-icon--color-icon.

ThreadComposer had two import conflicts, resolved by what the merged file
actually references rather than by picking a side: dropped StyleSheet and
themeColorWithAlpha as genuinely unused, kept Pylon's Alert,
KeyboardAvoidingView, and Modal.

GitActionProgressOverlay takes upstream's version wholesale, which is
correct here: it already carries #8399's glass restyle, and Pylon's only
divergence was the error-phase palette, which upstream expresses identically in
the new adaptive classes. The default merge had produced a hybrid whose
shadowColor was computed and never used — caught by lint, not by tests.

The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts.
All 65 tokens were compared before resolving: value-identical, so no Pylon
palette customisation is lost.

Second commit: the part upstream could not do

The adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
at error severity. Upstream converted its own files; three Pylon files it has
never seen still violated it 24 times, so this could not land green.
SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx are
Pylon-only, and the single ThreadFeed.tsx violation sits on a Pylon-added line
(upstream's copy of that file has no dark:/light: utilities at all).

28 light/dark pairs merged into adaptive tokens; 7 new tokens registered in the
generator. This is colour-preserving rather than lint-silencing — each token is
built from the same Tailwind family, shade, and opacity as the pair it replaced,
and the emitted values were checked numerically against tailwindcss/colors
(e.g. adaptive-rose-800-200oklch(45.5% 0.188 13.697) light,
oklch(89.2% 0.058 10.001) dark, exactly rose-800 and rose-200). Pairs merge
only where both halves are on one line, so a light-only utility is never
rewritten into an appearance-sensitive token.

Verification

  • vp test run apps/mobile841 passed
  • vp run -F @t3tools/mobile typecheck — clean
  • vp lint apps/mobile/src — clean, including the new rule (24 → 0)
  • vp test run oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.test.ts — 25 passed
  • vp run icons:check — "Pylon brand assets are current (40 files)", so no brand
    asset regression from the mark change
  • Generated files are reproducible: hashing generated-uniwind-themes.css,
    -theme-names.json, and -default-theme-variables.json across a regeneration
    gives an identical digest

Not verified

This has had no run on a simulator or device. It is a ~100-file theming change,
so it wants a test-pylon-mobile pass before merge — tests and lint cannot see
a colour that resolves to the wrong thing at runtime.

Claude Opus 5 via Claude Code.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

juliusmarmingeand others added 2 commits August 28, 2026 14:39
Mobile theming read colours through a useThemeColor hook at every call site.
This compiles the semantic palettes into Uniwind theme CSS and moves components
onto class-based colours, deleting useThemeColor and its 67 call sites.
Adopted from T3 Code 018d7f2775daabd2ef07898af29586915a0b7f67
(pingdotgg/t3code#7327).
Taken now rather than deferred because it is a foundation change, not a
cosmetic one. Upstream is entirely on the far side of it, so every later mobile
commit Pylon adopts would otherwise need hand-translating back into
useThemeColor - a tax paid repeatedly with the benefit never arriving.
Adaptation, six conflicts:
T3Wordmark stays deleted. Pylon removed it when the fork was established and
renders PylonMark instead; adopting upstream's converted version would have
restored a T3 brand component. PylonMark gains the same withUniwind
colorClassName treatment upstream applied to its own wordmark, so Pylon's mark
follows the new idiom without inheriting T3 artwork. Its mask geometry keeps
literal fills, since only the visible silhouette is themed.
CompactBrandTitle and HomeHeader keep PylonMark and move from
useThemeColor("--color-icon") to colorClassName="accent-icon", which resolves
the same variable.
ThreadComposer's two import conflicts were resolved by what the merged file
actually references rather than by picking a side, dropping StyleSheet and
themeColorWithAlpha as genuinely unused and keeping Pylon's Alert,
KeyboardAvoidingView and Modal.
GitActionProgressOverlay takes upstream's version wholesale. It already carries
upstream #8399's glass restyle, and Pylon's only divergence there was the
error-phase palette, which upstream expresses identically in the new adaptive
classes. The default merge left a hybrid whose shadowColor was computed and
never used.
The generated default-theme JSON replaces Pylon's mobileDefaultTheme.ts. All 65
tokens were compared first and are value-identical, so no Pylon palette
customisation is lost.
The Uniwind adoption ships a new lint rule, no-mobile-uniwind-theme-escape-hatches,
which rejects dark:/light: utilities because they do not follow registered custom
themes. Upstream converted its own files; three Pylon files it has never seen
still violated it, at error severity, so the adoption could not land green:
- SessionPresentationSurface.tsx and PendingSessionInteractionCard.tsx exist
only in Pylon;
- the one violation in ThreadFeed.tsx is on a Pylon-added line, confirmed by
upstream's copy of that file containing no dark:/light: utilities at all.
Merges 28 light/dark utility pairs into adaptive tokens across those three
files. Twelve of the tokens needed already existed; seven did not and are
registered in the generator's ADAPTIVE_COLORS map:
amber-100-500-a18, amber-900-200, neutral-100-a95-900-a95, neutral-800-200,
rose-800-200, sky-100-500-a18, and sky-800-200.
The conversion is colour-preserving, not merely lint-silencing. Each new token
is defined from the same Tailwind family, shade, and opacity as the pair it
replaces, and the emitted values were checked against tailwindcss/colors: for
example adaptive-rose-800-200 resolves to oklch(45.5% 0.188 13.697) in light and
oklch(89.2% 0.058 10.001) in dark, which are rose-800 and rose-200 exactly.
Pairs are merged only where both halves appear on the same line, so a light-only
utility is never rewritten into an appearance-sensitive token.
@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 28, 2026
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.2 KiB−117 B (−0.9%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+4 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.3 KiB−121 B (−1.9%)7.8 KiB
CodexLive turn WebSocket decoded55.5 KiB54.7 KiB−822 B (−1.4%)66.4 KiB
CodexLive turn messages990 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB−5 B (−0.0%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−5 B (−0.1%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB0 B (0.0%)7.8 KiB
ClaudeLive turn WebSocket decoded56.3 KiB56.3 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages990 (0.0%)21

Baseline: 3ed6520 · PR result: e3b4bd2 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

…alette checks
Review follow-ups on the Uniwind adoption. All three are upstream regressions the
refactor introduced, not Pylon adaptations.
The thread composer lost its shadow colour. Upstream replaced an explicit
shadowColor plus 0.12/0.35 opacity with shadowOpacity: 1 and a bare
shadow-adaptive-black-a15-a35 class. Compiling that class with the repo's own
tailwindcss 4.3 shows it emits only --tw-shadow-color and no box-shadow, and
Uniwind's native store only maps a style when result.boxShadow is defined, so the
class reached React Native as nothing at all. With no shadowColor and full
opacity, iOS falls back to opaque black. The colour is set explicitly again, the
way GitActionProgressOverlay does it in this same refactor. Of the two
shadow-adaptive usages, the other one is paired with shadow-lg and is fine.
PendingUserInputCard drifted a shade pair: text-neutral-700 dark:text-neutral-200
became adaptive-neutral-600-300, lighter in light mode and darker in dark mode,
so contrast dropped in both. Every other conversion in that file is 1:1.
Registers adaptive-neutral-700-200 and uses it.
The readability test stopped covering the default palette when its loop narrowed
to BUILT_IN_THEME_IDS. Restoring MOBILE_THEME_IDS does not fix it: t3-code is
absent from BUILT_IN_THEMES, so getMobileThemeVariables falls back to
BUILT_IN_THEMES[0] and would check t3-chat twice, and it does not typecheck. The
default palette is asserted through readDefaultMobileThemeVariables, which reads
global.css. Verified by making the default placeholder unreadable: the check now
fails at a 1.02 contrast ratio, where before it passed.
@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Independent review pass found seven issues, all upstream regressions rather than
Pylon adaptations. Three fixed in a23ada2e5, four still open.

Fixed — the thread composer rendered an opaque black shadow.

Upstream replaced an explicit shadowColor plus 0.12/0.35 opacity with
shadowOpacity: 1 and a bare shadow-adaptive-black-a15-a35 class. I compiled
that class with the repo's own tailwindcss 4.3:

.shadow-adaptive-black-a15-a35 { --tw-shadow-color: … } ← no box-shadow
.shadow-lg { …; box-shadow: … } ← real box-shadow

Uniwind's native store only maps a style when result.boxShadow is defined
(core/native/store.js:126), so the class reached React Native as nothing.
With no shadowColor and full opacity, iOS falls back to opaque black on every
thread composer and the new-task draft screen. Colour is set explicitly again,
matching how GitActionProgressOverlay does it in this same refactor. The other
shadow-adaptive-* usage is paired with shadow-lg and is fine.

Fixed — a shade pair drifted.text-neutral-700 dark:text-neutral-200 became
adaptive-neutral-600-300: lighter in light, darker in dark, so contrast dropped
both ways. Every other conversion in that file is 1:1. Registered
adaptive-neutral-700-200.

Fixed — the default palette lost readability coverage. Note that the obvious
fix does not work: restoring MOBILE_THEME_IDS fails to typecheck and would
silently check t3-chat twice, because t3-code is absent from
BUILT_IN_THEMES and getMobileThemeVariables falls back to
BUILT_IN_THEMES[0]. I tried that first and my probe caught it. Now asserted
through readDefaultMobileThemeVariables, which reads global.css — verified by
making the default placeholder unreadable and watching the check fail at a 1.02
contrast ratio.

Still open — flagged, not fixed, because they want the device pass this PR
already needs:

  • ComposerToolbar.tsxshadow-lg now supplies a real boxShadow, so the
    surviving shadowOpacity: props.disabled ? 0 : 1 may no longer remove the
    shadow on disabled toolbar buttons.
  • App.tsx / LoadingScreen.tsxStatusBar backgroundColor removed while
    translucent stays true, so the Android status-bar scrim is gone and
    --color-status-bar has no consumer.
  • ComposerToolbar.tsx — toolbar fades interpolate to transparent rather than
    the surface colour at alpha 0, which reintroduces the grey-smudge artifact the
    removed themeColorWithAlpha(surface, 0) existed to avoid.
  • SourceControlIcon.tsx — the GitHub glyph lost its #24292F default fill.
    Latent: the only caller passes colorClassName.

@rynfar

Copy link
Copy Markdown
CollaboratorAuthor

Mobile pass — partial

Ran this branch's bundle on a booted iPhone 17 Pro (iOS 26.3) against a
disposable backend seeded from real data. No native rebuild: the only
native-adjacent change is a Metro config line, so an installed PylonDev.app
plus expo start --clear was the right launch path.

What the refactor gets right, confirmed on device. The compiled Uniwind
themes render correctly across the Home list and the glass sheet — text
hierarchy, muted secondary labels, separators, the blue "Working" accent, the
settled section, and the sheet's own surface and shadow all resolve.

Home screen on the Uniwind branch

Choose project glass sheet

The environment connected rather than rendering an empty disconnected state —
the third settled row picks up the Pylon environment icon after pairing.

What I could not verify, and why. The three things most worth a device pass
are still unverified: the composer shadow fix in a23ada2e5, the amber warning
row, and the three converted Pylon-only screens. All of them live behind a thread
or a project row, and neither is exposed to accessibility. A runtime snapshot
lists only chrome — compose, filter, Open settings, search, the settled
toggle — and wait_for_ui for any cell times out with none present. The
skill's guidance is to report that as an accessibility blocker rather than guess
coordinates, so I stopped there.

Worth noting on its own: thread and project rows having no accessible role is a
VoiceOver problem independent of this PR, and I did not confirm whether it
predates the refactor.

So the four visual findings from the review — disabled-button shadow, Android
status-bar scrim, toolbar fade smudge, GitHub glyph fill — remain open, and the
composer fix remains verified only by the Tailwind/Uniwind mechanism analysis and
not by eye.

…tatus bar scrim
Two more review follow-ups on the Uniwind adoption.
Disabled toolbar buttons kept their shadow. The refactor moved the shadow to a
shadow-lg class, which Uniwind maps to React Native's boxShadow, and boxShadow
wins over the legacy shadowOpacity the style still set to 0 when disabled. The
class is now dropped for disabled buttons and the dead shadowOpacity line is
gone.
The Android status bar lost its scrim. backgroundColor was removed while
translucent stayed true, so content draws under the bar with nothing behind it,
and --color-status-bar was left with no consumer at all. Restores the same
variable origin/pylon read, now through useUniwindTheme. App.tsx joins the lint
rule's interop allowlist because StatusBar's Android backgroundColor is a native
prop that cannot take a className.
Two findings from the same review are deliberately not fixed. The toolbar fade
ramps to transparent black rather than the surface at alpha 0; the obvious
class-level fix compiles to color-mix(..., 0%, transparent), which is not clearly
the surface hue either, and I cannot tell the two apart without a device. The
GitHub glyph lost its #24292F default, which is latent because the only caller
passes colorClassName, and hardcoding a brand hex back would fight the direction
of the refactor. Both are recorded on the pull request.
Pylon's 92ec009 landed after this branch and pursues the same goal by another
route: it replaces the status indicators' dark:/light: pairs with semantic
warning tokens and deletes the raw iconColor/iconBackground fields entirely.
That is the later Pylon decision, so threadPresentation and both thread-list
files take Pylon's version wholesale rather than this branch's adaptive-token
conversion of the same lines.
Integrating them needed four things. Pylon's files still imported useThemeColor,
which this branch deletes, so 25 call sites across the two thread-list files move
to useUniwindTheme. Pylon's nine remaining dark:/light: pairs are converted to
adaptive tokens the generator already defines, because the new lint rule rejects
them at error severity. The generated theme files are regenerated to pick up
Pylon's five new variables, and the theme test's hardcoded variable count moves
from 65 to 70 to match.
mobileTheme.test.ts keeps this branch's structure rather than Pylon's: Pylon's
version calls getMobileThemeVariables with DEFAULT_MOBILE_THEME_ID, which does
not typecheck here and silently resolves to BUILT_IN_THEMES[0] anyway.
#131 landed on pylon with the mobile warning colour split this branch had already
converted to adaptive tokens. Keeps both: the destructive/warning split stays,
expressed as adaptive-rose-600-400 and adaptive-amber-600-400 so the new lint
rule accepts it. Registers adaptive-amber-600-400, which did not exist yet.
@rynfar
rynfar merged commit eafef85 into pylonAug 29, 2026
15 checks passed
@rynfar
rynfar deleted the upstream/2026-08-28-mobile-uniwind-themes branch August 29, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXLvouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rynfar@juliusmarminge