aboutsummaryrefslogtreecommitdiffstats
path: root/docker/stack.yml
diff options
context:
space:
mode:
Diffstat (limited to 'docker/stack.yml')
0 files changed, 0 insertions, 0 deletions
d='n110' href='#n110'>110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459
// Hats — cosmetic supporter hats, worn above the avatar everywhere it shows:
// chat rows, DMs, profile pages, notes, search results, the header.
//
// Ownership lives on the names registrar (a kind-0 field would be
// self-asserted — anyone could wear anything). Buying is a plain Lightning
// invoice minted by the registrar and paid headlessly from the Spending
// balance; the registrar grants on settle, and the sats simply stay with
// coinos. That's the whole point: a hat is proof you chipped in.
//
// Display is a batched lookup (`GET /hats?pks=…`) cached alongside nothing
// else — one request covers a whole room, answers persist for a few hours.
// The crown is Adam's and is not for sale.

import { t } from '../i18n.js';
import { fmtSats } from '../format.js';
import { nip98Header } from '../nip98.js';
import { getNetwork } from '../api.js';

const REGISTRAR = 'https://names.coinos.io';
// Hats exist per network: mainnet hats are the real supporter hats; mutinynet
// runs a parallel play-money economy so staging can exercise the whole flow.
const hatNet = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet');
const ADMIN_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';

