@civitai/blocks-react 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +296 -6
  2. package/dist/hooks/consentRetryOptions.d.ts +40 -0
  3. package/dist/hooks/consentRetryOptions.js +2 -0
  4. package/dist/hooks/useBuzzWorkflow.d.ts +23 -1
  5. package/dist/hooks/useBuzzWorkflow.js +125 -44
  6. package/dist/hooks/useCheckpointPicker.d.ts +43 -10
  7. package/dist/hooks/useCheckpointPicker.js +37 -6
  8. package/dist/hooks/useCivitaiNavigate.d.ts +57 -6
  9. package/dist/hooks/useCivitaiNavigate.js +50 -7
  10. package/dist/hooks/useCivitaiRoute.d.ts +63 -0
  11. package/dist/hooks/useCivitaiRoute.js +71 -0
  12. package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
  13. package/dist/hooks/useCreatePostFromApp.js +87 -34
  14. package/dist/hooks/useGoodPurchase.d.ts +2 -1
  15. package/dist/hooks/useGoodPurchase.js +64 -19
  16. package/dist/hooks/useRequestConsent.js +10 -12
  17. package/dist/hooks/useResourcePicker.d.ts +56 -7
  18. package/dist/hooks/useResourcePicker.js +27 -3
  19. package/dist/hooks/useTip.d.ts +2 -1
  20. package/dist/hooks/useTip.js +99 -20
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.js +3 -0
  23. package/dist/internal/liveHost.js +238 -7
  24. package/dist/internal/mockHost.js +67 -9
  25. package/dist/internal/popoverShim.d.ts +94 -0
  26. package/dist/internal/popoverShim.js +181 -0
  27. package/dist/internal/withConsentRetry.d.ts +239 -0
  28. package/dist/internal/withConsentRetry.js +457 -0
  29. package/dist/testing.d.ts +1 -0
  30. package/dist/testing.js +1 -0
  31. package/dist/transport/iframeTransport.d.ts +33 -0
  32. package/dist/transport/iframeTransport.js +56 -0
  33. package/dist/transport/validate.d.ts +25 -0
  34. package/dist/transport/validate.js +31 -0
  35. package/package.json +4 -4
@@ -435,6 +435,19 @@ export function createLiveHost(options) {
435
435
  // the host that is otherwise the closest thing to prod.
436
436
  let currentTheme = theme;
437
437
  let pushToBlock = null;
438
+ // The sub-path the block has been TOLD about, and whether it has been told
439
+ // anything at all. Together these reproduce the production host's gate
440
+ // (`PageBlockHost.tsx`: `if (!initSentRef.current || status !== 'ready')
441
+ // return;` with deps `[subPath, status, send]`), which fires `ROUTE_CHANGED`
442
+ // only AFTER init and only when the resolved sub-path CHANGES.
443
+ //
444
+ // 🔴 SEEDED FROM THE INIT CONTEXT, NOT FROM `''`. The first value reaches the
445
+ // block in `BLOCK_INIT.context.subPath`, so a harness given
446
+ // `options.context: { …, subPath: 'compare/42' }` must not then push
447
+ // `ROUTE_CHANGED { subPath: 'compare/42' }` as if it were a change — the real
448
+ // host's effect does not fire for the value init already carried.
449
+ let currentSubPath = '';
450
+ let initDispatched = false;
438
451
  function install() {
439
452
  if (installed)
440
453
  return teardown;
@@ -523,6 +536,11 @@ export function createLiveHost(options) {
523
536
  theme: currentTheme,
524
537
  };
525
538
  const context = hostContextWithTheme(baseContext, currentTheme);
539
+ // Seed the route ledger from what init actually carries, so the first
540
+ // `ROUTE_CHANGED` is a change rather than a restatement — see the
541
+ // declaration of `currentSubPath`.
542
+ currentSubPath =
543
+ 'subPath' in context && typeof context.subPath === 'string' ? context.subPath : '';
526
544
  const initPayload = {
527
545
  blockInstanceId: decoded.blockInstanceId ?? 'page_live',
528
546
  blockId: decoded.blockId ?? 'live-block',
@@ -539,7 +557,61 @@ export function createLiveHost(options) {
539
557
  : {}),
540
558
  };
541
559
  dispatchToBlock({ type: 'BLOCK_INIT', payload: initPayload });
560
+ initDispatched = true;
542
561
  }
562
+ /**
563
+ * Reflect a route move back into the block over `ROUTE_CHANGED`, the way
564
+ * `PageBlockHost` does — the second half of an app-scoped `NAVIGATE`, and
565
+ * the only way a block learns where a shallow push put it.
566
+ *
567
+ * 🔴 THIS REPLACED A SYNTHETIC `popstate`. The earlier shape of this handler
568
+ * dispatched `new PopStateEvent('popstate')` after `pushState`, which made a
569
+ * history-based router in the block re-render — in `dev:live` only. Nothing
570
+ * in production dispatches a `popstate` for a host-side shallow push, so a
571
+ * block that worked here still showed the wrong view on civitai.com: the
572
+ * #5209 symptom with its sign flipped, which is the shape that keeps a
573
+ * platform bug invisible. The message is the real channel; this emits it.
574
+ *
575
+ * Three gates, all mirroring the host's own effect:
576
+ * - AFTER INIT. The initial sub-path travels in `BLOCK_INIT.context`, and a
577
+ * push the transport receives before init has no `context.subPath` to
578
+ * update, so it would be silently dropped anyway.
579
+ * - ONLY ON A CHANGE. The host's effect is keyed on `[subPath, …]`, so a
580
+ * navigation to the route already showing produces no message.
581
+ * - CURRENT FRAME ONLY. A `new_tab` navigation does not move THIS frame's
582
+ * route, so it must not emit — see the call sites.
583
+ */
584
+ const reflectRoute = (subPath) => {
585
+ if (!initDispatched)
586
+ return;
587
+ if (subPath === currentSubPath)
588
+ return;
589
+ currentSubPath = subPath;
590
+ dispatchToBlock({ type: 'ROUTE_CHANGED', payload: { subPath } });
591
+ };
592
+ /**
593
+ * The sub-path `win.location` currently names, in the shape the host sends:
594
+ * the segment below the app root, with no leading slash (`''` on the index).
595
+ *
596
+ * In `dev:live` the block IS the page, served at this dev origin's own root,
597
+ * so the app root is `/` and the whole pathname below it is the sub-path.
598
+ * That is the same mapping the app-scope branch of `NAVIGATE` applies in the
599
+ * other direction (`/<path>` on this origin), kept in ONE place so the two
600
+ * cannot disagree.
601
+ */
602
+ const subPathFromLocation = () => win.location.pathname.replace(/^\/+/, '');
603
+ // The viewer's OWN back/forward, which production reports too: the host's
604
+ // effect is keyed on the resolved `subPath`, so it fires for a history move
605
+ // nobody asked for exactly as it does for a `NAVIGATE`. Without this a block
606
+ // written against `useCivitaiRoute()` would follow its own navigations here
607
+ // and ignore the back button — a divergence in the opposite direction to the
608
+ // one this PR closes, but a divergence.
609
+ //
610
+ // `pushState` does NOT fire `popstate`, so our own pushes do not come back
611
+ // through here; the change gate in `reflectRoute` makes a double-fire inert
612
+ // regardless.
613
+ const onPopState = () => reflectRoute(subPathFromLocation());
614
+ win.addEventListener('popstate', onPopState);
543
615
  /**
544
616
  * Open the in-harness picker overlay and resolve it into a picker-result
545
617
  * message. `resultType` is the inbound message type the block awaits
@@ -1535,21 +1607,136 @@ export function createLiveHost(options) {
1535
1607
  return;
1536
1608
  }
1537
1609
  case 'NAVIGATE': {
1538
- const path = typed.payload?.path ?? '';
1539
- const target = typed.payload?.target ?? 'current';
1540
- // Resolve relative paths against the backend origin so an in-app
1541
- // path (`/models/123`) opens on the real site.
1542
- const url = /^https?:\/\//i.test(path) ? path : `${baseUrl}${path}`;
1610
+ // 🔴 THIS HANDLER USED TO SEND EVERY PATH TO THE REAL SITE, AND THAT
1611
+ // IS WHY `dev:live` AND PRODUCTION DISAGREED. It resolved any
1612
+ // relative path against `baseUrl` — "so an in-app path
1613
+ // (`/models/123`) opens on the real site" — while production
1614
+ // resolved the same call under the block's OWN route. So the docs
1615
+ // and the dev harness agreed with each other and production was the
1616
+ // odd one out, which is exactly the shape that makes a platform bug
1617
+ // look like a block bug (civitai#5209).
1618
+ //
1619
+ // It now mirrors the merged contract, DEFAULT INCLUDED:
1620
+ // scope absent / anything but 'site' → APP space (this dev origin)
1621
+ // scope === 'site' → SITE space (`baseUrl`)
1622
+ // compared against the literal 'site' rather than validated against
1623
+ // the union, so an unknown value fails CLOSED onto the narrower
1624
+ // space — byte-for-byte the host's own test.
1625
+ //
1626
+ // 🔴 WHAT IS DELIBERATELY *NOT* MIRRORED: the host's hostile-path
1627
+ // battery (control characters, backslashes, `%2f`/`%5c`,
1628
+ // the resolved-vs-sent segment-structure rule, app containment).
1629
+ // Those guard an UNTRUSTED iframe. In `dev:live` there is no
1630
+ // untrusted party — the developer's own code, their own dev token,
1631
+ // their own browser — so copying ~200 lines of security-critical
1632
+ // resolver here would buy no safety and create a second, non-
1633
+ // authoritative copy of it to drift. What IS mirrored is the
1634
+ // CONTRACT a block author can be honestly wrong about: which space
1635
+ // a path lands in, the `'app'` default, and the two refusals the
1636
+ // published docs promise (a scheme, and `/api/*` in site scope).
1637
+ const payload = typed.payload ?? {};
1638
+ const rawPath = typeof payload.path === 'string' ? payload.path : '';
1639
+ const target = payload.target ?? 'current';
1640
+ const scope = payload.scope === 'site' ? 'site' : 'app';
1641
+ // A scheme or protocol-relative reference is refused by the host in
1642
+ // BOTH scopes — a block cannot move the host to another origin. This
1643
+ // harness used to pass an absolute URL straight through, so a
1644
+ // `navigate('https://example.com/x')` that production DROPS worked
1645
+ // here. Refuse it, and say so: a silent drop in a dev harness is the
1646
+ // thing a dev then blames on their own code.
1647
+ if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(rawPath) || /^\/[/\\]/.test(rawPath)) {
1648
+ logOnce('navigate-off-origin', `NAVIGATE to ${JSON.stringify(rawPath)} was DROPPED: the host refuses any scheme ` +
1649
+ 'or protocol-relative path, in either scope. Pass a path within a scope ' +
1650
+ "(`navigate('models/123', { scope: 'site' })`) instead of a full URL.");
1651
+ return;
1652
+ }
1653
+ // Leading slashes are normalised away in BOTH scopes — `scope`
1654
+ // already said which space this is, so the slash has nothing left to
1655
+ // mean. Do not reintroduce punctuation semantics here.
1656
+ const path = rawPath.replace(/^\/+/, '');
1657
+ if (scope === 'site') {
1658
+ // The host's one content-based refusal: `/api/*` is not a page
1659
+ // route, and `/api/auth/logout` takes a bare GET. Kept here because
1660
+ // it is part of the PUBLISHED contract, not because this harness is
1661
+ // a security boundary — a dev who hits it in production should hit
1662
+ // it in `dev:live` too.
1663
+ //
1664
+ // 🔴 BY DECODED VALUE, LIKE THE HOST — not by spelling. An earlier
1665
+ // revision compared `path.split('/')[0]` raw and said so in a
1666
+ // comment, which left `%61pi/auth/logout` refused in production and
1667
+ // followed here: the published contract mirrored with the one input
1668
+ // shape that defeats it. Deleting the branch instead was the other
1669
+ // coherent option and was rejected — the refusal is part of the
1670
+ // PUBLISHED contract (not of any security boundary this harness
1671
+ // pretends to be), so a dev who hits it in production should hit it
1672
+ // in `dev:live` too, and a mirror that is wrong for one input is
1673
+ // worse than either having it or not.
1674
+ //
1675
+ // This is NOT the host's "two spellings of one rule" case, which
1676
+ // deleted a `first.toLowerCase() === 'api'` fast path sitting
1677
+ // ALONGSIDE the decoded comparison and provably unable to reach a
1678
+ // verdict the survivor did not (`decodeURIComponent('api')` is
1679
+ // `'api'`; a mutation run showed it SURVIVED every test). There was
1680
+ // only ever one comparison here, and it was the wrong one.
1681
+ //
1682
+ // Both of the host's refusal channels, in its order
1683
+ // (`navigateSiteFirstSegmentIsRefused`): a first segment whose
1684
+ // meaning cannot be established — a malformed escape like `%zz` —
1685
+ // is refused too, because an undecodable segment is not evidence
1686
+ // that it is not `api`. Fails CLOSED, like the host.
1687
+ if (navigateSiteFirstSegmentIsRefused(path)) {
1688
+ logOnce('navigate-api', `NAVIGATE to ${JSON.stringify(rawPath)} was DROPPED: in site scope the host ` +
1689
+ 'refuses a first segment that decodes to `api`, and refuses one it cannot ' +
1690
+ 'decode at all. Only page routes are reachable.');
1691
+ return;
1692
+ }
1693
+ try {
1694
+ const url = `${baseUrl}/${path}`;
1695
+ if (target === 'new_tab') {
1696
+ win.open(url, '_blank');
1697
+ }
1698
+ else {
1699
+ win.location.assign(url);
1700
+ }
1701
+ }
1702
+ catch {
1703
+ /* navigation may be unavailable (tests) */
1704
+ }
1705
+ return;
1706
+ }
1707
+ // APP scope. Production resolves this under `<base>/<slug>/<path>`
1708
+ // and pushes it SHALLOWLY, keeping the page mounted. In `dev:live`
1709
+ // the block IS the page, served at this dev origin's own root, so the
1710
+ // faithful analogue is `/<path>` on THIS origin — and `pushState`
1711
+ // rather than `assign`, because a full load is precisely what
1712
+ // "shallow" excludes.
1713
+ //
1714
+ // 🔴 `reflectRoute` IS WHAT MAKES IT WORK rather than merely move the
1715
+ // URL bar — and it is the production channel, not a local analogue
1716
+ // of one. Production reflects the new sub-path back into the block
1717
+ // over `ROUTE_CHANGED`; so does this now. An earlier revision
1718
+ // dispatched a synthetic `popstate` here instead, "since this SDK
1719
+ // models no `ROUTE_CHANGED`" — which made a history router re-render
1720
+ // HERE and nowhere else, because no production host dispatches a
1721
+ // `popstate` for its own shallow push. That is the #5209 failure
1722
+ // class with its sign flipped, and a dev harness that is kinder than
1723
+ // production is how #5209 stayed invisible in the first place.
1724
+ //
1725
+ // Emitted only in the `current` arm: a `new_tab` navigation does not
1726
+ // move THIS frame's route, and the host's effect is keyed on the
1727
+ // sub-path of the page it is actually rendering.
1543
1728
  try {
1729
+ const url = `${win.location.origin}/${path}`;
1544
1730
  if (target === 'new_tab') {
1545
1731
  win.open(url, '_blank');
1546
1732
  }
1547
1733
  else {
1548
- win.location.assign(url);
1734
+ win.history.pushState(null, '', url);
1735
+ reflectRoute(path);
1549
1736
  }
1550
1737
  }
1551
1738
  catch {
1552
- /* navigation may be unavailable (tests) */
1739
+ /* history/navigation may be unavailable (tests) */
1553
1740
  }
1554
1741
  return;
1555
1742
  }
@@ -1622,6 +1809,14 @@ export function createLiveHost(options) {
1622
1809
  torn = true;
1623
1810
  installed = false;
1624
1811
  pushToBlock = null;
1812
+ // Symmetric with the `addEventListener` in `install` — a listener that
1813
+ // outlived the host would keep pushing `ROUTE_CHANGED` at a block whose
1814
+ // host is gone (and, across a re-install, from two hosts at once).
1815
+ win.removeEventListener('popstate', onPopState);
1816
+ // The next install re-dispatches BLOCK_INIT and re-seeds the route from
1817
+ // its context, so neither piece of route state may survive this teardown.
1818
+ initDispatched = false;
1819
+ currentSubPath = '';
1625
1820
  for (const t of timers)
1626
1821
  clearTimeout(t);
1627
1822
  timers.clear();
@@ -1686,6 +1881,42 @@ export function createLiveHost(options) {
1686
1881
  function anonFallbackViewer() {
1687
1882
  return { id: 0, username: 'dev-live', signedIn: true };
1688
1883
  }
1884
+ /**
1885
+ * SITE-scope refusal on a path's FIRST segment, by DECODED value — the dev-host
1886
+ * mirror of the host's own `navigateSiteFirstSegmentIsRefused`
1887
+ * (civitai/civitai `src/components/AppBlocks/pageBlockHostLogic.ts`).
1888
+ *
1889
+ * `path` arrives already stripped of leading slashes by the caller, so segment 0
1890
+ * is `path.split('/')[0]`.
1891
+ *
1892
+ * TWO CHANNELS, and the second is the reason the host named the function
1893
+ * "…IsRefused" rather than "…IsApi":
1894
+ * 1. the segment DECODES to `api` (case-insensitively) — the `/api/*` rule the
1895
+ * published docs promise, which `%61pi` must not evade;
1896
+ * 2. the segment cannot be decoded at all (`%zz`, a bare `%`) — its meaning
1897
+ * cannot be established, so it is not pushed. A deliberate fail-closed
1898
+ * decision, and it means this returns `true` for a segment that is not `api`.
1899
+ *
1900
+ * ⚠️ IT IS THE CONTRACT MIRROR, NOT A SECURITY BOUNDARY. The host's hostile-path
1901
+ * battery (control characters, backslashes, `%2f`/`%5c`, the resolved-vs-sent
1902
+ * segment-structure rule, app containment) is deliberately NOT mirrored in this
1903
+ * harness — see the `NAVIGATE` case — so unlike the host, this cannot rely on
1904
+ * `%2f` already being refused and a decode here CAN introduce a separator
1905
+ * (`a%2fb` decodes to `a/b`). That costs nothing for the question being asked:
1906
+ * only whether segment 0 means `api`, and a decode that produces a separator
1907
+ * cannot turn a non-`api` segment into an `api` one.
1908
+ */
1909
+ function navigateSiteFirstSegmentIsRefused(path) {
1910
+ const first = path.split('/')[0] ?? '';
1911
+ let decoded;
1912
+ try {
1913
+ decoded = decodeURIComponent(first);
1914
+ }
1915
+ catch {
1916
+ return true;
1917
+ }
1918
+ return decoded.toLowerCase() === 'api';
1919
+ }
1689
1920
  /**
1690
1921
  * Extract the `snapshot` from a tRPC response. Handles the superjson
1691
1922
  * `{ result: { data: { json: T } } }` envelope AND the transformer-less
@@ -38,7 +38,7 @@
38
38
  * this from production code.
39
39
  */
40
40
  import { APP_STORAGE_ERROR_REQUEST_FAILED, APP_STORAGE_ERROR_USER_QUOTA_EXCEEDED, APP_STORAGE_ERROR_USER_ROW_LIMIT, APP_STORAGE_ERROR_VALUE_TOO_LARGE, APP_STORAGE_MAX_BYTES, APP_STORAGE_MAX_ROWS, APP_STORAGE_MAX_VALUE_BYTES, BrowsingLevel, SFW_LEVELS, } from '@civitai/app-sdk/blocks';
41
- import { consentUnavailablePayload, resolveUngrantableConsentNotice } from './consent.js';
41
+ import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
42
42
  import { hostContextWithTheme } from '../transport/transport.js';
43
43
  import { isRoutableRequestId } from '../transport/requestId.js';
44
44
  /**
@@ -831,6 +831,29 @@ export function createMockHost(options = {}) {
831
831
  const parentOrigin = win.location.origin;
832
832
  const originalParent = win.parent;
833
833
  let consentGranted = !!options.consentGranted;
834
+ /**
835
+ * Scopes granted by a `REQUEST_CONSENT` round-trip OTHER than
836
+ * `ai:write:budgeted` (which keeps its own flag, because `buzzBudget` is
837
+ * conditional on it and `setScenario` can toggle it).
838
+ *
839
+ * 🔴 WHY THIS EXISTS. The grant branch used to hand back exactly
840
+ * `[BUDGETED_SCOPE]` and nothing else, so on this host NO consent-gated
841
+ * scope but the money one could EVER appear on a token. That was invisible
842
+ * while nothing waited on a grant; it stops being invisible the moment the
843
+ * SDK does (`internal/withConsentRetry.ts`), because a block asking for
844
+ * `posts:write:self` would be "granted" a token that still lacks it and
845
+ * would sit out the full wait on a host that had already said yes. The real
846
+ * host grants the missing set it computed from the manifest, so modelling it
847
+ * as "grant what was asked for, filtered to the known vocabulary" is closer
848
+ * than the constant was — and a dev host that quietly diverges here is
849
+ * precisely what `./consent.js`'s header warns about.
850
+ */
851
+ const extraGrantedScopes = new Set();
852
+ /** Everything the CURRENT token carries. One reader, so the two cannot drift. */
853
+ const currentScopes = () => [
854
+ ...(consentGranted ? [BUDGETED_SCOPE] : []),
855
+ ...extraGrantedScopes,
856
+ ];
834
857
  let tokenSerial = 0;
835
858
  let submitCount = 0;
836
859
  // body + cost remembered per workflow so the succeeded snapshot can echo them.
@@ -851,7 +874,7 @@ export function createMockHost(options = {}) {
851
874
  tokenSerial += 1;
852
875
  return {
853
876
  raw: `${DEV_TOKEN}.${tokenSerial}`,
854
- scopes: consentGranted ? [BUDGETED_SCOPE] : [],
877
+ scopes: currentScopes(),
855
878
  expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
856
879
  ...(consentGranted ? { buzzBudget } : {}),
857
880
  };
@@ -913,11 +936,11 @@ export function createMockHost(options = {}) {
913
936
  // `createLiveHost` via ./consent.js so dev and prod cannot drift
914
937
  // on when this fires or what it names.
915
938
  const notice = resolveUngrantableConsentNotice(typed.payload?.scopes,
916
- // The scopes the CURRENT token carries. Computed inline rather
917
- // than read off `nextToken()` — that helper MINTS (it bumps
918
- // `tokenSerial`), so calling it here would burn a serial on a
919
- // path that issues no token.
920
- consentGranted ? [BUDGETED_SCOPE] : [],
939
+ // The scopes the CURRENT token carries. Read through
940
+ // `currentScopes()` rather than `nextToken()` — that helper MINTS
941
+ // (it bumps `tokenSerial`), so calling it here would burn a
942
+ // serial on a path that issues no token.
943
+ currentScopes(),
921
944
  // Nothing is grantable — that is what `consentGrantable:false`
922
945
  // MEANS. Passing the empty set here (rather than short-circuiting)
923
946
  // keeps this call identical in shape to the host's.
@@ -934,10 +957,45 @@ export function createMockHost(options = {}) {
934
957
  });
935
958
  return;
936
959
  }
937
- // Lazy-consent round-trip: grant the scope, then push a
960
+ // Lazy-consent round-trip: grant what the hint named, then push a
938
961
  // host-initiated TOKEN_REFRESH carrying it (the App's auto-resume
939
962
  // depends on seeing the new scope on its token).
940
- consentGranted = true;
963
+ //
964
+ // `scopes` is untrusted block input (it is where markup and 5 KB
965
+ // strings arrive), so it is filtered to the known vocabulary exactly
966
+ // as the refusal payload is — `isKnownBlockScope` is the same
967
+ // predicate both branches use.
968
+ //
969
+ // 🔴 `consentGranted` IS THE MONEY FLAG, SO IT IS GRANTED ONLY WHEN
970
+ // ASKED FOR. It puts `ai:write:budgeted` AND `buzzBudget` on every
971
+ // token this host mints from here on. Setting it unconditionally
972
+ // meant a `posts:write:self` request also handed out the money
973
+ // scope, which made the PARTIAL-GRANT case — the viewer granting the
974
+ // one permission the block asked for and nothing else — unreachable
975
+ // in `pnpm dev`, so every local run exercised the one shape that
976
+ // hides a missing-scope bug.
977
+ //
978
+ // ⚠️ THE `!granted.length` FALLBACK IS LOAD-BEARING, not tidiness:
979
+ // `requestConsent()` with NO payload is documented as legitimate (the
980
+ // real host already knows the missing set it computed at mint), and
981
+ // so is a hint that survives no filtering. Both must keep granting
982
+ // the default money scope, which is the pre-existing behaviour — a
983
+ // grant branch that granted nothing at all would be a silent dead
984
+ // end of exactly the kind `consentGrantable` was added to remove.
985
+ {
986
+ const hint = typed.payload
987
+ ?.scopes;
988
+ const granted = Array.isArray(hint)
989
+ ? hint.filter((s) => typeof s === 'string' && isKnownBlockScope(s))
990
+ : [];
991
+ if (granted.length === 0 || granted.includes(BUDGETED_SCOPE)) {
992
+ consentGranted = true;
993
+ }
994
+ for (const s of granted) {
995
+ if (s !== BUDGETED_SCOPE)
996
+ extraGrantedScopes.add(s);
997
+ }
998
+ }
941
999
  after(0, () => {
942
1000
  dispatchToBlock({ type: 'TOKEN_REFRESH', payload: { token: nextToken() } });
943
1001
  });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A minimal stand-in for the HTML popover API, for NON-BROWSER DOMs.
3
+ *
4
+ * WHY IT EXISTS (#485). `jsdom` and `happy-dom` do not implement the popover
5
+ * API at ANY version we could find: `showPopover`, `hidePopover` and
6
+ * `togglePopover` are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike.
7
+ * `:popover-open` is worse than absent — it is unreliable in a way that is NOT a
8
+ * property of the runner's version: under jsdom it resolves through `nwsapi`, so
9
+ * the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
10
+ * and returns `false` depending on which `nwsapi` a lockfile pulled in (measured
11
+ * `false` on nwsapi 2.2.28). Anything that calls into that API therefore explodes,
12
+ * or lies, in the one environment an App Block's own test suite runs in.
13
+ *
14
+ * 🔴 WHAT THIS DOES **NOT** DO, and you must read this before using it.
15
+ *
16
+ * It does not make a shadow-DOM component fully driveable under happy-dom.
17
+ * Measured on happy-dom 20.9.0: a click on a light-DOM child assigned to a
18
+ * `<slot>` bubbles to the HOST (a listener there fires 1x) but a listener on the
19
+ * `<slot>` ELEMENT ITSELF fires **0x**. Lit binds `@click` to the `<slot>`, so
20
+ * `<civitai-menu>`'s trigger click is SILENT there: no throw, no open, nothing.
21
+ * That is a property of happy-dom's event path through the flattened tree and
22
+ * this shim cannot fix it — installing it changes the count from 0 to 0. (jsdom
23
+ * 25 and 30 both DO deliver it, which is why this is probed rather than asserted.)
24
+ *
25
+ * So under a shimmed DOM you must drive overlay elements through their METHODS
26
+ * (`menu.show()` / `menu.hide()`), never by clicking the trigger. {@link
27
+ * installPopoverShim} PROBES for this on install and returns the result as
28
+ * {@link PopoverShimHandle.slottedClicksReachSlots}, warning loudly when it is
29
+ * false, because a shim that quietly made `show()` work while `click()` no-ops
30
+ * would read as "this element is testable now" while delivering half of it.
31
+ *
32
+ * Other deliberate deviations from the platform, all of them narrow:
33
+ * - `toggle` is dispatched in a MICROTASK, where the platform queues a task.
34
+ * A microtask flushes before the next `await`, which is what makes it
35
+ * observable after `await el.updateComplete` in a test; a real task would
36
+ * need a `setTimeout` round-trip. `beforetoggle` is not dispatched at all.
37
+ * - There is no top layer, no anchor positioning, and no LIGHT DISMISS: a
38
+ * click outside a shown popover does not close it. Those need layout and a
39
+ * hit-testing event path, neither of which a non-browser DOM has. Light
40
+ * dismiss is one of the two reasons `<civitai-menu>` uses popover at all, so
41
+ * if that is what you are testing, use a real browser.
42
+ * - `popover="manual"` vs `"auto"` is not distinguished (there being no light
43
+ * dismiss to distinguish them by).
44
+ */
45
+ /** What {@link installPopoverShim} hands back. */
46
+ export interface PopoverShimHandle {
47
+ /**
48
+ * `true` when this shim was needed — i.e. the DOM did not already have a
49
+ * popover API. `false` means nothing was patched, which is the correct result
50
+ * in a real browser, and makes the call safe to make unconditionally in a
51
+ * setup file shared between a happy-dom project and a browser-mode project.
52
+ */
53
+ installed: boolean;
54
+ /**
55
+ * 🔴 Whether a click on a slotted light-DOM element reaches a listener on the
56
+ * `<slot>` it is assigned to — measured, on install, with a throwaway element.
57
+ *
58
+ * `true` in a real browser. `false` on happy-dom 20.9.0, and while it is
59
+ * `false` an element that binds its handlers to a `<slot>` (every Lit
60
+ * component that does, `<civitai-menu>`'s trigger included) cannot be driven
61
+ * by clicking. Drive it through its methods instead.
62
+ */
63
+ slottedClicksReachSlots: boolean;
64
+ /** Restores whatever was on the prototypes before. Idempotent. */
65
+ uninstall(): void;
66
+ }
67
+ /** Options for {@link installPopoverShim}. */
68
+ export interface PopoverShimOptions {
69
+ /**
70
+ * Suppress the `console.warn` fired when slotted clicks do not reach slot
71
+ * listeners. The measurement is still returned on the handle. Default `false`
72
+ * — the warning is the point, so silence it only once you have read it.
73
+ */
74
+ quiet?: boolean;
75
+ }
76
+ /**
77
+ * Install the popover shim on the current global DOM. Call it once, in a vitest
78
+ * `setupFiles` entry or at the top of a test file, BEFORE the elements render.
79
+ *
80
+ * Safe and inert in a real browser: it detects a working popover API and patches
81
+ * nothing (`installed: false`).
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import { installPopoverShim } from '@civitai/blocks-react/testing';
86
+ *
87
+ * const shim = installPopoverShim();
88
+ * // Drive overlay elements through their methods — NOT by clicking the trigger.
89
+ * menu.show();
90
+ * await menu.updateComplete;
91
+ * ```
92
+ */
93
+ export declare function installPopoverShim(options?: PopoverShimOptions): PopoverShimHandle;
94
+ //# sourceMappingURL=popoverShim.d.ts.map