@civitai/blocks-react 0.60.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.
@@ -1,14 +1,40 @@
1
1
  import type { BlockCheckpointInfo } from '@civitai/app-sdk/blocks';
2
2
  /** What {@link useCheckpointPicker} returns. */
3
3
  export interface UseCheckpointPicker {
4
- open: (opts: {
4
+ open: (opts?: {
5
5
  /**
6
- * Ecosystem key (e.g. 'Flux1', 'SDXL'). Get it from
7
- * `useBlockContext().context.checkpoint?.baseModel` — but for the
8
- * picker filter the host will collapse to the ecosystem family, so
9
- * any baseModel in the family works as a hint.
6
+ * 🔴 OMIT THIS BY DEFAULT. It is an ecosystem-family FILTER, not a label:
7
+ * the host HIDES every checkpoint outside the family you pass. Passing the
8
+ * family you are already in is therefore a trap — it makes the picker offer
9
+ * only the ecosystem the user is trying to leave, and every other family
10
+ * becomes unreachable for the life of the session.
11
+ *
12
+ * Omit it for an unconstrained pick: the host applies no base-model
13
+ * narrowing and offers every checkpoint the viewer can generate with.
14
+ *
15
+ * Pass it ONLY when the block must stay inside a family it already holds —
16
+ * a regenerate/variation flow pinned to one checkpoint's ecosystem, say —
17
+ * and then DERIVE it from that checkpoint (`checkpoint.baseModel`, or
18
+ * `useBlockContext().context.checkpoint?.baseModel`). Never a hardcoded
19
+ * ecosystem string: a literal pins every viewer of the app to whichever
20
+ * family the author happened to be testing with. Accepts an ecosystem key
21
+ * (e.g. 'Flux1', 'SDXL') or any baseModel name in the family; the host
22
+ * collapses either to the ecosystem family.
23
+ *
24
+ * An empty string is normalized to absent here and never reaches the wire,
25
+ * and `''` is **not** an escape hatch — it does not even mean the same thing
26
+ * on both hosts. On a **model slot** the host normalises whatever string you
27
+ * send, so `''` resolves to the real ecosystem key `Other` and NARROWS to
28
+ * that one family. On a **page** the host drops a zero-length value, so `''`
29
+ * behaves exactly like omitting it. Neither is what you meant on at least one
30
+ * surface: omit the key, or pass a family derived from a real checkpoint, and
31
+ * never `''`.
32
+ *
33
+ * A WHITESPACE-ONLY string narrows on BOTH hosts — the page host's guard is
34
+ * `length > 0`, which `' '` passes — which is why this hook trims before
35
+ * deciding.
10
36
  */
11
- baseModelGroup: string;
37
+ baseModelGroup?: string;
12
38
  /** Currently-selected versionId so the picker can pre-highlight it. */
13
39
  currentVersionId?: number;
14
40
  }) => Promise<{
@@ -19,9 +45,10 @@ export interface UseCheckpointPicker {
19
45
  /**
20
46
  * Drives the platform-side Checkpoint picker and the persist-override flow.
21
47
  *
22
- * `open` opens the host's Resource picker filtered to Checkpoints in the
23
- * given ecosystem; resolves with `{ selected }` (undefined when the user
24
- * dismissed without picking).
48
+ * `open` opens the host's Resource picker on Checkpoints and resolves with
49
+ * `{ selected }` (undefined when the user dismissed without picking). By
50
+ * default the pick is UNCONSTRAINED — every family the viewer can generate
51
+ * with. Pass `baseModelGroup` only to pin it to one ecosystem.
25
52
  *
26
53
  * `persist` writes the chosen versionId into `block_user_settings` via the
27
54
  * host. Pass `null` to clear the override and fall back to the publisher
@@ -33,9 +60,15 @@ export interface UseCheckpointPicker {
33
60
  * useBuzzWorkflow.
34
61
  *
35
62
  * @example
63
+ * // DEFAULT — no ecosystem. The viewer can reach every family.
36
64
  * const { open, persist } = useCheckpointPicker();
37
- * const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
65
+ * const { selected } = await open({ currentVersionId: checkpoint.versionId });
38
66
  * if (selected) await persist(selected.versionId); // null clears the override
67
+ *
68
+ * @example
69
+ * // ONLY when the block must stay inside a family it already holds: derive the
70
+ * // filter from that checkpoint, never from a hardcoded ecosystem.
71
+ * const { selected } = await open({ baseModelGroup: checkpoint.baseModel });
39
72
  */
40
73
  export declare function useCheckpointPicker(): UseCheckpointPicker;
41
74
  //# sourceMappingURL=useCheckpointPicker.d.ts.map
@@ -6,9 +6,10 @@ import { sendTypedRequest } from '../transport/transport.js';
6
6
  /**
7
7
  * Drives the platform-side Checkpoint picker and the persist-override flow.
8
8
  *
9
- * `open` opens the host's Resource picker filtered to Checkpoints in the
10
- * given ecosystem; resolves with `{ selected }` (undefined when the user
11
- * dismissed without picking).
9
+ * `open` opens the host's Resource picker on Checkpoints and resolves with
10
+ * `{ selected }` (undefined when the user dismissed without picking). By
11
+ * default the pick is UNCONSTRAINED — every family the viewer can generate
12
+ * with. Pass `baseModelGroup` only to pin it to one ecosystem.
12
13
  *
13
14
  * `persist` writes the chosen versionId into `block_user_settings` via the
14
15
  * host. Pass `null` to clear the override and fall back to the publisher
@@ -20,17 +21,47 @@ import { sendTypedRequest } from '../transport/transport.js';
20
21
  * useBuzzWorkflow.
21
22
  *
22
23
  * @example
24
+ * // DEFAULT — no ecosystem. The viewer can reach every family.
23
25
  * const { open, persist } = useCheckpointPicker();
24
- * const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
26
+ * const { selected } = await open({ currentVersionId: checkpoint.versionId });
25
27
  * if (selected) await persist(selected.versionId); // null clears the override
28
+ *
29
+ * @example
30
+ * // ONLY when the block must stay inside a family it already holds: derive the
31
+ * // filter from that checkpoint, never from a hardcoded ecosystem.
32
+ * const { selected } = await open({ baseModelGroup: checkpoint.baseModel });
26
33
  */
27
34
  export function useCheckpointPicker() {
28
35
  const open = useCallback(async (opts) => {
36
+ // Both keys are spread conditionally, and an empty or whitespace-only
37
+ // family is normalized to absent.
38
+ //
39
+ // The distinction that matters is `''`/whitespace vs ABSENT — NOT
40
+ // explicit-`undefined` vs absent. The model-slot host branches on
41
+ // `typeof baseModelGroup === 'string'`, and `typeof undefined === 'string'`
42
+ // is false, so a present-and-undefined key takes exactly the same "no
43
+ // family" branch as an absent one on every host surface. A non-empty string
44
+ // does not: it IS a string, so it survives that branch, and
45
+ // `getBaseModelGroup` collapses an unrecognised value to the real ecosystem
46
+ // key 'Other' — which NARROWS the picker to that one family instead of
47
+ // widening it.
48
+ //
49
+ // 🔴 THE TWO SPELLINGS DIFFER BY HOST, so this normalization is doing two
50
+ // different jobs:
51
+ // - `''` narrows on the MODEL SLOT (any string is normalised there) but
52
+ // is already equivalent to omission on the PAGE host, whose resolver
53
+ // drops a zero-length value before the lookup. Stripping it matters on
54
+ // one surface and is a no-op on the other.
55
+ // - a WHITESPACE-ONLY string narrows on BOTH: the page host's guard is
56
+ // `length > 0`, which `' '` passes. That is what `.trim()` is for, and
57
+ // it is the half a test on `''` alone cannot see.
58
+ // Absence is the only spelling that means "unconstrained" on both surfaces.
59
+ const baseModelGroup = opts?.baseModelGroup?.trim();
29
60
  const { selected } = await sendTypedRequest(getTransport(), {
30
61
  type: 'OPEN_CHECKPOINT_PICKER',
31
62
  payload: {
32
- baseModelGroup: opts.baseModelGroup,
33
- ...(opts.currentVersionId != null
63
+ ...(baseModelGroup ? { baseModelGroup } : {}),
64
+ ...(opts?.currentVersionId != null
34
65
  ? { currentVersionId: opts.currentVersionId }
35
66
  : {}),
36
67
  },
@@ -1,17 +1,68 @@
1
+ import type { BlockNavigateScope } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * Options for {@link UseCivitaiNavigate.navigate}.
4
+ *
5
+ * Passed as the SECOND argument, where a bare `'current' | 'new_tab'` string is
6
+ * also still accepted — see {@link UseCivitaiNavigate.navigate}.
7
+ */
8
+ export interface UseCivitaiNavigateOptions {
9
+ /**
10
+ * Which SPACE `path` is resolved in. DEFAULTS to `'app'` — this app's own
11
+ * sub-paths, which is all `navigate` could reach before this field existed.
12
+ *
13
+ * 🔴 `'/models/12345'` WITHOUT a scope does NOT reach civitai's model page. A
14
+ * leading slash carries no meaning: the host normalises it away in both
15
+ * scopes, so that call is a request for `<this app>/models/12345`. To reach
16
+ * the civitai.com page, say `{ scope: 'site' }`.
17
+ */
18
+ scope?: BlockNavigateScope;
19
+ /** Where the host should open it. Defaults to `'current'`. */
20
+ target?: 'current' | 'new_tab';
21
+ }
1
22
  /** What {@link useCivitaiNavigate} returns. */
2
23
  export interface UseCivitaiNavigate {
3
- navigate: (path: string, target?: 'current' | 'new_tab') => void;
24
+ /**
25
+ * Requests a navigation. The second argument is either an options object or —
26
+ * for the call shape that predates `scope` — a bare target string.
27
+ */
28
+ navigate: (path: string, options?: 'current' | 'new_tab' | UseCivitaiNavigateOptions) => void;
4
29
  }
5
30
  /**
6
- * Requests a navigation within civitai.com. The host mediates — `target:
7
- * "current"` navigates the parent frame; `"new_tab"` opens a new tab (which
8
- * requires `allow-popups-to-escape-sandbox` in the manifest sandbox).
31
+ * Requests a navigation from the host: the hook sends a `NAVIGATE` message and
32
+ * returns. Fire-and-forget — the host doesn't reply, so the block never learns
33
+ * what the host did, including when the host REFUSES the request.
34
+ *
35
+ * 🔴 `scope` SELECTS THE SPACE, AND IT DEFAULTS TO `'app'`.
36
+ *
37
+ * - `'app'` (default) — `path` is resolved under this app's OWN route, as a
38
+ * sub-path of it, and pushed shallowly so the page stays mounted.
39
+ * - `'site'` — `path` is resolved at the civitai.com root and the viewer leaves
40
+ * the app. The host grants this per-surface and refuses it elsewhere.
41
+ *
42
+ * 🔴 A LEADING SLASH CARRIES NO MEANING — the host normalises it away in BOTH
43
+ * scopes, so `'/settings'` and `'settings'` are one request within whichever
44
+ * scope you chose. `navigate('/models/12345')` is therefore a request for THIS
45
+ * APP's `/models/12345`, not civitai's model page; that needs
46
+ * `{ scope: 'site' }`. (Both spellings were app-scoped before `scope` existed
47
+ * too, so no existing call changed meaning — which is the point of the default.)
48
+ *
49
+ * `target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
50
+ * `'new_tab'` is host-side behaviour, and the host is the authority on it; this
51
+ * package sends the message and makes no promise about the outcome.
9
52
  *
10
- * Fire-and-forget: the host doesn't reply with confirmation.
53
+ * 🔴 Nothing in your manifest enables `'new_tab'`. In particular, do NOT declare
54
+ * `allow-popups-to-escape-sandbox`: the host intersects a manifest's
55
+ * `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
56
+ * is dropped for every block at every trust tier and declaring it has no effect.
57
+ * (Earlier versions of this doc said `"new_tab"` required it — that was wrong.)
11
58
  *
12
59
  * @example
13
60
  * const { navigate } = useCivitaiNavigate();
14
- * navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandbox
61
+ * navigate('settings'); // this app's own /settings
62
+ * navigate('/settings'); // identical — the slash means nothing
63
+ * navigate('models/12345', { scope: 'site' }); // civitai.com/models/12345
64
+ * navigate('models/12345', { scope: 'site', target: 'new_tab' });
65
+ * navigate('detail/7', 'new_tab'); // the pre-`scope` shape, still app-scoped
15
66
  */
16
67
  export declare function useCivitaiNavigate(): UseCivitaiNavigate;
17
68
  //# sourceMappingURL=useCivitaiNavigate.d.ts.map
@@ -1,19 +1,62 @@
1
1
  import { useCallback } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  /**
4
- * Requests a navigation within civitai.com. The host mediates — `target:
5
- * "current"` navigates the parent frame; `"new_tab"` opens a new tab (which
6
- * requires `allow-popups-to-escape-sandbox` in the manifest sandbox).
4
+ * Requests a navigation from the host: the hook sends a `NAVIGATE` message and
5
+ * returns. Fire-and-forget — the host doesn't reply, so the block never learns
6
+ * what the host did, including when the host REFUSES the request.
7
7
  *
8
- * Fire-and-forget: the host doesn't reply with confirmation.
8
+ * 🔴 `scope` SELECTS THE SPACE, AND IT DEFAULTS TO `'app'`.
9
+ *
10
+ * - `'app'` (default) — `path` is resolved under this app's OWN route, as a
11
+ * sub-path of it, and pushed shallowly so the page stays mounted.
12
+ * - `'site'` — `path` is resolved at the civitai.com root and the viewer leaves
13
+ * the app. The host grants this per-surface and refuses it elsewhere.
14
+ *
15
+ * 🔴 A LEADING SLASH CARRIES NO MEANING — the host normalises it away in BOTH
16
+ * scopes, so `'/settings'` and `'settings'` are one request within whichever
17
+ * scope you chose. `navigate('/models/12345')` is therefore a request for THIS
18
+ * APP's `/models/12345`, not civitai's model page; that needs
19
+ * `{ scope: 'site' }`. (Both spellings were app-scoped before `scope` existed
20
+ * too, so no existing call changed meaning — which is the point of the default.)
21
+ *
22
+ * `target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
23
+ * `'new_tab'` is host-side behaviour, and the host is the authority on it; this
24
+ * package sends the message and makes no promise about the outcome.
25
+ *
26
+ * 🔴 Nothing in your manifest enables `'new_tab'`. In particular, do NOT declare
27
+ * `allow-popups-to-escape-sandbox`: the host intersects a manifest's
28
+ * `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
29
+ * is dropped for every block at every trust tier and declaring it has no effect.
30
+ * (Earlier versions of this doc said `"new_tab"` required it — that was wrong.)
9
31
  *
10
32
  * @example
11
33
  * const { navigate } = useCivitaiNavigate();
12
- * navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandbox
34
+ * navigate('settings'); // this app's own /settings
35
+ * navigate('/settings'); // identical — the slash means nothing
36
+ * navigate('models/12345', { scope: 'site' }); // civitai.com/models/12345
37
+ * navigate('models/12345', { scope: 'site', target: 'new_tab' });
38
+ * navigate('detail/7', 'new_tab'); // the pre-`scope` shape, still app-scoped
13
39
  */
14
40
  export function useCivitaiNavigate() {
15
- const navigate = useCallback((path, target = 'current') => {
16
- getTransport().sendMessage({ type: 'NAVIGATE', payload: { path, target } });
41
+ const navigate = useCallback((path, options = {}) => {
42
+ // A bare string is the pre-`scope` call shape (`navigate(path, 'new_tab')`)
43
+ // and stays supported: there are live callers, and the whole point of the
44
+ // `'app'` default is that none of them changes meaning. Widening the
45
+ // parameter rather than adding an overload keeps ONE published signature,
46
+ // so `UseCivitaiNavigate` still describes the hook exactly.
47
+ const opts = typeof options === 'string' ? { target: options } : options;
48
+ // `scope` is OMITTED, not sent as `undefined`, when the caller did not
49
+ // choose one — absent and `'app'` mean the same thing to the host, so the
50
+ // payload an unscoped call puts on the wire stays byte-identical to what
51
+ // every pre-`scope` build sent.
52
+ getTransport().sendMessage({
53
+ type: 'NAVIGATE',
54
+ payload: {
55
+ path,
56
+ ...(opts.scope ? { scope: opts.scope } : {}),
57
+ target: opts.target ?? 'current',
58
+ },
59
+ });
17
60
  }, []);
18
61
  return { navigate };
19
62
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What {@link useCivitaiRoute} returns. An alias — see `./returnTypeLedger.js`
3
+ * for why every hook on the entry has one of these.
4
+ */
5
+ export type UseCivitaiRoute = string;
6
+ /**
7
+ * The sub-path below your app's root that is CURRENTLY showing, kept live for
8
+ * the whole life of the block ON THE IFRAME TRANSPORT.
9
+ *
10
+ * The page surface owns the browser history; your block does not. You ask for a
11
+ * move with {@link useCivitaiNavigate} (`scope: 'app'`, the default), the host
12
+ * pushes it shallowly so your frame stays mounted — and this is how you learn
13
+ * where you ended up. It also reports the moves you did NOT ask for: the
14
+ * viewer's own back/forward, and a deep link the host resolved after init.
15
+ *
16
+ * Reads the SAME singleton transport snapshot {@link useBlockContext} does, so
17
+ * it re-renders when the value changes. Two things set it:
18
+ *
19
+ * 1. `BLOCK_INIT` — `context.subPath`, the host's value at mount. The FIRST
20
+ * value always arrives here, never over a message;
21
+ * 2. `ROUTE_CHANGED`, the host's push on every later change.
22
+ *
23
+ * It is the same value as `useBlockContext().context.subPath` on a page slot —
24
+ * reach for this when the route is all you need, and because this hook's return
25
+ * type is a plain `string` rather than a field on a union you have to narrow.
26
+ *
27
+ * ```tsx
28
+ * const subPath = useCivitaiRoute(); // '' on your app's index
29
+ * const [view, id] = subPath.split('/'); // 'compare/42' → ['compare', '42']
30
+ * ```
31
+ *
32
+ * 🔴 `''` IS A REAL ROUTE — your app's own index — AND IT IS ALSO THE PRE-INIT
33
+ * SENTINEL. The two are indistinguishable from this hook alone, exactly as
34
+ * `'light'` is both a real theme and {@link useBlockTheme}'s pre-init value.
35
+ * Gate on `useBlockContext().ready` if your first paint must tell them apart.
36
+ *
37
+ * 🔴 NO LEADING SLASH, and no slash-tolerance to lean on. The host sends the
38
+ * segment below your app root — `'compare/42'`, not `'/compare/42'` — so
39
+ * `subPath === 'compare/42'` is the comparison that works and
40
+ * `subPath === '/compare/42'` is the one that silently never matches.
41
+ *
42
+ * 🔴 PAGE SLOT ONLY. A model-page slot has no route of its own, so this returns
43
+ * `''` there and never moves. It is not a defect to debug: the host's own
44
+ * `ROUTE_CHANGED` effect lives in `PageBlockHost`, and `ModelSlotContext` has no
45
+ * `subPath` field for it to update.
46
+ *
47
+ * 🔴 OLD HOST: a host that never sends `ROUTE_CHANGED` simply never moves the
48
+ * value — the hook degrades to the init sub-path, which is the behaviour every
49
+ * page block had before the message existed. Nothing here awaits a message, so
50
+ * there is no hang and no timeout.
51
+ *
52
+ * 🔴 INLINE TRANSPORT: the value is FROZEN at the init sub-path. v1 inline mode
53
+ * receives no host pushes at all (`InlineTransport.onMessage` is a stub and
54
+ * `subscribe` is a no-op, so nothing can emit), the same degradation as an old
55
+ * host, and it lifts when v2 inline mode lands.
56
+ *
57
+ * 🔴 READ IT ON EVERY RENDER. A block that copies the value into state once at
58
+ * mount — or routes imperatively in a mount-only effect — stays on the route it
59
+ * started with and reproduces the symptom this message exists to end: the URL
60
+ * moves and nothing renders.
61
+ */
62
+ export declare function useCivitaiRoute(): UseCivitaiRoute;
63
+ //# sourceMappingURL=useCivitaiRoute.d.ts.map
@@ -0,0 +1,71 @@
1
+ import { useTransportSnapshot } from './useBlockContext.js';
2
+ /**
3
+ * The sub-path below your app's root that is CURRENTLY showing, kept live for
4
+ * the whole life of the block ON THE IFRAME TRANSPORT.
5
+ *
6
+ * The page surface owns the browser history; your block does not. You ask for a
7
+ * move with {@link useCivitaiNavigate} (`scope: 'app'`, the default), the host
8
+ * pushes it shallowly so your frame stays mounted — and this is how you learn
9
+ * where you ended up. It also reports the moves you did NOT ask for: the
10
+ * viewer's own back/forward, and a deep link the host resolved after init.
11
+ *
12
+ * Reads the SAME singleton transport snapshot {@link useBlockContext} does, so
13
+ * it re-renders when the value changes. Two things set it:
14
+ *
15
+ * 1. `BLOCK_INIT` — `context.subPath`, the host's value at mount. The FIRST
16
+ * value always arrives here, never over a message;
17
+ * 2. `ROUTE_CHANGED`, the host's push on every later change.
18
+ *
19
+ * It is the same value as `useBlockContext().context.subPath` on a page slot —
20
+ * reach for this when the route is all you need, and because this hook's return
21
+ * type is a plain `string` rather than a field on a union you have to narrow.
22
+ *
23
+ * ```tsx
24
+ * const subPath = useCivitaiRoute(); // '' on your app's index
25
+ * const [view, id] = subPath.split('/'); // 'compare/42' → ['compare', '42']
26
+ * ```
27
+ *
28
+ * 🔴 `''` IS A REAL ROUTE — your app's own index — AND IT IS ALSO THE PRE-INIT
29
+ * SENTINEL. The two are indistinguishable from this hook alone, exactly as
30
+ * `'light'` is both a real theme and {@link useBlockTheme}'s pre-init value.
31
+ * Gate on `useBlockContext().ready` if your first paint must tell them apart.
32
+ *
33
+ * 🔴 NO LEADING SLASH, and no slash-tolerance to lean on. The host sends the
34
+ * segment below your app root — `'compare/42'`, not `'/compare/42'` — so
35
+ * `subPath === 'compare/42'` is the comparison that works and
36
+ * `subPath === '/compare/42'` is the one that silently never matches.
37
+ *
38
+ * 🔴 PAGE SLOT ONLY. A model-page slot has no route of its own, so this returns
39
+ * `''` there and never moves. It is not a defect to debug: the host's own
40
+ * `ROUTE_CHANGED` effect lives in `PageBlockHost`, and `ModelSlotContext` has no
41
+ * `subPath` field for it to update.
42
+ *
43
+ * 🔴 OLD HOST: a host that never sends `ROUTE_CHANGED` simply never moves the
44
+ * value — the hook degrades to the init sub-path, which is the behaviour every
45
+ * page block had before the message existed. Nothing here awaits a message, so
46
+ * there is no hang and no timeout.
47
+ *
48
+ * 🔴 INLINE TRANSPORT: the value is FROZEN at the init sub-path. v1 inline mode
49
+ * receives no host pushes at all (`InlineTransport.onMessage` is a stub and
50
+ * `subscribe` is a no-op, so nothing can emit), the same degradation as an old
51
+ * host, and it lifts when v2 inline mode lands.
52
+ *
53
+ * 🔴 READ IT ON EVERY RENDER. A block that copies the value into state once at
54
+ * mount — or routes imperatively in a mount-only effect — stays on the route it
55
+ * started with and reproduces the symptom this message exists to end: the URL
56
+ * moves and nothing renders.
57
+ */
58
+ export function useCivitaiRoute() {
59
+ const context = useTransportSnapshot().context;
60
+ // A PRESENCE test, not a slot-id test, and not a cast. `BlockContext` is a
61
+ // union whose `UnknownSlotContext` arm declares `slotId` and nothing else, and
62
+ // the pre-init `EMPTY_SNAPSHOT.context` is exactly that shape — so there is
63
+ // genuinely no field to read before init, or on any slot but the page. The
64
+ // `typeof` half is not belt-and-braces either: `UnknownSlotContext` would let
65
+ // a host put any value on that key, and this hook's published return type is
66
+ // `string`.
67
+ if ('subPath' in context && typeof context.subPath === 'string')
68
+ return context.subPath;
69
+ return '';
70
+ }
71
+ //# sourceMappingURL=useCivitaiRoute.js.map
@@ -1,4 +1,5 @@
1
1
  import type { BlockCreatePostHostError, BlockCreatePostRequest, BlockCreatePostResult, BlockPostSource } from '@civitai/app-sdk/blocks';
2
+ import type { ConsentRetryOptions } from './consentRetryOptions.js';
2
3
  export type { BlockCreatePostHostError, BlockCreatePostRequest, BlockCreatePostResult, BlockPostSource, };
3
4
  /**
4
5
  * The closed set of HOST refusal codes, as a runtime Set.
@@ -75,7 +76,7 @@ export interface UseCreatePostFromApp {
75
76
  * `declined`, which means the viewer dismissed the confirm and NO POST EXISTS.
76
77
  * Check `.declined` before rendering a failure.
77
78
  */
78
- createPost: (args: BlockCreatePostRequest) => Promise<BlockCreatePostResult>;
79
+ createPost: (args: BlockCreatePostRequest, options?: ConsentRetryOptions) => Promise<BlockCreatePostResult>;
79
80
  /** `true` while a request is in flight (including the viewer's confirm). */
80
81
  pending: boolean;
81
82
  /**
@@ -1,7 +1,17 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
3
+ import { withConsentRetry } from '../internal/withConsentRetry.js';
2
4
  import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
5
  import { getTransport } from '../transport/singleton.js';
4
6
  import { RequestTimeoutError, sendTypedRequest } from '../transport/transport.js';
7
+ /**
8
+ * The consent-gated scope this bridge requires — `posts:write:self`, named in
9
+ * `@civitai/app-sdk`'s own `CREATE_POST_FROM_APP` message docs ("Scope
10
+ * `posts:write:self` (NOT `ai:write:budgeted`), which is SENSITIVE and
11
+ * CONSENT-GATED"). From {@link BLOCK_SCOPES}, never a literal: a typo would not
12
+ * error, it would make the automatic consent prompt silently do nothing.
13
+ */
14
+ const CREATE_POST_SCOPES = [BLOCK_SCOPES.POSTS_WRITE_SELF];
5
15
  /**
6
16
  * The closed set of HOST refusal codes, as a runtime Set.
7
17
  *
@@ -152,49 +162,92 @@ export function useCreatePostFromApp() {
152
162
  mountedRef.current = false;
153
163
  };
154
164
  }, []);
155
- const createPost = useCallback(async (args) => {
165
+ /** ONE round-trip + its result contract. Re-invoked verbatim on a consent retry. */
166
+ const createPostOnce = useCallback(async (args) => {
167
+ const reply = await sendTypedRequest(getTransport(), {
168
+ type: 'CREATE_POST_FROM_APP',
169
+ payload: {
170
+ sources: args.sources,
171
+ ...(args.title !== undefined ? { title: args.title } : {}),
172
+ ...(args.detail !== undefined ? { detail: args.detail } : {}),
173
+ ...(args.tags !== undefined ? { tags: args.tags } : {}),
174
+ ...(args.modelVersionId !== undefined
175
+ ? { modelVersionId: args.modelVersionId }
176
+ : {}),
177
+ },
178
+ }, 'CREATE_POST_RESULT',
179
+ // See the `'human'` bucketing in `transport/requestTimeouts.ts`: the
180
+ // host answers only when the viewer clicks or dismisses its confirm.
181
+ { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS }).catch((err) => {
182
+ // 🔴 THE `timedOut` STAMP LIVES HERE, INSIDE THE CLOSURE
183
+ // `withConsentRetry` RE-INVOKES — NOT IN `createPost`'s OUTER CATCH.
184
+ // That placement is the whole point and it is load-bearing: rule 4 of
185
+ // `internal/withConsentRetry.ts` ("never retry a keyless bridge's
186
+ // timeout") is read off THIS error, so a stamp applied outside the
187
+ // wrapper is invisible to it. Until #500 round 2 it was applied outside,
188
+ // and the consequence was exactly what `CreatePostError.timedOut`'s
189
+ // docstring exists to prevent: a raw `RequestTimeoutError` reached the
190
+ // helper carrying neither `declined` nor `timedOut`, so on a token
191
+ // lacking `posts:write:self` the SDK prompted and RE-SENT THE POST.
192
+ // `CREATE_POST_FROM_APP` has no `idempotencyKey` on the wire, so that
193
+ // second send is a genuine second write — a DUPLICATE PUBLIC POST under
194
+ // the viewer's name.
195
+ //
196
+ // Structural, not a message match — see `RequestTimeoutError`.
197
+ if (err instanceof RequestTimeoutError) {
198
+ throw new CreatePostError(err.message, { timedOut: true });
199
+ }
200
+ throw err instanceof Error ? err : new Error(String(err));
201
+ });
202
+ if (reply.error || !reply.result) {
203
+ // 🔴 `||`, NOT `??`. `isValidCreatePostResult` gates `error` on SHAPE
204
+ // only (it has to: the channel carries free-text server messages), so
205
+ // a host `error: ''` is a VALID reply that reaches here. `??` would
206
+ // then throw an Error with an EMPTY message for a public post, which
207
+ // renders as a blank failure. Fall through to a code that at least
208
+ // names a real outcome.
209
+ throw new CreatePostError(reply.error || 'no images to post');
210
+ }
211
+ return reply.result;
212
+ }, []);
213
+ const createPost = useCallback(async (args, options) => {
156
214
  if (mountedRef.current) {
157
215
  setPending(true);
158
216
  setError(null);
159
217
  }
160
218
  try {
161
- const reply = await sendTypedRequest(getTransport(), {
162
- type: 'CREATE_POST_FROM_APP',
163
- payload: {
164
- sources: args.sources,
165
- ...(args.title !== undefined ? { title: args.title } : {}),
166
- ...(args.detail !== undefined ? { detail: args.detail } : {}),
167
- ...(args.tags !== undefined ? { tags: args.tags } : {}),
168
- ...(args.modelVersionId !== undefined
169
- ? { modelVersionId: args.modelVersionId }
170
- : {}),
171
- },
172
- }, 'CREATE_POST_RESULT',
173
- // See the `'human'` bucketing in `transport/requestTimeouts.ts`: the
174
- // host answers only when the viewer clicks or dismisses its confirm.
175
- { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
176
- if (reply.error || !reply.result) {
177
- // 🔴 `||`, NOT `??`. `isValidCreatePostResult` gates `error` on SHAPE
178
- // only (it has to: the channel carries free-text server messages), so
179
- // a host `error: ''` is a VALID reply that reaches here. `??` would
180
- // then throw an Error with an EMPTY message for a public post, which
181
- // renders as a blank failure. Fall through to a code that at least
182
- // names a real outcome.
183
- throw new CreatePostError(reply.error || 'no images to post');
184
- }
185
- return reply.result;
219
+ // 🔴 INSIDE the try, not around it: the wrapper must sit between the
220
+ // round-trip and the `setError` below, so a first attempt that is
221
+ // recovered by a consent grant never leaves a failure in `error` for the
222
+ // UI to render next to a post that succeeded.
223
+ //
224
+ // No idempotency key is involved — `CREATE_POST_FROM_APP` has none on the
225
+ // wire, which is exactly why a TIMEOUT here must never be retried: there
226
+ // is nothing for the server to dedupe a second send against, so the retry
227
+ // would be a second public post. `createPostOnce` stamps `timedOut` on
228
+ // its own error for that reason, INSIDE this closure where rule 4 can see
229
+ // it. The remaining duplicate-protection is the host's own per-post
230
+ // confirm, which a consent retry re-opens exactly once; and a `declined`
231
+ // (the viewer dismissing that confirm) is never retried at all.
232
+ return await withConsentRetry(getTransport(), CREATE_POST_SCOPES, () => createPostOnce(args), options,
233
+ // Rule 3 in the time axis: a grant that lands after this component is
234
+ // gone must not publish a post nobody is left to see.
235
+ () => mountedRef.current);
186
236
  }
187
237
  catch (err) {
188
- // A transport timeout arrives as a plain Error; wrap it so callers have
189
- // ONE error type to test, with `.code` left undefined (it is not a host
190
- // refusal). Re-wrapping our own error would lose `.code`, so pass it
191
- // through.
238
+ // Wrap anything that is not already ours so callers have ONE error type
239
+ // to test, with `.code` left undefined (it is not a host refusal).
240
+ // Re-wrapping our own error would lose `.code` AND `.timedOut`, so pass
241
+ // it through.
242
+ //
243
+ // 🔴 NO `timedOut` STAMP HERE. It used to be applied at this line, which
244
+ // put it OUTSIDE `withConsentRetry` and made rule 4 unreachable for this
245
+ // bridge — the round-2 defect. `createPostOnce` owns the stamp now, so
246
+ // every transport timeout arrives here ALREADY a `CreatePostError` and
247
+ // takes the pass-through branch above.
192
248
  const wrapped = err instanceof CreatePostError
193
249
  ? err
194
- : new CreatePostError(err instanceof Error ? err.message : String(err), {
195
- // Structural, not a message match — see `RequestTimeoutError`.
196
- timedOut: err instanceof RequestTimeoutError,
197
- });
250
+ : new CreatePostError(err instanceof Error ? err.message : String(err));
198
251
  if (mountedRef.current)
199
252
  setError(wrapped);
200
253
  // 🔴 THROWN UNCONDITIONALLY, even when unmounted. The caller's `await`
@@ -1,3 +1,4 @@
1
+ import type { ConsentRetryOptions } from './consentRetryOptions.js';
1
2
  import type { Entitlement } from './useEntitlements.js';
2
3
  /** Which good to buy, and optionally the price the app showed the viewer. */
3
4
  export interface GoodPurchaseParams {
@@ -12,7 +13,7 @@ export interface GoodPurchaseParams {
12
13
  expectedPriceBuzz?: number;
13
14
  }
14
15
  /** Optional per-purchase controls. */
15
- export interface GoodPurchaseOptions {
16
+ export interface GoodPurchaseOptions extends ConsentRetryOptions {
16
17
  /**
17
18
  * A STABLE idempotency key for this logical purchase. Reuse the SAME value
18
19
  * when RETRYING a purchase whose response was lost (timeout / network drop)