Skip to content

Partner logos as bare tappable images, and loading skeletons for Docs/Videos - #6

Open
simonhamp wants to merge 9 commits into
mainfrom
feat/partner-logos-and-loading-skeletons
Open

Partner logos as bare tappable images, and loading skeletons for Docs/Videos#6
simonhamp wants to merge 9 commits into
mainfrom
feat/partner-logos-and-loading-skeletons

Conversation

@simonhamp

Copy link
Copy Markdown
Member

Two related polish passes on the Home, Docs and Videos tabs.

Agency partners: bare tappable logos

The partner cards laid out badly on iOS, and two of the three had no logo at all — they fell back to a lettered monogram because the native <image> renderer can't draw SVG and only Synergi published a raster.

  • Rasterised all three logos from the official SVGs into transparent-background PNGs, with a light and dark variant each, bundled under public/img/partners. Nexcalia and Web Mavens come from the inline SVGs on nativephp.com/consulting; Synergi is regenerated from the SVGs already in the repo, whose previous PNGs had a baked-in white background.
  • Variants are picked server-side via isDark(), so an appearance flip needs a re-render — hence the new AppearanceChanged listener on Home.
  • Dropped the cards, taglines and chrome: at logo size the text was noise, so the section is now a centered column of bare tappable logos, each sized to its own aspect ratio. That removes the iOS layout issue along with the layout.

Bundled local paths work on both platforms (UIImage(contentsOfFile:) on iOS, Coil on Android), which is what let the rasters replace the remote-only URLs.

Docs / Videos: loading skeletons, only when needed

Both screens block in mount() on a network fetch, so a fresh push showed nothing until it returned. Both are now #[Lazy] with a placeholder() skeleton mirroring the real layout — Videos: featured thumb, section header, list rows; Docs: the TOC with its first section expanded — so the frame doesn't shift when content lands. Bones use theme tokens (works in both appearances) and are static, since the renderer has no pulse/shimmer support.

A skeleton only appears when there's actually something to wait for:

  • Videos uses Cache::remember, so a warm cache returns within the frame — publishPlaceholder() bails on Cache::has() and paints the real feed.
  • Docs needed more: DocsIndex::sections() is deliberately network-first, so a warm cache did not make mount fast, and skipping the skeleton on "cache exists" alone would have left the previous screen on display. DocsIndex::fresh() now returns the cached corpus only inside a short window, tracked by a separate marker key so the corpus itself still survives its full 24h as the offline fallback. mount() and publishPlaceholder() branch on the same condition, so they can't disagree.

The window is deliberately short: past it, sections() refetches (and the skeleton covers it), so a docs update lands without waiting out the 24h fallback TTL. Pull-to-refresh still forces a fetch anytime. FRESH_MINUTES is the single knob if you'd rather trade freshness for never seeing the skeleton.

Also removes the $loading prop and its branches from both screens — mount() always ran to completion before the first render, so those "Loading…" states were dead code that #[Lazy] now genuinely fills.

Also

Bumps nativephp/mobile to latest dev-main (c53cae81b61319) to pick up the merged stale-tab-render work. The #[Lazy] / placeholder() API the skeletons rely on is unchanged there.

Testing

  • php artisan view:cache compiles all views, including the two new skeletons.
  • php artisan test: 24 passed, 161 assertions. The one failure (ExampleTest, "No application encryption key") is pre-existing — this checkout has an empty .env — and unrelated.
  • Verified the cache gating directly against a live cache store: cold → skeleton on both screens; warm → no skeleton on either; stale docs → skeleton plus refetch, with the 24h corpus intact as the offline fallback.
  • Pint passes on every file touched. (app/Providers/NativeServiceProvider.php and app/NativeComponents/Concerns/InteractsWithDiscovery.php fail Pint, but both already do so on main.)

Worth a visual check on both platforms that the logo display sizes feel balanced, and that the skeletons line up with the loaded content.

🤖 Generated with Claude Code

simonhamp and others added 9 commits July 25, 2026 12:13
The partner cards laid out badly on iOS and two of the three had no logo
at all, falling back to a lettered monogram, because the native <image>
renderer can't draw SVG and only Synergi published a raster.

Rasterise all three from the official SVGs (headless Chrome — ImageMagick's
SVG delegate is unreliable here) into transparent-background PNGs with a
light and dark variant each, bundled under public/img/partners. Nexcalia
and Web Mavens come from the inline SVGs on nativephp.com/consulting;
Synergi is regenerated from the SVGs already in the repo, since the
previous PNGs had a baked-in white background.

Logos are picked server-side via isDark(), so an appearance flip needs a
re-render — hence the AppearanceChanged listener. Local absolute paths
work on both platforms (UIImage(contentsOfFile:) on iOS, Coil on Android),
which is what let the bundled rasters replace the remote-only URLs.

Drop the cards, taglines and chrome entirely: at logo size the text was
noise, so the section is now a centered column of bare tappable logos,
each sized to its own aspect ratio. That also removes the iOS layout
issue along with the layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both screens block in mount() on a network fetch, so a fresh push showed
nothing until it returned. Mark them #[Lazy] and override placeholder()
with a skeleton that mirrors the real layout (Videos: featured thumb,
section header, list rows; Docs: the TOC with its first section expanded),
so the frame doesn't shift when content lands. Bones are theme tokens, so
they work in both appearances; they're static because the renderer has no
pulse/shimmer support.

Only show a skeleton when there's actually something to wait for:

- Videos uses Cache::remember, so a warm cache returns within the frame —
  publishPlaceholder() bails out on Cache::has() and paints the real feed.
