aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-04 18:37:53 -0700
committerAdam Soltys <[email protected]>2026-09-04 18:37:53 -0700
commit4431198900b2d1a5cfb73bd02ddfd1e377d9acb9 (patch)
treeb285d431f7d17d6ceee8f630dad4b8a043c628f5 /src
parentff5c5d2ac5c017c7c134f3969416306cd8742d1c (diff)
downloadcoinosv3-4431198900b2d1a5cfb73bd02ddfd1e377d9acb9.tar.gz
coinosv3-4431198900b2d1a5cfb73bd02ddfd1e377d9acb9.zip
Hats no longer pop in a beat after page load. The hats feature is a deferred chunk, so every boot painted bare heads until it arrived — but the core bundle can't carry the hat art. Instead, wrapAvatar now leaves a STAMP per head in localStorage (art + percent geometry, size-independent, capped at 40, cleared when a head goes bare so removals can't ghost), and the core avatar factory replays stamps until hatsReady answers — from then on a null from wrapAvatar genuinely means bare. Verified headless: a stamped hat is on the header avatar 15ms after DOM-ready, and once the chunk lands with a bare-head answer the stamp is cleared with no double-wrap
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'src')
-rw-r--r--src/features/hats.js38
-rw-r--r--src/features/messages.js18
2 files changed, 55 insertions, 1 deletions
diff --git a/src/features/hats.js b/src/features/hats.js
index c8b27b6..22b573a 100644
--- a/src/features/hats.js
+++ b/src/features/hats.js
@@ -134,6 +134,40 @@ export function hatsFeature(ctx) {
}, 800);
};
+ // ---- boot stamps ---------------------------------------------------------
+ // This feature is a DEFERRED chunk — for the beat between first paint and
+ // the chunk landing, avatars render bare and hats popped in late. The core
+ // bundle can't hold the hat art, so wrapAvatar leaves a STAMP per head
+ // (art + percent geometry, size-independent) in localStorage, and the
+ // avatar factory replays stamps until this chunk takes over.
+ let stamps = null, stampT = 0;
+ const stampStore = () => {
+ if (stamps === null) { try { stamps = JSON.parse(localStorage.getItem('hat-stamps') || '{}'); } catch { stamps = {}; } }
+ return stamps;
+ };
+ const stamp = (pk, hat) => {
+ const s = stampStore();
+ if (!hat) {
+ // a bare head clears its stamp, or a removed hat would ghost on boot
+ if (s[pk]) { delete s[pk]; saveStamps(); }
+ return;
+ }
+ const next = { s: posStyle(hat), a: hat.art, t: Date.now() };
+ if (!s[pk] || s[pk].a !== next.a || s[pk].s !== next.s) {
+ s[pk] = next;
+ const keys = Object.keys(s);
+ if (keys.length > 40) {
+ keys.sort((a, b) => (s[a].t || 0) - (s[b].t || 0));
+ for (const k of keys.slice(0, keys.length - 40)) delete s[k];
+ }
+ saveStamps();
+ } else s[pk].t = Date.now();
+ };
+ const saveStamps = () => {
+ clearTimeout(stampT);
+ stampT = setTimeout(() => { try { localStorage.setItem('hat-stamps', JSON.stringify(stamps)); } catch {} }, 500);
+ };
+
const pending = new Set();
let fetchT = 0, failedAt = 0;
const queueFetch = (pk) => {
@@ -373,8 +407,12 @@ export function hatsFeature(ctx) {
id: 'hats',
// The avatar factory (and the search rows) offer every avatar up for
// decoration; a bare head comes back null and is used as-is.
+ // Present the moment this chunk lands — the avatar factory stops
+ // replaying boot stamps once a real answer (hat or bare) is available.
+ hatsReady() { return true; },
wrapAvatar(pk, node) {
const hat = hatById(hatFor(pk));
+ stamp(pk, hat); // keep the boot stamp current (or clear it)
if (!hat) return null;
return h('span', { class: 'hat-wrap' },
node,
diff --git a/src/features/messages.js b/src/features/messages.js
index d6eacc9..0aa7291 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1592,9 +1592,25 @@ export function messagesFeature(ctx) {
node.classList.add('clickable');
node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); });
}
- return hook('wrapAvatar', pk, node) || node;
+ return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
+ // The hats feature is a deferred chunk — until it lands, replay last
+ // session's boot stamps (hat art + geometry it persisted) so a hat sits on
+ // the head from the FIRST frame instead of popping in a beat later. Once
+ // hatsReady answers, the stamps stand down: a null from wrapAvatar then
+ // genuinely means "bare head".
+ let _hatStamps = null;
+ function hatStamp(pk, node) {
+ if (hook('hatsReady')) return null;
+ if (_hatStamps === null) {
+ try { _hatStamps = JSON.parse(localStorage.getItem('hat-stamps') || '{}'); } catch { _hatStamps = {}; }
+ }
+ const s = _hatStamps[pk];
+ if (!s || !s.a) return null;
+ return h('span', { class: 'hat-wrap' }, node, h('span', { class: 'ava-hat', style: s.s, html: s.a }));
+ }
+
// ---- profiles: view + own kind-0 editor ---------------------------------
const fullProfiles = new Map(); // pk -> { raw kind0 content object, fetched_at }