// One drawing per hat, tuned to perch on a circle: `w` scales against the
// avatar's width, `b` is where the hat's bottom sits (as % of avatar height,
// from the bottom), `r` gives it the jaunty angle.
const svg = (inner) => `<svg viewBox="0 0 64 44" xmlns="http://www.w3.org/2000/svg">${inner}</svg>`;
const HATS = [
  {
    id: 'beanie', nameKey: 'hatBeanie', sat: 21, w: 92, b: 79, r: -8,
    art: svg(`<circle cx="32" cy="9" r="6" fill="#f3e6c9" stroke="#c9a96e" stroke-width="0.9"/><path d="M29 7 q3 -2 6 0 M29.5 11 q2.5 2 5 0" stroke="#d9c49a" stroke-width="1" fill="none" stroke-linecap="round"/>
      <path d="M11 34 Q11 12 32 12 Q53 12 53 34 Z" fill="#e0554a"/>
      <path d="M20 14.5 Q26 25 25 32 M32 12.5 Q32 23 32 32 M44 14.5 Q38 25 39 32" stroke="#c9443b" stroke-width="2" fill="none"/>
      <rect x="8" y="31" width="48" height="10" rx="5" fill="#c9443b"/>
      <path d="M15 33 V39.5 M23 32.5 V40.5 M31 32.5 V40.5 M39 32.5 V40.5 M47 33 V39.5" stroke="#b23a32" stroke-width="2"/>`),
  },
  {
    id: 'trucker', nameKey: 'hatTrucker', sat: 2100, w: 106, b: 69, r: -6,
    art: svg(`<path d="M9 33 Q9 10 29 10 Q49 10 49 33 Z" fill="#3579c2"/>
      <path d="M17 33 Q17 14 29 14 Q41 14 41 33 Z" fill="#f2f5f8"/>
      <text x="29" y="28" font-size="14" font-weight="700" text-anchor="middle" fill="#f7931a" font-family="system-ui,sans-serif">₿</text>
      <circle cx="29" cy="9.5" r="2.4" fill="#2a5f9c"/>
      <path d="M45 29 Q58 27.2 61.8 31.6 Q63.4 34.6 59 36.4 Q52 37.1 45 37 Z" fill="#2a5f9c"/>
      <rect x="9" y="32" width="39" height="5" rx="2.5" fill="#2a5f9c"/>`),
  },
  {
    id: 'cowboy', nameKey: 'hatCowboy', sat: 21000, w: 122, b: 69, r: -7,
    art: svg(`<path d="M21 26 Q21 5 32 5 Q43 5 43 26 Z" fill="#a97844"/>
      <path d="M32 5.5 Q29.5 14 31 24" stroke="#8a5f33" stroke-width="1.8" fill="none"/>
      <rect x="20" y="20.5" width="24" height="5" rx="2" fill="#6f4a26"/>
      <path d="M6 27 Q5 18.5 11 24.5 L53 24.5 Q59 18.5 58 27 Q57 31.5 47 34.5 Q32 38.5 17 34.5 Q7 31.5 6 27 Z" fill="#a97844"/>
      <path d="M8 28 Q17 32.5 32 32.5 Q47 32.5 56 28" stroke="#8a5f33" stroke-width="1.5" fill="none"/>`),
  },
  {
    id: 'fedora', nameKey: 'hatFedora', sat: 21000, w: 118, b: 71, r: -8,
    art: svg(`<path d="M18 29 L20 11 Q32 6 44 11 L46 29 Z" fill="#4c525b" stroke="rgba(255,255,255,.1)" stroke-width="0.8"/>
      <path d="M20 11 Q32 15.5 44 11" stroke="#3a3f47" stroke-width="2" fill="none"/>
      <path d="M17.4 23 L46.6 23 L47 29 L17 29 Z" fill="#23262c"/>
      <path d="M6 31 Q32 25 58 31 Q58 37 32 37.5 Q6 37 6 31 Z" fill="#3a3f47" stroke="rgba(255,255,255,.1)" stroke-width="0.8"/>`),
  },
  {
    id: 'bowler', nameKey: 'hatBowler', sat: 210000, w: 104, b: 71, r: -7,
    art: svg(`<path d="M15 29 Q15 7 32 7 Q49 7 49 29 Z" fill="#2b2e35" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>
      <path d="M22 11 Q27 8.5 33 9.5" stroke="#464b55" stroke-width="2" fill="none" stroke-linecap="round"/>
      <path d="M14.6 24 L49.4 24 L49.6 29 L14.4 29 Z" fill="#17191d"/>
      <path d="M7 31 Q7 26.5 13 29 L51 29 Q57 26.5 57 31 Q57 36 32 36 Q7 36 7 31 Z" fill="#17191d" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>`),
  },
  {
    id: 'top', nameKey: 'hatTop', sat: 210000, w: 96, b: 73, r: -7,
    art: svg(`<path d="M17 5 Q32 1.5 47 5 L45.5 28 L18.5 28 Z" fill="#26282f" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>
      <path d="M18.7 22 L45.3 22 L45.6 28 L18.4 28 Z" fill="#b6382e"/>
      <path d="M7 30.5 Q7 26.5 14 29 L50 29 Q57 26.5 57 30.5 Q57 36 32 36 Q7 36 7 30.5 Z" fill="#17191d" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>`),
  },
  {
    id: 'wizard', nameKey: 'hatWizard', sat: 2100000, w: 112, b: 83, r: -4, dx: -8,
    art: svg(`<path d="M25 35 Q33 21 36.5 11 Q37.5 5.5 44 3 Q41 9 40 13 Q44 25 50 35 Z" fill="#6d4bb8"/>
      <circle cx="44.5" cy="3.5" r="2.6" fill="#ffd166"/>
      <ellipse cx="37" cy="36" rx="21" ry="5" fill="#59389f"/>
      <path d="M33 19 l1.2 2.6 2.6 1.2 -2.6 1.2 -1.2 2.6 -1.2 -2.6 -2.6 -1.2 2.6 -1.2 Z" fill="#ffd166"/>
      <path d="M41.5 26 l0.9 2 2 0.9 -2 0.9 -0.9 2 -0.9 -2 -2 -0.9 2 -0.9 Z" fill="#ffe08a"/>`),
  },
  {
    // Not for sale: gifted by the operator to beta testers (see the
    // registrar's /hats/grant). Appears in the shop only once owned.
    id: 'construction', nameKey: 'hatConstruction', sat: null, w: 108, b: 71, r: -6,
    art: svg(`<path d="M12 30 Q12 9 30 9 Q48 9 48 30 Z" fill="#ef7f1a"/>
      <path d="M26.5 9.6 h7 q1.6 0 1.6 1.6 v7.8 h-10.2 v-7.8 q0-1.6 1.6-1.6 Z" fill="#ff9d47"/>
      <path d="M18.5 13.5 Q16 20 16 30 M41.5 13.5 Q44 20 44 30" stroke="#cf640c" stroke-width="1.6" fill="none"/>
      <path d="M44 29.2 Q57 27.4 61.5 31.3 Q63.4 33.9 59 35.2 Q52 35.7 44 35.5 Z" fill="#cf640c"/>
      <rect x="9" y="29" width="40" height="6.5" rx="3" fill="#cf640c" stroke="rgba(0,0,0,.1)" stroke-width="0.8"/>`),
  },
  {
    // Not for sale either: a knight's great helm, gifted. Unlike every
    // other hat it covers the whole face — its own square drawing sits
    // over the avatar rather than perching on top of it.
    id: 'knight', nameKey: 'hatKnight', sat: null, w: 114, b: -7, r: 0, dx: 8,
    art: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
      <path d="M31 6.5 Q22 -0.5 12 2.5 Q20.5 3.5 27 9.5 Z" fill="#d6323e"/>
      <path d="M31 6.5 Q26 3 18 2.6" stroke="#f26a72" stroke-width="1.1" fill="none" stroke-linecap="round"/>
      <path d="M3 30 Q3 4 32 4 Q61 4 61 30 L61 56 Q61 62 55 62 L9 62 Q3 62 3 56 Z" fill="#b3bbc6" stroke="#5c6470" stroke-width="1.3"/>
      <path d="M8 32 Q8 11 25 7.5" stroke="rgba(255,255,255,.6)" stroke-width="2.2" fill="none" stroke-linecap="round"/>
      <path d="M53 10 Q59 19 59 30 L59 56" stroke="rgba(0,0,0,.16)" stroke-width="3" fill="none" stroke-linecap="round"/>
      <circle cx="32" cy="5.2" r="2.6" fill="#e2b53b" stroke="#a87f1f" stroke-width=".8"/>
      <path d="M3 27.5 H61" stroke="#7c848f" stroke-width="1"/>
      <g fill="#6b7380"><circle cx="9" cy="24.5" r="1.05"/><circle cx="15.5" cy="24.5" r="1.05"/><circle cx="22" cy="24.5" r="1.05"/><circle cx="42" cy="24.5" r="1.05"/><circle cx="48.5" cy="24.5" r="1.05"/><circle cx="55" cy="24.5" r="1.05"/></g>
      <rect x="7" y="32.5" width="50" height="5.6" rx="2.8" fill="#14171c"/>
      <rect x="30.3" y="27" width="3.4" height="19" rx="1.2" fill="#d2d8e0" stroke="#5c6470" stroke-width=".8"/>
      <g fill="#3a3f47"><circle cx="18" cy="47.5" r="1.25"/><circle cx="22.5" cy="50.5" r="1.25"/><circle cx="18" cy="53.5" r="1.25"/><circle cx="46" cy="47.5" r="1.25"/><circle cx="41.5" cy="50.5" r="1.25"/><circle cx="46" cy="53.5" r="1.25"/></g>
      <path d="M3 56.5 Q32 59.5 61 56.5 L61 57 Q61 62 55 62 L9 62 Q3 62 3 57 Z" fill="#8d95a1"/>
      </svg>`,
  },
  {
    id: 'crown', nameKey: 'hatCrown', sat: null, w: 86, b: 83, r: -7,
    art: svg(`<path d="M10 38 L7 13 L20 25 L32 6 L44 25 L57 13 L54 38 Z" fill="#f2b32a"/>
      <circle cx="7" cy="12" r="3" fill="#ffd97a"/><circle cx="32" cy="6" r="3" fill="#ffd97a"/><circle cx="57" cy="12" r="3" fill="#ffd97a"/>
      <rect x="9" y="34" width="46" height="8" rx="2.5" fill="#d9930f"/>
      <circle cx="18" cy="38" r="2.2" fill="#e04a56"/><circle cx="32" cy="38" r="2.2" fill="#3a7bd5"/><circle cx="46" cy="38" r="2.2" fill="#3fae6a"/>`),
  },
];
const hatById = (id) => HATS.find((x) => x.id === id) || null;
// -58, not -50: every hat rides a couple of pixels left of center —
// the art leans right and dead-center read as drifting off the head.
const posStyle = (hat) => `width:${hat.w}%;bottom:${hat.b}%;transform:translateX(${-58 + (hat.dx || 0)}%) rotate(${hat.r}deg)`;

export function hatsFeature(ctx) {
  const { h, ui, render, wallet, hook, toast, brandHeader } = ctx;

  // ---- who wears what: batched lookup + persistent cache ------------------

  // Serve from cache instantly, but ask the registrar again after a couple
  // of minutes — six hours of staleness meant a hat swapped on one device
  // kept its old look on every other for the rest of the day. One batched
  // request per quiet period is what the /hats endpoint is for.
  const REVALIDATE = 2 * 60_000;
  const worn = new Map(); // pk -> { hat: id|null, t }
  let loaded = false;
  const loadStore = () => {
    if (loaded) return;
    loaded = true;
    try {
      for (const [pk, v] of Object.entries(wallet.loadFeatureState('hats', {}).worn || {}))
        worn.set(pk, v);
    } catch {}
  };
  let saveT = 0;
  const saveStore = () => {
    clearTimeout(saveT);
    saveT = setTimeout(() => {
      try {
        const keep = [...worn.entries()].sort((a, b) => b[1].t - a[1].t).slice(0, 300);
        wallet.saveFeatureState('hats', { worn: Object.fromEntries(keep) });
      } catch {}
    }, 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) => {
    if (Date.now() - failedAt < 60_000) return;
    pending.add(pk);
    if (!fetchT) fetchT = setTimeout(flushFetch, 400);
  };
  async function flushFetch() {
    fetchT = 0;
    const pks = [...pending].slice(0, 100);
    pending.clear();
    if (!pks.length) return;
    try {
      const net = hatNet();
      const r = await fetch(`${REGISTRAR}/hats?pks=${pks.join(',')}&net=${net}`);
      const j = await r.json();
      if (!r.ok) throw new Error('hats lookup failed');
      const now = Date.now();
      let changed = false;
      for (const pk of pks) {
        const key = net + ':' + pk;
        const next = (j.hats || {})[pk] || null;
        // a hat coming OFF is a change too — the old check only repainted
        // when a fresh entry had a hat, so removals lingered on screen
        if ((worn.get(key) || {}).hat !== next) changed = true;
        worn.set(key, { hat: next, t: now });
      }
      saveStore();
      if (changed) render();
    } catch {
      failedAt = Date.now();
    }
  }

  // Stale-while-revalidate: answer from cache immediately, refresh behind.
  function hatFor(pk) {
    if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return null;
    loadStore();
    const v = worn.get(hatNet() + ':' + pk);
    if (!v || Date.now() - v.t > REVALIDATE) queueFetch(pk);
    return v ? v.hat : null;
  }

  // ---- registrar calls ----------------------------------------------------

  const withTimeout = (p, ms, what) => Promise.race([
    p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms)),
  ]);

  // Hats belong to the shown identity (login npub when there is one, else
  // the wallet key) — the same face the hat will sit on.
  async function identity() {
    const id = hook('nostrLoginIdentity');
    if (id) {
      const signer = id.signer || (await hook('nostrLoginResume'));
      return signer ? { pubkey: id.pubkey, signer } : null;
    }
    const pk = wallet.nostrPubkey && wallet.nostrPubkey();
    return pk ? { pubkey: pk, signer: { signEvent: (e) => wallet.nostrSign(e) } } : null;
  }

  // Safari reports a dropped request as the bare "Load failed", and a fresh
  // PWA's first tap is prime territory for exactly one of those. Retry a
  // NETWORK-shaped failure once, transparently (a fresh NIP-98 header each
  // attempt); a real refusal from the registrar never re-posts. Safe for
  // both posts: /hats/invoice mints anew, /hats/claim is idempotent.
  const NETWORKISH = /load failed|failed to fetch|network|timed? ?out/i;
  async function post(path, payload, signer, attempt = 0) {
    const url = `${REGISTRAR}${path}`;
    const body = JSON.stringify(payload);
    try {
      const auth = await withTimeout(nip98Header(signer, url, 'POST', body), 12000, 'signing');
      const r = await withTimeout(fetch(url, {
        method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body,
      }), 12000, 'registrar');
      const j = await r.json().catch(() => ({}));
      if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
      return j;
    } catch (e) {
      if (attempt < 1 && NETWORKISH.test(e.message || '')) return post(path, payload, signer, attempt + 1);
      throw e;
    }
  }

  // ---- the shop -----------------------------------------------------------

  function openShop() {
    ui.hatShop = true;
    ui.hatConfirm = null;
    loadShop();
  }

  function loadShop() {
    ui.hatShopData = null;
    render();
    const me = ctx.shownPubkey();
    if (!me) { ui.hatShopData = { owned: [], equipped: null }; render(); return; }
    const attempt = (retriesLeft) => fetch(`${REGISTRAR}/hats/${me}?net=${hatNet()}`).then((r) => r.json()).then((j) => {
      if (!ui.hatShop) return;
      ui.hatShopData = { owned: j.owned || [], equipped: j.equipped || null, prices: j.prices || null };
      const now = Date.now();
      worn.set(hatNet() + ':' + me, { hat: j.equipped || null, t: now });
      saveStore();
      render();
    }).catch(() => {
      // one quiet retry before showing the offline shop — see post()
      if (retriesLeft > 0) return new Promise((r) => setTimeout(r, 1200)).then(() => attempt(retriesLeft - 1));
      if (ui.hatShop && !ui.hatShopData) { ui.hatShopData = { owned: [], equipped: null, offline: true }; render(); }
    });
    attempt(1);
  }
  const closeShop = () => { ui.hatShop = null; ui.hatShopData = null; ui.hatConfirm = null; render(); };

  async function buy(hat) {
    const price = priceOf(hat);
    if (!['mainnet', 'mutinynet'].includes(getNetwork())) { toast(t('hatMainnetOnly')); return; }
    if (!hook('arkReady')) { toast(t('hatNeedSpending')); return; }
    const spendable = hook('arkSpendableSat') || 0;
    // Routing headroom, honestly sized: the hat invoice lives on the ASP's
    // own node, so the quoted route fee is ~0–2 sats. The old 2% guard
    // demanded 42k sats of phantom headroom on the wizard hat and told a
    // wallet holding 2,133,786 that it couldn't afford 2,100,000.
    if (spendable < price + Math.max(10, Math.ceil(price * 0.002))) {
      toast(t('hatNoFunds', { sats: fmtSats(price) }));
      return;
    }
    ui.hatBuying = hat.id;
    render();
    try {
      const id = await identity();
      if (!id) {
        if (!hook('nostrReconnectPrompt')) toast(t('msgNoIdentity'));
        ui.hatBuying = null; ui.hatConfirm = null; render();
        return;
      }
      const inv = await post('/hats/invoice', { hat: hat.id, net: hatNet() }, id.signer);
      await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.sat });
      const rec = await post('/hats/claim', { paymentHash: inv.paymentHash, hat: hat.id, net: hatNet() }, id.signer);
      ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped };
      worn.set(hatNet() + ':' + id.pubkey, { hat: rec.equipped, t: Date.now() });
      saveStore();
      toast(t('hatYours', { name: t(hat.nameKey) }));
    } catch (e) {
      toast(e.message || String(e));
    } finally {
      ui.hatBuying = null;
      ui.hatConfirm = null;
      render();
    }
  }

  async function equip(hatId) {
    ui.hatBuying = hatId || 'none';
    render();
    try {
      const id = await identity();
      if (!id) {
        if (!hook('nostrReconnectPrompt')) toast(t('msgNoIdentity'));
        ui.hatBuying = null; render();
        return;
      }
      const rec = await post('/hats/equip', { hat: hatId, net: hatNet() }, id.signer);
      ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped };
      worn.set(hatNet() + ':' + id.pubkey, { hat: rec.equipped, t: Date.now() });
      saveStore();
    } catch (e) {
      toast(e.message || String(e));
    } finally {
      ui.hatBuying = null;
      render();
    }
  }

  const priceOf = (hat) => {
    const d = ui.hatShopData;
    return (d && d.prices && d.prices[hat.id]) || hat.sat;
  };

  const demo = (hat) => h('span', { class: 'hat-demo' },
    h('span', { class: 'hat-demo-head' }),
    h('span', { class: 'ava-hat', style: posStyle(hat), html: hat.art }));

  function shopRow(hat, d, me) {
    const ownedHere = d && d.owned.includes(hat.id);
    const wearing = d && d.equipped === hat.id;
    const busy = ui.hatBuying === hat.id;
    let action;
    if (hat.id === 'crown' && me !== ADMIN_PK && !ownedHere) {
      action = h('span', { class: 'small faint' }, t('hatCrownTaken'));
    } else if (busy) {
      action = h('span', { class: 'spinner sm' });
    } else if (wearing) {
      action = h('button', { class: 'btn-sm hat-btn wearing', onClick: () => equip(null) }, t('hatWearing'));
    } else if (ownedHere) {
      action = h('button', { class: 'btn-sm hat-btn', onClick: () => equip(hat.id) }, t('hatWear'));
    } else if (ui.hatConfirm === hat.id) {
      action = h('button', { class: 'btn-sm hat-btn confirm', onClick: () => buy(hat) }, t('hatConfirmTap'));
    } else {
      action = h('button', {
        class: 'btn-sm hat-btn', disabled: !!ui.hatBuying,
        onClick: () => { ui.hatConfirm = hat.id; render(); setTimeout(() => { if (ui.hatConfirm === hat.id) { ui.hatConfirm = null; render(); } }, 5000); },
      }, fmtSats(priceOf(hat)) + ' sats');
    }
    return h('div', { class: 'hat-row' },
      demo(hat),
      h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
        h('div', { class: 'chat-name' }, t(hat.nameKey)),
        ownedHere
          ? h('div', { class: 'muted small' }, t('hatOwned'))
          : hat.id === 'crown' ? h('div', { class: 'muted small' }, t('hatCrownWho')) : null),
      action);
  }

  function shopScreen() {
    const d = ui.hatShopData;
    const me = ctx.shownPubkey();
    return h('div', { class: 'col', style: 'gap:16px' },
      brandHeader(false),
      h('div', { class: 'card col', style: 'gap:12px' },
        h('h3', { style: 'margin:0' }, '🎩 ' + t('hatShopTitle')),
        h('p', { class: 'small muted', style: 'margin:0' }, t('hatShopBlurb')),
        d == null
          ? h('div', { style: 'text-align:center;padding:16px' }, h('span', { class: 'spinner' }))
          : h('div', { class: 'col', style: 'gap:2px' },
              // unpriced hats (the crown, the beta hard hat) are not
              // merchandise — they appear only in the collection of a head
              // that owns them, never as a listing
              ...HATS.filter((hat) => (priceOf(hat) || 0) > 0 || ((d && d.owned) || []).includes(hat.id))
                .map((hat) => shopRow(hat, d, me))),
        d && d.offline ? h('div', { class: 'small faint', style: 'text-align:center' }, t('hatShopOffline')) : null),
      h('button', { class: 'btn-ghost btn-block', onClick: closeShop }, t('back')));
  }

  // -------------------------------------------------------------------------

  return {
    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,
        h('span', { class: 'ava-hat', style: posStyle(hat), html: hat.art }));
    },
    // The footer's "Show your support" link: straight into the shop.
    openHatShop() { openShop(); try { window.scrollTo(0, 0); } catch {} return true; },
    // Own-profile editor entry: the door to the shop.
    hatShopEntry() {
      return h('button', {
        class: 'btn-sm', style: 'margin-top:6px;align-self:flex-start', onClick: openShop,
      }, '🎩 ' + t('hatShopSupport'));
    },
    screenView() {
      if (ui.screen !== 'wallet' || !ui.hatShop) return null;
      // A restored session brings the shop screen back WITHOUT its data
      // (hatShop rides the nav snapshot; the fetch result doesn't) — refetch
      // instead of rendering a collection nobody loaded. undefined = never
      // fetched; null = fetch in flight.
      if (ui.hatShopData === undefined) setTimeout(loadShop, 0);
      return shopScreen();
    },
  };
}