- DocsIndex::sections() is deliberately network-FIRST, so a warm cache did
  NOT make mount fast and skipping the skeleton on "cache exists" alone
  would have left the previous screen on display. DocsIndex::fresh() now
  returns the cached corpus only inside a short window, tracked by a
  separate marker key so the corpus itself still survives its full 24h as
  the offline fallback. mount() and publishPlaceholder() branch on the
  same condition, so they can't disagree.

The window is deliberately short: past it sections() refetches (and the
skeleton covers it), so a docs update lands without waiting out the 24h
fallback TTL. Pull-to-refresh still forces a fetch anytime.

Also removes the $loading prop and its branches from both screens — mount()
always ran to completion before the first render, so those "Loading…"
states were dead code that #[Lazy] now genuinely fills.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up the merged stale-tab-render work (c53cae8 => 1b61319). The
#[Lazy] / placeholder() API the skeletons rely on is unchanged in main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Video thumbnails used fit="1" (.fit / ContentScale.Fit), which letterboxes:
YouTube's hqdefault.jpg is 4:3, the containers are ~16:9, so each image
scaled to fit the height and left a gap down the trailing edge. fit="2"
(.fill / Crop) fills the frame instead, and cropping a 4:3 hqdefault to
16:9 removes exactly the black bars YouTube bakes into it. Both the
featured card and the list rows were affected.

Home's root column carried no background — only the scroll-view inside it
did. The tree reaches the physical screen edge, but the scroll-view's
content is inset by the top safe area, so the status-bar strip fell through
to the host's Color(.systemBackground) (white) and showed as a seam against
our off-white theme background. Every other screen already paints its root;
Home was the outlier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Exiting a remote app cleared the shell's tree outright, so the client UI
blinked out a frame or more before Jump's home republished — a hard cut with
a flash of empty background in between.

The publish path already animates a swap when `navigationPending` is set: it
reclassifies the previous tree as the outgoing layer (iOS) or bumps the
screenKey that drives AnimatedContent (Android). The exit couldn't use any of
that because it destroyed the very frame the animation needs — iOS only
stages an outgoing screen `if wasActive, let oldTree = bridge.currentTree`.

So endSession() gains `keepingLastFrame`: it still suppresses the session and
stops routing events remotely, but leaves the last frame on screen. exitToJump
then stages `slide_from_left` before waking home, and home's republish is
consumed as a navigation — the remote app slides out to the trailing edge as
home enters from the leading one, matching the 3-finger swipe-RIGHT that
triggered it.

Both platforms accept the same transition names (the "slide_forward /
slide_back" list in Android's NativeUIBridge is stale). A remote app whose
root sentinel matches home's (tabs → tabs) counts as a native-chrome
continuation and skips the swap, degrading to today's instant repaint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Font registration is process-wide on both platforms, so fonts a remote app
pushed for its session would otherwise keep answering to tokens Jump itself
uses — its own Inter/Audiowide could resolve to the dev app's files after
the swipe back. Clear on exit; __jumpResume re-pushes Jump's theme right
after, so the bundled faces resolve again on the next lookup.

Needs the matching mobile-ui change (runtime font registry).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The relay called NativeUIFontResolver.clearRuntimeFonts() directly, which
hard-couples this plugin to a native-ui version that has a runtime font
store. Jump builds against whatever native-ui it bundles — currently one
without it — so the iOS build failed outright:

    type 'NativeUIFontResolver' has no member 'clearRuntimeFonts'

Look the function up by name in the bridge registry instead. Present → the
session's pushed fonts are cleared; absent → no runtime font store exists,
so nothing was ever pushed and there's nothing to clean up. Compiles against
either version, on both platforms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Between tapping a server and the remote app's first frame, Jump's own UI sat
there for a second or two — and not quietly. The remote app pushes its theme
as it boots, and its font aliases name files Jump doesn't bundle, so home
visibly re-rendered in fallback fonts before being replaced. It read as Jump
breaking, not as an app loading.

A full-screen <modal> now covers that window (iOS presents it as a
fullScreenCover). Every colour in it is a literal and it uses no `font=`
tokens, so the incoming theme can't shift the cover itself — the point is
lost if the loading screen flickers too.

Raised before the Discovery::connect() bridge call, which returns
immediately, so the cover renders first and the remote tree replaces it
whenever it's ready. Cleared on __jumpResume, which covers both a clean
3-finger exit and a connection that died or never produced a tree (the relay
routes an abandoned reconnect through the same path).

The layout's overlay guard now accounts for it too: the server can drop out
of the store mid-connect, which would empty the store and take the cover down
with the overlay — exposing Jump's UI for exactly the window it exists to
hide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each Jump session stacked another "servers nearby" pill, and the dismissed
bottom sheet appeared to reopen.

Both come from Element.Init calling registerPluginRenderers() on every
session. That registration is only half idempotent: element renderers land in
a dictionary keyed by type, but plugin ROOT HOSTS — the floating overlay, the
drawer — append to NativeRootHostRegistry, and its wrap() folds EVERY
registered entry around the content. So after one Jump the overlay was
wrapped twice, after two Jumps three times: N pills, and N copies of the
bottom sheets living inside that overlay. Dismissing "the" sheet dismissed
the top copy and left an identical one presented behind it, which read as
close → reopen → close.

Guard the call so it runs once per process. Nothing clears either registry,
so re-registering was never buying anything.

The real defect is upstream: NativeRootHostRegistry.register() takes a `name`
that identifies the host and then appends without deduping on it. Worth
fixing there too, so any host calling this more than once is safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant