Skip to content

feat(ui): skeleton→content crossfade helper + app-wide adoption#229

Open
Kelbie wants to merge 12 commits into
fix/add-mints-skeleton-parityfrom
feat/skeleton-content-crossfade
Open

feat(ui): skeleton→content crossfade helper + app-wide adoption#229
Kelbie wants to merge 12 commits into
fix/add-mints-skeleton-parityfrom
feat/skeleton-content-crossfade

Conversation

@Kelbie

@Kelbie Kelbie commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What

Adds SkeletonContentCrossfade (shared/ui/composed/SkeletonContentCrossfade.tsx) — the canonical region-level skeleton→content transition — and adopts it across the app's existing skeleton sites.

Behavior:

  • While loading, a sizeable region shows the shared SkeletonLoadingShimmer wave (one sweep per region; compact/inline swaps use wave="none" and keep the cheap pulse).
  • On the loading→loaded edge, the skeleton fades out over the real content (220ms), so images inside fade in independently via expo-image's transition and never block (or double-fade with) the swap.
  • Leaf primitives stay dumb (Text loading, Skeleton, Avatar, MintIcon) — the fade lives at the region seam, which keeps recycling safe and primitives cheap.
  • Share the chrome: pass the same component in both branches → zero content shift.
  • System Reduce Motion → instant swap (no wave, no fade).
  • exit="none" for FlashList-recycled cells (skeleton unmounts; real item owns its entrance).

Adoption

Add Mints (results region), Mint info (stats + rating), Mint reviews (footer skeletons), Receive (QR hub), payment QR, referenced-post preview, history-refresh row, mention search. MintIcon gained its missing expo-image transition. New Design System demo at /(settings-flow)/design-system-skeleton-crossfade.

Deliberately left on the shared wave

Thread (PostCardSkeleton/ThreadView) and profile (UserProfileScreen) keep using SkeletonLoadingShimmer directly. Thread items are FlashList-recycled — wrapping them reintroduces the wrong-recycled-slot exit glitch that ThreadView.tsx:400-403 already guards against; the profile stats grid has a tuned 3-branch layout + content-shift telemetry not worth a regression-prone rewrite. Both already run the shared wave + a FlashList-safe FadeIn, so they're adopted at the wave level.

Tests / gates

  • New __tests__/skeletonContentCrossfade.test.tsx (8 tests): phase machine, wave gating, reduced-motion, exit="none", rapid-toggle (no stacked overlay), mount-already-loaded (no spurious fade).
  • tsc clean, eslint clean (pre-existing react-perf warnings only), knip no new unused exports.
  • Pre-existing naggFeedClient* suite failures are unrelated to this change (fail identically on the base branch).

Stacked on #228. Skill docs (sovran-ui/references/thread-skeletons.md, ui-patterns.md) updated in the workspace.

🤖 Generated with Claude Code

Kelbie added 12 commits June 22, 2026 12:33
Add SkeletonContentCrossfade, the canonical region-level skeleton→content
transition. While loading, a sizeable region shows the shared
SkeletonLoadingShimmer wave; on the loading→loaded edge the skeleton fades
out over the real content (220ms) so images fade in independently via
expo-image and never block the swap. Leaf primitives stay dumb; reduced
motion does an instant swap; exit="none" keeps FlashList-recycled cells safe.

Adopt at Add Mints (results region), Mint info (stats + rating), Mint reviews
(footer skeletons), Receive (QR hub), payment QR, referenced-post preview,
history-refresh row, and mention search. Fix MintIcon, which was missing its
expo-image transition. Add a Design System demo
(/(settings-flow)/design-system-skeleton-crossfade).

Thread + profile keep using the shared SkeletonLoadingShimmer directly:
thread items are FlashList-recycled (an exiting skeleton renders in the wrong
recycled slot) and the profile stats grid has a tuned 3-branch layout.
Address device feedback on the skeleton→content transition:

- Make the fade a clear two-step crossfade — skeleton fades OUT, then content
  fades IN (300ms total) — instead of dissolving the skeleton over already-
  visible content. One centralized behavior, so every adopter (and the Design
  System demo) animates identically.
- Default the region wave's highlight to the theme `surface` tint (matching the
  thread shimmer); the previous background-tinted fallback was nearly invisible,
  which read as "no wave" on Add Mints.
The shimmer wave must use the theme background, not surface: a background-
colored band sweeping over the skeleton momentarily erases it, giving the
illusion of the skeleton disappearing and reappearing rather than a brighter
stripe on top. My previous surface default broke this (visible in the Design
System). Hard-code the highlight to `background` and drop the override so it
can't drift again.
The wave must paint the color revealed when the skeleton is "erased" — i.e.
whatever is *behind* it — so it reads as the skeleton disappearing/reappearing.
That's the screen background for full-screen lists, but the card/surface color
when the skeleton is on one. Add a `surfaceColor` prop (defaults to background)
and set it where skeletons sit on a surface: the Design System cards and
MintInfo stats (surface-secondary), and the referenced-post preview (surface).
Fixes the wrong-colored wave in the Design System.
…first-frame

buildNostrDataLayer() now memoises ONE facade (and one entity cache) per active
profile + tier config, so the cache accumulates across every caller instead of
being rebuilt empty per request. An identity switch clears the prior cache
before serving the new profile (no cross-account bleed).

useThread paints its first frame from the shared cache (layer.readThread) for
deep links and reply-taps that carry no nav snapshot, ordered after the richer
feed nav seed so existing behaviour is unchanged.
The for-you and notifications facade paths already ingest, but the nagg GraphQL
fast-paths (thread, user feed, posts-by-pubkey, and the non-mapped feed
fallback) returned app-shaped results without populating the cache — so the
cache was not authoritative for those surfaces. Write their notes/profiles/
metrics back into the singleton cache, tagged 'nagg', so opening a reply as its
own thread or revisiting a profile serves instantly from cache.
useThread now seeds from the shared cache first (readThread: ancestors + reply
previews + profiles/metrics), falling back to the transient nav snapshot only
for anything not yet ingested, then own-content for a just-posted note. The cache
is authoritative; the nav seed is a safety net pending on-device proof before its
removal.
PostCard now resolves the author's pfp/name from the authoritative singleton
cache via useProfile (per-key subscription) instead of a transient prop Map. A
row re-renders in place when its author's profile arrives — so a reply's parent
fills the instant it loads, relay-mode threads show authors the feed already
saw, and nothing flashes a stale fallback. A loading status drives a real
skeleton vs the npub fallback. Also seeds the viewer's own pfp/name from
profileStore into the cache at build/switch, so notifications (whose targets are
the viewer's own posts) render own identity with zero fetch.

Adds shared/lib/nostr/useEntityCache.ts (useProfile/useNote/useNoteStats).
Opening a thread, the cache-seeded reply previews were replaced wholesale by the
server's ranked order on the initial fetch, causing a visible reshuffle. Preserve
the already-painted order as a stable prefix and append the ranked delta instead
(reusing the load-more merge); with no seed, the server order is used as-is.
Put the new ResolvedThread.parents into allEvents (not the reply list) so
buildThreadStructure renders the parent chain in relay/primal mode, matching
nagg. With the parents now ingested into the cache, tapping a reply serves its
parent from cache instead of flashing it in from the network.
Add three deterministic analysis features to the log-doctor dev CLI, all
confined to codereview/log-doctor (no production logger changes). Pure logic
lives in a new analysis.ts module so it is unit-testable without loading the
CLI/WDA stack.

- errors: cluster near-identical entries into exemplar + count + first/last,
  collapsing the biggest token consumer. --all/--no-cluster restores the full
  per-entry listing with context.
- perf: complete the existing p95 with p50/p99 and a per-event latency
  sparkline (over params.ms / _perf ops). span-end duration_ms remains a
  separate, documented gap rather than being silently conflated.
- redaction: new read-side audit mode (+ a stats line, + budget row) that
  counts {_kind} brands and <REDACTED:..> markers and heuristically flags raw
  values that look un-redacted, tiered high/low signal. Reports event names,
  never values; respects the deliberate non-reversible {_kind,len} design.

Also reuse a shared percentile() in stats gaps, fix the stale MODES header
list, and document the new mode/flag in OVERVIEW.md + README.md.
The redaction pipeline already branded whole 64-hex (hex32), nsec, jwt, pem
and cashu tokens, but several private-key encodings still reached the log in
the clear because maxStringLength is 120:

- WIF private keys (base58, 51-52 chars) were logged in full as long_string.
- Extended private keys (xprv/yprv/zprv + testnet) are ~111 chars (<=120) and
  were logged in full (or previewed via the base64 long pattern).
- base64-encoded 32-byte secrets (44 chars) were shown in full.
- The hex / base64 long patterns previewed their first 32 chars — enough to
  leak half a key — even for opaque blobs that could be key material.

Hardening (value-based, field-name rules unchanged):
- New secret patterns: wif, xprv-family, base64_key (32/64-byte base64).
- hex / base64 / base58 long patterns are now no-preview: emit {_kind,len}
  only, never bytes — even when short enough to otherwise print in full.
- Embedded xprv inside larger strings is scrubbed to <REDACTED:xprv>.

Deliberately conservative on the privkey/pubkey hex ambiguity (already
handled by hex32) and does NOT touch npub (public identity stays readable).
Embedded raw 64-hex is left alone so public image-hash URLs remain debuggable.
Covered by __tests__/loggerRedaction.test.ts (8 cases, incl. over-redaction
regressions).
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