@civitai/blocks-react 0.58.1 → 0.59.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.
- package/README.md +42 -0
- package/dist/hooks/useEntitlements.d.ts +112 -0
- package/dist/hooks/useEntitlements.js +176 -0
- package/dist/hooks/useGoodPurchase.d.ts +147 -0
- package/dist/hooks/useGoodPurchase.js +253 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -469,6 +469,48 @@ const { purchased, newBalance } = await openPurchaseModal(suggestedAmount);
|
|
|
469
469
|
if (purchased) { /* retry the generation */ }
|
|
470
470
|
```
|
|
471
471
|
|
|
472
|
+
### `useGoodPurchase()`
|
|
473
|
+
|
|
474
|
+
Sell a **digital good** — a manifest-declared entitlement the platform sells to the viewer for Buzz on your app's behalf. Requires the `goods:purchase:self` scope **and** a `goods` entry in your manifest; without both the endpoint answers 404.
|
|
475
|
+
|
|
476
|
+
```tsx
|
|
477
|
+
const { purchase, loading, error } = useGoodPurchase();
|
|
478
|
+
const { entitlement } = await purchase(
|
|
479
|
+
{ goodId: 'extra-slots', expectedPriceBuzz: 250 },
|
|
480
|
+
{ topUpOnInsufficientFunds: true },
|
|
481
|
+
);
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
🔴 **The platform renders no confirmation for the purchase itself** — this is a plain authed POST on the block token, so whatever the viewer confirms is *your* UI. Spend is bounded by the manifest-reviewed price and the viewer's daily cap, but a good can be priced near that cap where a tip cannot. Show the price and require an explicit action.
|
|
485
|
+
|
|
486
|
+
`{ topUpOnInsufficientFunds: true }` opens `useBuzzPurchase()` on an `insufficient_funds` refusal and retries **once with the same idempotency key** — but only if the viewer actually bought Buzz. **Pass `expectedPriceBuzz` with it**, or the modal opens with no suggested amount and the retry can re-refuse after real fiat was spent. It keys on the *reason*, never on "the call failed", so it never offers Buzz for a failure Buzz cannot fix.
|
|
487
|
+
|
|
488
|
+
Refusals reject with a `GoodPurchaseRefusal` carrying `status` and, where the server sends one, `reason`. `reason` is **`undefined`** for the endpoint's own refusals (404, 429, daily-cap 400, every idempotency refusal) — only service-level ones populate it, so always fall back to `status` and `message`.
|
|
489
|
+
|
|
490
|
+
Two rejections are **not** refusals, and `name` tells them apart: the 30s bound rejects with a plain `Error` naming the timeout — a real failure, the charge may have landed, retry with the **same** `idempotencyKey` — while an unmount rejects with `name === 'AbortError'`, the usual signal that the component navigated away and there is nothing to report. That split is decided by the **abort state**, so it holds wherever the abort lands, the body read included. The one exception runs the other way: a refusal the hook had already parsed stays a `GoodPurchaseRefusal` even if an abort fires in the same tick — `reason` is worth more than the abort wrapper. So ignoring `AbortError` never swallows a refusal or a timeout.
|
|
491
|
+
|
|
492
|
+
### `useEntitlements()`
|
|
493
|
+
|
|
494
|
+
What the viewer owns **from this app** — the read half of the goods rail. Scope `goods:read:self`, which is consent-exempt: the reply is scoped server-side to your own app, so a read-only block needs no purchase power and triggers no re-consent prompt.
|
|
495
|
+
|
|
496
|
+
```tsx
|
|
497
|
+
function PaidFeature() {
|
|
498
|
+
const { owns, loading, error, unauthenticated, refetch } = useEntitlements();
|
|
499
|
+
if (loading) return <Spinner />;
|
|
500
|
+
if (unauthenticated) return <SignInToBuy />; // a logged-out viewer
|
|
501
|
+
if (error) return <RetryNotice onRetry={refetch} />; // NOT "you own nothing"
|
|
502
|
+
return owns('extra-slots') ? <Unlocked /> : <BuyButton onDone={refetch} />;
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
🔴 **`owns()` returns `false` when the viewer owns nothing AND when the read failed**, so never gate paid content on it alone — check `loading`, `unauthenticated` and `error` first, in that order. A block that paywalls on `!owns(id)` takes away something the viewer paid for on every transient failure. `unauthenticated` exists because a **page** app is a public surface, so a logged-out viewer is the common path rather than an edge.
|
|
507
|
+
|
|
508
|
+
🔴 **`unauthenticated` is derived from the viewer, not from a response.** It is `isSignedIn(useBlockContext().viewer) === false` once `BLOCK_INIT` has landed — so it is known *before* any request, and an anonymous viewer costs **no round trip**: the hook skips the GET entirely and settles with `error === null`, because nothing failed. It stays `false` until init lands (a pre-init viewer is *unknown*, not absent), so a block that never gets embedded reaches its host-origin error rather than a sign-in screen.
|
|
509
|
+
|
|
510
|
+
🔴 **No 403 is ever read as "not signed in"** — and a predicate that tried to be was **dead code**. The endpoint runs under `withBlockScope(…, { requiredScope: 'goods:read:self' })`, whose `:self` arm rejects an anonymous subject as `code: 'context_binding'`, so there is no reachable uncoded 403 on this route; keying on `context_binding` instead would be worse, since the same code covers a wrong `modelId`. Every 403 therefore reaches `error` with the server's own wording — including the one you will actually hit, `insufficient_scope`, a manifest that forgot `goods:read:self`. Read `error.message`, not the status.
|
|
511
|
+
|
|
512
|
+
Call `refetch()` after a successful purchase to reflect it without a remount.
|
|
513
|
+
|
|
472
514
|
### `useBuzzBalance()`
|
|
473
515
|
|
|
474
516
|
The signed-in viewer's per-pool Buzz balance (`{ blue, green, yellow }` — the
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** One thing the viewer owns, as the platform recorded it. */
|
|
2
|
+
export interface Entitlement {
|
|
3
|
+
/** The `id` of the good from this app's manifest. */
|
|
4
|
+
goodId: string;
|
|
5
|
+
/** `good` for an ordinary purchase, `app_unlock` for a paid-app unlock. */
|
|
6
|
+
kind: string;
|
|
7
|
+
/** The opaque payload the manifest declared. The platform never interprets it. */
|
|
8
|
+
payload: Record<string, unknown>;
|
|
9
|
+
grantedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export interface UseEntitlements {
|
|
12
|
+
/**
|
|
13
|
+
* What the viewer owns, or `null` until the first successful fetch.
|
|
14
|
+
*
|
|
15
|
+
* Stays `null` for an anonymous viewer, who is never asked: see
|
|
16
|
+
* {@link UseEntitlements.unauthenticated}. `owns()` is `false` either way,
|
|
17
|
+
* which is the right answer for someone with no account to own anything on.
|
|
18
|
+
*/
|
|
19
|
+
entitlements: Entitlement[] | null;
|
|
20
|
+
/** `true` while a fetch (initial or `refetch`) is in flight. */
|
|
21
|
+
loading: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The last fetch's failure, or `null`. Cleared at the start of the next fetch.
|
|
24
|
+
* Also carries the NO-HOST-ORIGIN terminal state: if `BLOCK_INIT` never lands,
|
|
25
|
+
* `loading` drops to `false` and this becomes a named `Error` after
|
|
26
|
+
* {@link HOST_ORIGIN_WAIT_MS} rather than the hook spinning forever.
|
|
27
|
+
*/
|
|
28
|
+
error: Error | null;
|
|
29
|
+
/** Re-read entitlements (e.g. immediately after a successful purchase). */
|
|
30
|
+
refetch: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* `true` when there is NO SIGNED-IN VIEWER, so there is nobody for this app
|
|
33
|
+
* to have sold anything to.
|
|
34
|
+
*
|
|
35
|
+
* 🔴 THIS IS THE ONE CASE WHERE "you own nothing" IS THE RIGHT ANSWER, and
|
|
36
|
+
* without it the guidance on `error` produces the wrong screen. A page App
|
|
37
|
+
* Block is a PUBLIC surface: a logged-out viewer's block token carries an
|
|
38
|
+
* anonymous subject and the endpoint refuses it — so rendering a retry notice
|
|
39
|
+
* on `error` alone would show every anonymous first paint a button that can
|
|
40
|
+
* never succeed. Branch on this first: show the unpurchased/sign-in state,
|
|
41
|
+
* not a failure.
|
|
42
|
+
*
|
|
43
|
+
* 🔴 DERIVED FROM THE VIEWER, NOT FROM A RESPONSE, and that is the whole
|
|
44
|
+
* point. It is `isSignedIn(useBlockContext().viewer) === false` once
|
|
45
|
+
* `BLOCK_INIT` has landed — `null` viewer means anonymous, the one wire value
|
|
46
|
+
* every host version agrees on (`ViewerInfo` / `isSignedIn` in
|
|
47
|
+
* `@civitai/app-sdk/blocks` carry the adjudication). Three consequences worth
|
|
48
|
+
* knowing:
|
|
49
|
+
*
|
|
50
|
+
* - It is knowable BEFORE any request fires, so an anonymous viewer costs
|
|
51
|
+
* no round trip: the hook SKIPS the GET entirely (see `refetch`).
|
|
52
|
+
* - `error` stays `null` for an anonymous viewer. Nothing failed; we never
|
|
53
|
+
* asked. A retry notice would be wrong twice over.
|
|
54
|
+
* - It is `false` until `BLOCK_INIT` lands, because before that the viewer
|
|
55
|
+
* is UNKNOWN rather than absent (the pre-init snapshot's `viewer` is also
|
|
56
|
+
* `null`). A block that never gets init therefore reaches its terminal
|
|
57
|
+
* `error` with this flag `false` — an unembedded block is not a sign-in
|
|
58
|
+
* problem, and `test/hostOriginAbsent.test.tsx` pins exactly that.
|
|
59
|
+
*
|
|
60
|
+
* 🔴 IT IS NOT "the status was 403", AND A RESPONSE-DERIVED PREDICATE CANNOT
|
|
61
|
+
* WORK HERE. A 403 on this route has seven producers, and the anonymous case
|
|
62
|
+
* is NOT one of the uncoded ones: `withBlockScope` puts `goods:read:self` in
|
|
63
|
+
* its `:self` arm, so an anonymous subject is rejected as
|
|
64
|
+
* `code: 'context_binding'` — the same code a wrong `modelId` produces. There
|
|
65
|
+
* is no reachable uncoded 403 on this route, so an earlier
|
|
66
|
+
* `status === 403 && code == null` predicate could never fire, and keying on
|
|
67
|
+
* `context_binding` would conflate "not signed in" with a context mismatch.
|
|
68
|
+
* Every 403 now reaches `error` with the server's own wording, which is where
|
|
69
|
+
* a developer can read the cause — including the likeliest one in practice, a
|
|
70
|
+
* manifest missing `goods:read:self` (`insufficient_scope`).
|
|
71
|
+
*/
|
|
72
|
+
unauthenticated: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* `true` if the viewer owns `goodId`. 🔴 Returns `false` while
|
|
75
|
+
* `entitlements` is still `null`, which is ALSO what a failed read looks
|
|
76
|
+
* like — so never gate paid content on this alone without checking
|
|
77
|
+
* `loading` and `error`. A refused read renders as "you own nothing", and
|
|
78
|
+
* treating that as authoritative takes away something the viewer paid for.
|
|
79
|
+
*/
|
|
80
|
+
owns: (goodId: string) => boolean;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Read what the viewer owns FROM THIS APP through the block-token-gated
|
|
84
|
+
* `GET /api/v1/blocks/entitlements` REST endpoint (scope `goods:read:self`).
|
|
85
|
+
*
|
|
86
|
+
* The reply is scoped server-side to the calling app's own `appBlockId`, so an
|
|
87
|
+
* app can only ever see what it itself sold — which is why this scope is
|
|
88
|
+
* consent-EXEMPT: there is no third-party data to consent to. A read-only app
|
|
89
|
+
* that just wants to render "you own this" therefore needs no purchase power
|
|
90
|
+
* and triggers no re-consent prompt; only `goods:purchase:self` does.
|
|
91
|
+
*
|
|
92
|
+
* Revoked entitlements are excluded by the server — "what do I own" must not
|
|
93
|
+
* include what was taken back.
|
|
94
|
+
*
|
|
95
|
+
* Only the LATEST request may write state: a reply superseded by a newer
|
|
96
|
+
* `refetch`, or one landing after unmount, is dropped.
|
|
97
|
+
*
|
|
98
|
+
* An ANONYMOUS viewer is answered without a request: `unauthenticated` is read
|
|
99
|
+
* off `BLOCK_INIT`'s viewer, so the hook settles to `loading: false` with no
|
|
100
|
+
* `error` and never issues the GET the server would refuse anyway.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* const { owns, loading, error, unauthenticated, refetch } = useEntitlements();
|
|
104
|
+
* if (loading) return <Spinner />;
|
|
105
|
+
* // Order matters. `unauthenticated` is the state no retry can clear — a
|
|
106
|
+
* // logged-out viewer of a public page block — and it is NOT an `error`.
|
|
107
|
+
* if (unauthenticated) return <SignInToBuy />;
|
|
108
|
+
* if (error) return <RetryNotice onRetry={refetch} />; // NOT "you own nothing"
|
|
109
|
+
* return owns('extra-slots') ? <Unlocked /> : <BuyButton onDone={refetch} />;
|
|
110
|
+
*/
|
|
111
|
+
export declare function useEntitlements(): UseEntitlements;
|
|
112
|
+
//# sourceMappingURL=useEntitlements.d.ts.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { isSignedIn } from '@civitai/app-sdk/blocks';
|
|
3
|
+
import { useBlockContext } from './useBlockContext.js';
|
|
4
|
+
import { useBlockToken } from './useBlockToken.js';
|
|
5
|
+
import { useHostOrigin } from './useHostOrigin.js';
|
|
6
|
+
import { useRequestSequencer } from './useRequestSequencer.js';
|
|
7
|
+
/**
|
|
8
|
+
* Backstop timeout for the direct REST entitlements GET (see {@link useTip} /
|
|
9
|
+
* {@link useGenerationResources} for why direct-fetch hooks need their own bound).
|
|
10
|
+
*/
|
|
11
|
+
const ENTITLEMENTS_TIMEOUT_MS = 30_000;
|
|
12
|
+
/**
|
|
13
|
+
* How long the hook waits for `useHostOrigin()` before declaring the block
|
|
14
|
+
* un-embedded. A BOUND, not an immediate error: the host origin is absent on
|
|
15
|
+
* EVERY boot and lands a tick or two after mount with `BLOCK_INIT`, so erroring
|
|
16
|
+
* on the first `!host` would flash a spurious error on every healthy block.
|
|
17
|
+
* Only its CONTINUED absence is the fault.
|
|
18
|
+
*
|
|
19
|
+
* Equal to {@link ENTITLEMENTS_TIMEOUT_MS} by coincidence, not derivation: this
|
|
20
|
+
* bounds a wait for the HOST to introduce itself, that one bounds a request
|
|
21
|
+
* already in flight. Two constants so tuning either cannot retune the other.
|
|
22
|
+
*/
|
|
23
|
+
const HOST_ORIGIN_WAIT_MS = 30_000;
|
|
24
|
+
/**
|
|
25
|
+
* Read what the viewer owns FROM THIS APP through the block-token-gated
|
|
26
|
+
* `GET /api/v1/blocks/entitlements` REST endpoint (scope `goods:read:self`).
|
|
27
|
+
*
|
|
28
|
+
* The reply is scoped server-side to the calling app's own `appBlockId`, so an
|
|
29
|
+
* app can only ever see what it itself sold — which is why this scope is
|
|
30
|
+
* consent-EXEMPT: there is no third-party data to consent to. A read-only app
|
|
31
|
+
* that just wants to render "you own this" therefore needs no purchase power
|
|
32
|
+
* and triggers no re-consent prompt; only `goods:purchase:self` does.
|
|
33
|
+
*
|
|
34
|
+
* Revoked entitlements are excluded by the server — "what do I own" must not
|
|
35
|
+
* include what was taken back.
|
|
36
|
+
*
|
|
37
|
+
* Only the LATEST request may write state: a reply superseded by a newer
|
|
38
|
+
* `refetch`, or one landing after unmount, is dropped.
|
|
39
|
+
*
|
|
40
|
+
* An ANONYMOUS viewer is answered without a request: `unauthenticated` is read
|
|
41
|
+
* off `BLOCK_INIT`'s viewer, so the hook settles to `loading: false` with no
|
|
42
|
+
* `error` and never issues the GET the server would refuse anyway.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const { owns, loading, error, unauthenticated, refetch } = useEntitlements();
|
|
46
|
+
* if (loading) return <Spinner />;
|
|
47
|
+
* // Order matters. `unauthenticated` is the state no retry can clear — a
|
|
48
|
+
* // logged-out viewer of a public page block — and it is NOT an `error`.
|
|
49
|
+
* if (unauthenticated) return <SignInToBuy />;
|
|
50
|
+
* if (error) return <RetryNotice onRetry={refetch} />; // NOT "you own nothing"
|
|
51
|
+
* return owns('extra-slots') ? <Unlocked /> : <BuyButton onDone={refetch} />;
|
|
52
|
+
*/
|
|
53
|
+
export function useEntitlements() {
|
|
54
|
+
const host = useHostOrigin();
|
|
55
|
+
const { raw } = useBlockToken();
|
|
56
|
+
const { ready, viewer } = useBlockContext();
|
|
57
|
+
const [entitlements, setEntitlements] = useState(null);
|
|
58
|
+
const [loading, setLoading] = useState(true);
|
|
59
|
+
const [error, setError] = useState(null);
|
|
60
|
+
// 🔴 DERIVED, NOT STATE. `ready` is load-bearing and is the whole reason this
|
|
61
|
+
// is not a bare `!isSignedIn(viewer)`: the pre-`BLOCK_INIT` snapshot also
|
|
62
|
+
// carries `viewer: null`, so without the gate every block would report
|
|
63
|
+
// "signed out" for the first frames of every healthy boot — and a block whose
|
|
64
|
+
// init NEVER lands would report it forever, sending an embedded block's
|
|
65
|
+
// terminal host-origin error to a sign-in screen it cannot act on.
|
|
66
|
+
const anonymous = ready && !isSignedIn(viewer);
|
|
67
|
+
// Not a sequencing guard: drained only by the unmount cleanup, so it exists
|
|
68
|
+
// to abort on unmount and let a superseded request's socket close.
|
|
69
|
+
// `useRequestSequencer` supplies the latest-wins predicate.
|
|
70
|
+
const inFlight = useRef(new Set());
|
|
71
|
+
const seq = useRequestSequencer();
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const controllers = inFlight.current;
|
|
74
|
+
return () => {
|
|
75
|
+
for (const c of controllers)
|
|
76
|
+
c.abort();
|
|
77
|
+
controllers.clear();
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
// Bounded wait for the host origin. Re-armed whenever `host` changes and
|
|
81
|
+
// cleared the moment one arrives, so the terminal error is reachable ONLY
|
|
82
|
+
// when the origin is still absent a full HOST_ORIGIN_WAIT_MS after mount.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (host)
|
|
85
|
+
return;
|
|
86
|
+
const id = setTimeout(() => {
|
|
87
|
+
setLoading(false);
|
|
88
|
+
setError(new Error(`useEntitlements: host origin not established after ${HOST_ORIGIN_WAIT_MS}ms (no BLOCK_INIT — the block is probably not embedded).`));
|
|
89
|
+
}, HOST_ORIGIN_WAIT_MS);
|
|
90
|
+
return () => clearTimeout(id);
|
|
91
|
+
}, [host, seq]);
|
|
92
|
+
const refetch = useCallback(() => {
|
|
93
|
+
if (!host)
|
|
94
|
+
return;
|
|
95
|
+
// 🔴 ANONYMOUS VIEWERS ARE ANSWERED WITHOUT A REQUEST — a deliberate choice,
|
|
96
|
+
// not an omission. Entitlements are bound to the token SUBJECT, and an
|
|
97
|
+
// anonymous subject can hold none, so the server's answer is knowable and
|
|
98
|
+
// constant (a `context_binding` 403 from `withBlockScope`'s `:self` arm).
|
|
99
|
+
// A page block is a PUBLIC surface, so this is the common path rather than
|
|
100
|
+
// an edge: firing the GET would spend a round trip per anonymous paint to
|
|
101
|
+
// learn something `BLOCK_INIT` already said, and would leave a 403 in every
|
|
102
|
+
// developer's network tab that reads as a bug in their manifest.
|
|
103
|
+
//
|
|
104
|
+
// `error` is left NULL on purpose. Nothing failed — `unauthenticated` is
|
|
105
|
+
// the terminal state, and it is the one the documented render order checks
|
|
106
|
+
// before `error`.
|
|
107
|
+
if (anonymous) {
|
|
108
|
+
seq.begin(); // invalidate any in-flight read from a previous signed-in state
|
|
109
|
+
setEntitlements(null);
|
|
110
|
+
setError(null);
|
|
111
|
+
setLoading(false);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const token = seq.begin();
|
|
115
|
+
setLoading(true);
|
|
116
|
+
setError(null);
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
inFlight.current.add(controller);
|
|
119
|
+
const timeoutId = setTimeout(() => controller.abort(), ENTITLEMENTS_TIMEOUT_MS);
|
|
120
|
+
fetch(`${host}/api/v1/blocks/entitlements`, {
|
|
121
|
+
headers: { Authorization: `Bearer ${raw}` },
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
})
|
|
124
|
+
.then(async (res) => {
|
|
125
|
+
const body = (await res.json().catch(() => null));
|
|
126
|
+
if (!seq.isCurrent(token))
|
|
127
|
+
return;
|
|
128
|
+
if (!res.ok || body == null || !Array.isArray(body.entitlements)) {
|
|
129
|
+
// 🔴 NO RESPONSE IS EVER CLASSIFIED AS "NOT SIGNED IN" HERE, AND THAT
|
|
130
|
+
// IS THE FIX RATHER THAN AN OVERSIGHT. `unauthenticated` is derived
|
|
131
|
+
// from the viewer (see the field's doc); by the time a reply lands we
|
|
132
|
+
// already know a viewer was signed in, because otherwise no request
|
|
133
|
+
// was issued at all.
|
|
134
|
+
//
|
|
135
|
+
// The predicate that used to live here — `status === 403 && code ==
|
|
136
|
+
// null` — was DEAD. `src/pages/api/v1/blocks/entitlements.ts` wraps
|
|
137
|
+
// the handler in `withBlockScope(…, { requiredScope: 'goods:read:self'
|
|
138
|
+
// })`, and that middleware puts a `:self` scope with an anonymous
|
|
139
|
+
// subject in its `context_binding` arm, so the anonymous 403 arrives
|
|
140
|
+
// CODED. No uncoded 403 is reachable on this route, so the flag could
|
|
141
|
+
// never fire for the one case it exists for. Re-keying it on
|
|
142
|
+
// `context_binding` would be worse: that code is also what a wrong
|
|
143
|
+
// `modelId` and an array-form query param produce, so a signed-in
|
|
144
|
+
// viewer would be told to sign in.
|
|
145
|
+
//
|
|
146
|
+
// So every 403 — coded or not, parseable or not — surfaces on `error`
|
|
147
|
+
// with the server's own wording, which is where a developer can read
|
|
148
|
+
// the real cause. Pinned by the CONTROL cases in
|
|
149
|
+
// `test/useEntitlements.test.tsx`.
|
|
150
|
+
throw new Error(body?.error ?? `entitlements request failed (${res.status})`);
|
|
151
|
+
}
|
|
152
|
+
setEntitlements(body.entitlements);
|
|
153
|
+
setLoading(false);
|
|
154
|
+
})
|
|
155
|
+
.catch((err) => {
|
|
156
|
+
if (!seq.isCurrent(token))
|
|
157
|
+
return;
|
|
158
|
+
setError(controller.signal.aborted
|
|
159
|
+
? new Error(`useEntitlements: request aborted (timed out after ${ENTITLEMENTS_TIMEOUT_MS}ms or the hook unmounted).`)
|
|
160
|
+
: err instanceof Error
|
|
161
|
+
? err
|
|
162
|
+
: new Error(String(err)));
|
|
163
|
+
setLoading(false);
|
|
164
|
+
})
|
|
165
|
+
.finally(() => {
|
|
166
|
+
clearTimeout(timeoutId);
|
|
167
|
+
inFlight.current.delete(controller);
|
|
168
|
+
});
|
|
169
|
+
}, [anonymous, host, raw, seq]);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
refetch();
|
|
172
|
+
}, [refetch]);
|
|
173
|
+
const owns = useCallback((goodId) => (entitlements ?? []).some((e) => e.goodId === goodId), [entitlements]);
|
|
174
|
+
return { entitlements, loading, error, unauthenticated: anonymous, refetch, owns };
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=useEntitlements.js.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Entitlement } from './useEntitlements.js';
|
|
2
|
+
/** Which good to buy, and optionally the price the app showed the viewer. */
|
|
3
|
+
export interface GoodPurchaseParams {
|
|
4
|
+
/** The `id` of a good declared in this app's approved manifest. */
|
|
5
|
+
goodId: string;
|
|
6
|
+
/**
|
|
7
|
+
* The price the app DISPLAYED. Optional, and worth passing: the server
|
|
8
|
+
* charges its OWN price and refuses when this disagrees, so sending it turns
|
|
9
|
+
* "the app showed a stale price" into a clean refusal instead of a viewer
|
|
10
|
+
* being charged an amount they never saw.
|
|
11
|
+
*/
|
|
12
|
+
expectedPriceBuzz?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Optional per-purchase controls. */
|
|
15
|
+
export interface GoodPurchaseOptions {
|
|
16
|
+
/**
|
|
17
|
+
* A STABLE idempotency key for this logical purchase. Reuse the SAME value
|
|
18
|
+
* when RETRYING a purchase whose response was lost (timeout / network drop)
|
|
19
|
+
* so the server replays the first terminal result instead of charging twice.
|
|
20
|
+
* Omit → a fresh key per `purchase()` call.
|
|
21
|
+
*/
|
|
22
|
+
idempotencyKey?: string;
|
|
23
|
+
/**
|
|
24
|
+
* When the viewer cannot afford the good, open the host's Buzz top-up modal
|
|
25
|
+
* and — only if they actually bought Buzz — retry the purchase ONCE.
|
|
26
|
+
* Default `false`, so the refusal surfaces and the app decides.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ PASS `expectedPriceBuzz` WITH THIS. It is what the modal is opened with,
|
|
29
|
+
* so without it the viewer sees no suggested amount, may top up less than the
|
|
30
|
+
* good costs, and the single retry re-refuses `insufficient_funds` after real
|
|
31
|
+
* fiat was spent. The option works without it; it just works worse in the one
|
|
32
|
+
* direction that costs the viewer money.
|
|
33
|
+
*
|
|
34
|
+
* 🔴 THE RETRY REUSES THE SAME IDEMPOTENCY KEY, DELIBERATELY. The server's
|
|
35
|
+
* own comment on this path is that an `insufficient_funds` refusal leaves the
|
|
36
|
+
* key FREE precisely because "an identical retry CAN reach a different
|
|
37
|
+
* verdict — a top-up"; it caches terminal results, not this one. Minting a
|
|
38
|
+
* fresh key here would work too, but it would make a lost response on the
|
|
39
|
+
* retry unrecoverable, which is the thing idempotency keys exist to prevent.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ RECONCILED WITH THE OPPOSITE POLICY NEXT DOOR, which a reader will hit.
|
|
42
|
+
* `useBuzzWorkflow` states twice that a resolved-failed budget "is the cue to
|
|
43
|
+
* call `useBuzzPurchase().openPurchaseModal()`" — i.e. the APP decides — and
|
|
44
|
+
* warns that "routing a rejection into a top-up sells Buzz for a failure Buzz
|
|
45
|
+
* cannot fix". That policy is right for a generation, and this option does not
|
|
46
|
+
* contradict it: it defaults to `false`, and it keys on the REASON
|
|
47
|
+
* (`insufficient_funds`) rather than on "the call failed", so it never offers
|
|
48
|
+
* Buzz for a failure Buzz cannot fix. What it buys, and the only reason it is
|
|
49
|
+
* in the library at all rather than left to each app, is the same-key retry
|
|
50
|
+
* above — a server contract an app gets wrong in the expensive direction.
|
|
51
|
+
*/
|
|
52
|
+
topUpOnInsufficientFunds?: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** The successful purchase echo the endpoint returns. */
|
|
55
|
+
export interface GoodPurchaseResult {
|
|
56
|
+
ok: true;
|
|
57
|
+
purchase: {
|
|
58
|
+
id: string;
|
|
59
|
+
goodId: string;
|
|
60
|
+
priceBuzz: number;
|
|
61
|
+
};
|
|
62
|
+
entitlement: Entitlement;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A refusal the server produced deliberately, as opposed to a transport
|
|
66
|
+
* failure.
|
|
67
|
+
*
|
|
68
|
+
* `reason` is the machine-readable discriminator where one exists — branch on it
|
|
69
|
+
* rather than on `message`, which is viewer-facing copy and will be reworded.
|
|
70
|
+
*
|
|
71
|
+
* 🔴 `reason` IS OFTEN `undefined`, AND A CONSUMER MUST HANDLE THAT. Only the
|
|
72
|
+
* SERVICE-level refusals carry one (`insufficient_funds`, `ledger_conflict`,
|
|
73
|
+
* `charge_failed`, `charge_unknown`, the price-disagreement and
|
|
74
|
+
* already-owned cases). The ENDPOINT's own refusals return `{ error }` alone:
|
|
75
|
+
* the 404 for an unavailable good, the 429 rate limit, the daily-cap 400 and
|
|
76
|
+
* every idempotency refusal (409 / 422 / 503). So a `switch (reason)` with no
|
|
77
|
+
* default silently swallows a material fraction of real failures — always fall
|
|
78
|
+
* back to `status` plus `message`.
|
|
79
|
+
*/
|
|
80
|
+
export declare class GoodPurchaseRefusal extends Error {
|
|
81
|
+
readonly status: number;
|
|
82
|
+
readonly reason: string | undefined;
|
|
83
|
+
constructor(message: string, status: number, reason?: string);
|
|
84
|
+
}
|
|
85
|
+
export interface UseGoodPurchase {
|
|
86
|
+
/**
|
|
87
|
+
* Buy a manifest-declared good for the viewer. Resolves with the server's
|
|
88
|
+
* echo, including the entitlement it granted. REJECTS with a
|
|
89
|
+
* {@link GoodPurchaseRefusal} on any 4xx/5xx — read `reason` to tell
|
|
90
|
+
* `insufficient_funds` from a stale price or a rate limit.
|
|
91
|
+
*
|
|
92
|
+
* Two non-refusal rejections, distinguishable by `name`:
|
|
93
|
+
* - the 30s bound elapsed → a plain `Error` naming the timeout. A REAL
|
|
94
|
+
* failure; the charge may have landed, so retry with the SAME
|
|
95
|
+
* `idempotencyKey`.
|
|
96
|
+
* - the hook unmounted first → an `Error` with `name === 'AbortError'`, so a
|
|
97
|
+
* caller that ignores navigate-away rejections can keep doing so. The same
|
|
98
|
+
* same-key retry advice applies if the component comes back.
|
|
99
|
+
*
|
|
100
|
+
* 🔴 THAT CLASSIFICATION IS DECIDED BY THE ABORT STATE, NOT BY WHETHER A BODY
|
|
101
|
+
* PARSED, and it holds wherever the abort lands — including DURING the body
|
|
102
|
+
* read, which is the common shape when the headers arrive first. The one
|
|
103
|
+
* documented exception is the opposite race: a refusal the server had already
|
|
104
|
+
* delivered AND the hook had already parsed stays a {@link GoodPurchaseRefusal}
|
|
105
|
+
* even if the abort fires in the same tick, because a refusal we actually read
|
|
106
|
+
* is more informative than an abort, and `reason` is what the top-up branch
|
|
107
|
+
* needs. So an `AbortError` here never hides a refusal, and a refusal here
|
|
108
|
+
* never hides a timeout.
|
|
109
|
+
*/
|
|
110
|
+
purchase: (params: GoodPurchaseParams, options?: GoodPurchaseOptions) => Promise<GoodPurchaseResult>;
|
|
111
|
+
/** `true` while a purchase POST is in flight. */
|
|
112
|
+
loading: boolean;
|
|
113
|
+
/** The last purchase's failure, or `null`. Cleared at the start of the next call. */
|
|
114
|
+
error: Error | null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Buy a DIGITAL GOOD for the viewer through the block-token-gated
|
|
118
|
+
* `POST /api/v1/blocks/goods/purchase` REST endpoint (scope
|
|
119
|
+
* `goods:purchase:self`).
|
|
120
|
+
*
|
|
121
|
+
* Direct-fetch against the VALIDATED host origin (`useHostOrigin()`) with the
|
|
122
|
+
* block bearer token, the same security-reviewed pattern as {@link useTip}. The
|
|
123
|
+
* BUYER is always the token subject — the server self-binds it and the block
|
|
124
|
+
* never supplies a user id, so there is no client-supplied value on the money
|
|
125
|
+
* path.
|
|
126
|
+
*
|
|
127
|
+
* 🔴 BUYING A GOOD AND BUYING BUZZ ARE OPPOSITE DIRECTIONS, AND THIS HOOK
|
|
128
|
+
* TOUCHES BOTH. A good is Buzz flowing FROM the viewer TO the app owner. The
|
|
129
|
+
* top-up modal this hook can open ({@link useBuzzPurchase}) is fiat flowing INTO
|
|
130
|
+
* the viewer's balance. They are different rails with different ledgers; the
|
|
131
|
+
* only thing they share is that one can unblock the other.
|
|
132
|
+
*
|
|
133
|
+
* ⚠️ THE PLATFORM RENDERS NO CONFIRMATION FOR THE PURCHASE ITSELF. This is a
|
|
134
|
+
* plain authed POST, so whatever confirm UI the viewer sees is the APP's. Spend
|
|
135
|
+
* is bounded by the good's manifest-reviewed price and the viewer's daily cap,
|
|
136
|
+
* but a good can be priced near that cap where a tip cannot — so show the price
|
|
137
|
+
* and get an explicit action before calling this.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* const { purchase, loading, error } = useGoodPurchase();
|
|
141
|
+
* const { entitlement } = await purchase(
|
|
142
|
+
* { goodId: 'extra-slots', expectedPriceBuzz: 250 },
|
|
143
|
+
* { topUpOnInsufficientFunds: true },
|
|
144
|
+
* );
|
|
145
|
+
*/
|
|
146
|
+
export declare function useGoodPurchase(): UseGoodPurchase;
|
|
147
|
+
//# sourceMappingURL=useGoodPurchase.d.ts.map
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { generateIdempotencyKey } from '../transport/transport.js';
|
|
3
|
+
import { useBlockToken } from './useBlockToken.js';
|
|
4
|
+
import { useBuzzPurchase } from './useBuzzPurchase.js';
|
|
5
|
+
import { useHostOrigin } from './useHostOrigin.js';
|
|
6
|
+
/**
|
|
7
|
+
* Backstop timeout for the direct REST purchase POST. Like {@link useTip} (and
|
|
8
|
+
* unlike the postMessage hooks), this talks to the HTTP API directly, so it
|
|
9
|
+
* needs its OWN bound — otherwise a hung request never rejects.
|
|
10
|
+
*/
|
|
11
|
+
const GOOD_PURCHASE_TIMEOUT_MS = 30_000;
|
|
12
|
+
/**
|
|
13
|
+
* A refusal the server produced deliberately, as opposed to a transport
|
|
14
|
+
* failure.
|
|
15
|
+
*
|
|
16
|
+
* `reason` is the machine-readable discriminator where one exists — branch on it
|
|
17
|
+
* rather than on `message`, which is viewer-facing copy and will be reworded.
|
|
18
|
+
*
|
|
19
|
+
* 🔴 `reason` IS OFTEN `undefined`, AND A CONSUMER MUST HANDLE THAT. Only the
|
|
20
|
+
* SERVICE-level refusals carry one (`insufficient_funds`, `ledger_conflict`,
|
|
21
|
+
* `charge_failed`, `charge_unknown`, the price-disagreement and
|
|
22
|
+
* already-owned cases). The ENDPOINT's own refusals return `{ error }` alone:
|
|
23
|
+
* the 404 for an unavailable good, the 429 rate limit, the daily-cap 400 and
|
|
24
|
+
* every idempotency refusal (409 / 422 / 503). So a `switch (reason)` with no
|
|
25
|
+
* default silently swallows a material fraction of real failures — always fall
|
|
26
|
+
* back to `status` plus `message`.
|
|
27
|
+
*/
|
|
28
|
+
export class GoodPurchaseRefusal extends Error {
|
|
29
|
+
status;
|
|
30
|
+
reason;
|
|
31
|
+
constructor(message, status, reason) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'GoodPurchaseRefusal';
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.reason = reason;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Buy a DIGITAL GOOD for the viewer through the block-token-gated
|
|
40
|
+
* `POST /api/v1/blocks/goods/purchase` REST endpoint (scope
|
|
41
|
+
* `goods:purchase:self`).
|
|
42
|
+
*
|
|
43
|
+
* Direct-fetch against the VALIDATED host origin (`useHostOrigin()`) with the
|
|
44
|
+
* block bearer token, the same security-reviewed pattern as {@link useTip}. The
|
|
45
|
+
* BUYER is always the token subject — the server self-binds it and the block
|
|
46
|
+
* never supplies a user id, so there is no client-supplied value on the money
|
|
47
|
+
* path.
|
|
48
|
+
*
|
|
49
|
+
* 🔴 BUYING A GOOD AND BUYING BUZZ ARE OPPOSITE DIRECTIONS, AND THIS HOOK
|
|
50
|
+
* TOUCHES BOTH. A good is Buzz flowing FROM the viewer TO the app owner. The
|
|
51
|
+
* top-up modal this hook can open ({@link useBuzzPurchase}) is fiat flowing INTO
|
|
52
|
+
* the viewer's balance. They are different rails with different ledgers; the
|
|
53
|
+
* only thing they share is that one can unblock the other.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ THE PLATFORM RENDERS NO CONFIRMATION FOR THE PURCHASE ITSELF. This is a
|
|
56
|
+
* plain authed POST, so whatever confirm UI the viewer sees is the APP's. Spend
|
|
57
|
+
* is bounded by the good's manifest-reviewed price and the viewer's daily cap,
|
|
58
|
+
* but a good can be priced near that cap where a tip cannot — so show the price
|
|
59
|
+
* and get an explicit action before calling this.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const { purchase, loading, error } = useGoodPurchase();
|
|
63
|
+
* const { entitlement } = await purchase(
|
|
64
|
+
* { goodId: 'extra-slots', expectedPriceBuzz: 250 },
|
|
65
|
+
* { topUpOnInsufficientFunds: true },
|
|
66
|
+
* );
|
|
67
|
+
*/
|
|
68
|
+
export function useGoodPurchase() {
|
|
69
|
+
const host = useHostOrigin();
|
|
70
|
+
const { raw } = useBlockToken();
|
|
71
|
+
const { openPurchaseModal } = useBuzzPurchase();
|
|
72
|
+
const [loading, setLoading] = useState(false);
|
|
73
|
+
const [error, setError] = useState(null);
|
|
74
|
+
const mountedRef = useRef(true);
|
|
75
|
+
const inFlight = useRef(new Set());
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
mountedRef.current = true;
|
|
78
|
+
const controllers = inFlight.current;
|
|
79
|
+
return () => {
|
|
80
|
+
mountedRef.current = false;
|
|
81
|
+
for (const c of controllers)
|
|
82
|
+
c.abort();
|
|
83
|
+
controllers.clear();
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
const postOnce = useCallback(async (params, idempotencyKey) => {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
inFlight.current.add(controller);
|
|
89
|
+
// Which abort fired is not recoverable from the signal — both a timeout
|
|
90
|
+
// and the unmount cleanup set `aborted` — so record it at the source.
|
|
91
|
+
// The two need OPPOSITE handling below, and conflating them is how a
|
|
92
|
+
// money-path timeout gets swallowed by an unmount-ignoring caller.
|
|
93
|
+
let timedOut = false;
|
|
94
|
+
const timeoutId = setTimeout(() => {
|
|
95
|
+
timedOut = true;
|
|
96
|
+
controller.abort();
|
|
97
|
+
}, GOOD_PURCHASE_TIMEOUT_MS);
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(`${host}/api/v1/blocks/goods/purchase`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { Authorization: `Bearer ${raw}`, 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
goodId: params.goodId,
|
|
104
|
+
...(params.expectedPriceBuzz != null
|
|
105
|
+
? { expectedPriceBuzz: params.expectedPriceBuzz }
|
|
106
|
+
: {}),
|
|
107
|
+
idempotencyKey,
|
|
108
|
+
}),
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
});
|
|
111
|
+
// 🔴 AN ABORT DURING THE BODY READ MUST NOT BE SWALLOWED AS "no body".
|
|
112
|
+
// With a real `fetch` the response headers can arrive and the BODY still
|
|
113
|
+
// be in flight; aborting then rejects `res.json()` with an `AbortError`.
|
|
114
|
+
// A blanket `.catch(() => null)` turned that into `bodyJson === null`,
|
|
115
|
+
// and the classification below — which reads the BODY rather than the
|
|
116
|
+
// abort state — then produced exactly the wrong answer twice:
|
|
117
|
+
//
|
|
118
|
+
// 200 + unmount mid-body → a `malformed_success` refusal saying "the
|
|
119
|
+
// charge may have landed", reported to a caller the docs told to
|
|
120
|
+
// IGNORE navigate-away aborts. A purchase that SUCCEEDED, surfaced as
|
|
121
|
+
// a possible-charge failure.
|
|
122
|
+
//
|
|
123
|
+
// 4xx + timeout mid-body → `purchase request failed (<status>)` with
|
|
124
|
+
// `reason` lost, so the documented timeout error and its same-key
|
|
125
|
+
// retry advice never reached the caller.
|
|
126
|
+
//
|
|
127
|
+
// So a parse failure is only "no body" when the request was NOT aborted;
|
|
128
|
+
// otherwise it is rethrown and the `catch` below classifies it by the
|
|
129
|
+
// abort state, which is the only thing that can tell a timeout from an
|
|
130
|
+
// unmount. A genuinely unparseable body on a live request still yields
|
|
131
|
+
// `null` and the malformed/refusal handling below is unchanged.
|
|
132
|
+
const bodyJson = (await res.json().catch((err) => {
|
|
133
|
+
if (controller.signal.aborted)
|
|
134
|
+
throw err;
|
|
135
|
+
return null;
|
|
136
|
+
}));
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
throw new GoodPurchaseRefusal(bodyJson?.error ?? `purchase request failed (${res.status})`, res.status, bodyJson?.reason);
|
|
139
|
+
}
|
|
140
|
+
// 🔴 VALIDATE THE 2xx, because the declared return type promises an
|
|
141
|
+
// entitlement. Without this an unparseable 200 resolved as `null` and a
|
|
142
|
+
// wrong-shaped one resolved with `entitlement === undefined` — and the
|
|
143
|
+
// documented usage destructures it, so both surfaced as a TypeError in
|
|
144
|
+
// the app rather than as a failed purchase. On this hook the body IS the
|
|
145
|
+
// granted entitlement, so a caller cannot recover from a silent absence.
|
|
146
|
+
// `useEntitlements` in this same package performs the equivalent check.
|
|
147
|
+
//
|
|
148
|
+
// 🔴 `purchase` IS CHECKED TOO, and for the same reason `entitlement` is:
|
|
149
|
+
// `GoodPurchaseResult` declares it REQUIRED, so `result.purchase.id` is
|
|
150
|
+
// typed as safe and a body of `{ ok: true, entitlement }` alone would
|
|
151
|
+
// resolve and then TypeError at the dereference. The live 200 path
|
|
152
|
+
// (`src/pages/api/v1/blocks/goods/purchase.ts`) always sends all three,
|
|
153
|
+
// so this is the type's promise being kept rather than a reachable
|
|
154
|
+
// server bug — but an `as`-cast past an unvalidated required field is
|
|
155
|
+
// exactly how the `entitlement` case reached an app in the first place.
|
|
156
|
+
if (bodyJson == null ||
|
|
157
|
+
bodyJson.ok !== true ||
|
|
158
|
+
bodyJson.entitlement == null ||
|
|
159
|
+
bodyJson.purchase == null) {
|
|
160
|
+
throw new GoodPurchaseRefusal(`purchase succeeded (${res.status}) but the response was not a purchase result — the charge may have landed; do not retry without the same idempotency key`, res.status, 'malformed_success');
|
|
161
|
+
}
|
|
162
|
+
return bodyJson;
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
// A timeout or an unmount surfaces as a bare `AbortError`, which on a
|
|
166
|
+
// money path is the least informative wording available: the caller
|
|
167
|
+
// cannot tell it from a refusal, and the charge may have landed. Named
|
|
168
|
+
// and bounded, as `useTip` and `useEntitlements` both do.
|
|
169
|
+
//
|
|
170
|
+
// 🔴 THE `!(err instanceof GoodPurchaseRefusal)` HALF IS NOT DEFENSIVE
|
|
171
|
+
// PADDING — but it does NOT cover a refusal cut off mid-parse, which an
|
|
172
|
+
// earlier version of this comment claimed. That case no longer reaches
|
|
173
|
+
// here at all: an abort during the body read is rethrown above and
|
|
174
|
+
// classified as an abort, because a body nobody could read carries no
|
|
175
|
+
// `reason` to preserve.
|
|
176
|
+
//
|
|
177
|
+
// What it does cover is the race the other way round: the body had
|
|
178
|
+
// ALREADY ARRIVED and parsed into an `insufficient_funds` 400 when the
|
|
179
|
+
// abort fired underneath it — the 30s bound elapsing in the same tick
|
|
180
|
+
// the refusal was thrown, or an unmount landing there. `signal.aborted`
|
|
181
|
+
// is then true while we hold a complete, deliberate server refusal.
|
|
182
|
+
// Rewriting it into the abort `Error` would strip `reason`, so the
|
|
183
|
+
// top-up branch in `purchase` below would stop recognising it and the
|
|
184
|
+
// viewer would be shown a raw failure instead of the Buzz modal. A
|
|
185
|
+
// refusal we actually read always wins over an abort that raced it.
|
|
186
|
+
if (controller.signal.aborted && !(err instanceof GoodPurchaseRefusal)) {
|
|
187
|
+
if (timedOut) {
|
|
188
|
+
// The BOUND fired. This is a real failure the caller must handle,
|
|
189
|
+
// so it deliberately does NOT get the `AbortError` name below:
|
|
190
|
+
// callers routinely ignore `AbortError` as "we navigated away", and
|
|
191
|
+
// a silently-ignored money-path timeout is the worst outcome here.
|
|
192
|
+
throw new Error(`useGoodPurchase: request aborted (timed out after ${GOOD_PURCHASE_TIMEOUT_MS}ms). The charge may or may not have landed — retry with the SAME idempotencyKey to find out safely.`);
|
|
193
|
+
}
|
|
194
|
+
// 🔴 UNMOUNT. `name` stays `AbortError` on purpose: that is the
|
|
195
|
+
// discriminator a caller uses to IGNORE a rejection its component no
|
|
196
|
+
// longer cares about, and flattening it to a plain `Error` turns every
|
|
197
|
+
// navigate-away into a reported failure. The MESSAGE still says what
|
|
198
|
+
// happened — informative wording and a usable `name` are not a
|
|
199
|
+
// trade-off. `useBuzzWorkflow`'s `watch()` does the same.
|
|
200
|
+
const aborted = new Error('useGoodPurchase: request aborted (the hook unmounted before the response arrived). The charge may or may not have landed — retry with the SAME idempotencyKey to find out safely.');
|
|
201
|
+
aborted.name = 'AbortError';
|
|
202
|
+
throw aborted;
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
clearTimeout(timeoutId);
|
|
208
|
+
inFlight.current.delete(controller);
|
|
209
|
+
}
|
|
210
|
+
}, [host, raw]);
|
|
211
|
+
const purchase = useCallback(async (params, options) => {
|
|
212
|
+
if (!host) {
|
|
213
|
+
throw new Error('useGoodPurchase: host origin not established yet (wait for BLOCK_INIT).');
|
|
214
|
+
}
|
|
215
|
+
if (mountedRef.current) {
|
|
216
|
+
setLoading(true);
|
|
217
|
+
setError(null);
|
|
218
|
+
}
|
|
219
|
+
const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
|
|
220
|
+
try {
|
|
221
|
+
try {
|
|
222
|
+
return await postOnce(params, idempotencyKey);
|
|
223
|
+
}
|
|
224
|
+
catch (first) {
|
|
225
|
+
const canTopUp = options?.topUpOnInsufficientFunds === true &&
|
|
226
|
+
first instanceof GoodPurchaseRefusal &&
|
|
227
|
+
first.reason === 'insufficient_funds';
|
|
228
|
+
if (!canTopUp)
|
|
229
|
+
throw first;
|
|
230
|
+
// Only retry if the viewer ACTUALLY bought Buzz. `purchased: false`
|
|
231
|
+
// is the ordinary "they closed the modal" case, and retrying it would
|
|
232
|
+
// just reproduce the same refusal — so the original refusal is what
|
|
233
|
+
// the app should see.
|
|
234
|
+
const { purchased } = await openPurchaseModal(params.expectedPriceBuzz);
|
|
235
|
+
if (!purchased)
|
|
236
|
+
throw first;
|
|
237
|
+
return await postOnce(params, idempotencyKey);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
242
|
+
if (mountedRef.current)
|
|
243
|
+
setError(e);
|
|
244
|
+
throw e;
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
if (mountedRef.current)
|
|
248
|
+
setLoading(false);
|
|
249
|
+
}
|
|
250
|
+
}, [host, openPurchaseModal, postOnce]);
|
|
251
|
+
return { purchase, loading, error };
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=useGoodPurchase.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,10 @@ export { useHostOrigin } from './hooks/useHostOrigin.js';
|
|
|
26
26
|
export type { UseHostOrigin } from './hooks/useHostOrigin.js';
|
|
27
27
|
export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
|
|
28
28
|
export type { SubmitWorkflowOptions, UseBuzzWorkflow, WatchWorkflowOptions, WorkflowSubmitErrorCode, } from './hooks/useBuzzWorkflow.js';
|
|
29
|
+
export { useEntitlements } from './hooks/useEntitlements.js';
|
|
30
|
+
export type { UseEntitlements, Entitlement } from './hooks/useEntitlements.js';
|
|
31
|
+
export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
|
|
32
|
+
export type { UseGoodPurchase, GoodPurchaseParams, GoodPurchaseOptions, GoodPurchaseResult, } from './hooks/useGoodPurchase.js';
|
|
29
33
|
export { useTip } from './hooks/useTip.js';
|
|
30
34
|
export type { TipParams, TipOptions, TipResult, UseTip } from './hooks/useTip.js';
|
|
31
35
|
export { useTipAllowance } from './hooks/useTipAllowance.js';
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,8 @@ export { useBlockSettings } from './hooks/useBlockSettings.js';
|
|
|
24
24
|
export { useBlockToken } from './hooks/useBlockToken.js';
|
|
25
25
|
export { useHostOrigin } from './hooks/useHostOrigin.js';
|
|
26
26
|
export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
|
|
27
|
+
export { useEntitlements } from './hooks/useEntitlements.js';
|
|
28
|
+
export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
|
|
27
29
|
export { useTip } from './hooks/useTip.js';
|
|
28
30
|
export { useTipAllowance } from './hooks/useTipAllowance.js';
|
|
29
31
|
export { useBlockResize } from './hooks/useBlockResize.js';
|
package/dist/ui/styles.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* CSS. (At RUNTIME, `injectBlocksStyles()` injects these as three separately
|
|
6
6
|
* marked `<style>` elements instead — see below.)
|
|
7
7
|
*/
|
|
8
|
-
export declare const BLOCKS_UI_STYLES = "/* AUTOGENERATED by @civitai/theme (scripts/build-tokens.ts). DO NOT EDIT.\n Regenerate: pnpm --filter @civitai/theme build. Guarded by generation-parity test. */\n@property --civitai-color-text {\n syntax: '<color>';\n inherits: true;\n initial-value: #222;\n}\n@property --civitai-color-text-dimmed {\n syntax: '<color>';\n inherits: true;\n initial-value: #868e96;\n}\n@property --civitai-color-body {\n syntax: '<color>';\n inherits: true;\n initial-value: #fefefe;\n}\n@property --civitai-color-surface {\n syntax: '<color>';\n inherits: true;\n initial-value: #fefefe;\n}\n@property --civitai-color-surface-2 {\n syntax: '<color>';\n inherits: true;\n initial-value: #fefefe;\n}\n@property --civitai-color-border {\n syntax: '<color>';\n inherits: true;\n initial-value: #ced4da;\n}\n@property --civitai-color-primary {\n syntax: '<color>';\n inherits: true;\n initial-value: #228BE6;\n}\n@property --civitai-color-primary-hover {\n syntax: '<color>';\n inherits: true;\n initial-value: #1C7ED6;\n}\n@property --civitai-color-primary-fg {\n syntax: '<color>';\n inherits: true;\n initial-value: #fefefe;\n}\n@property --civitai-color-primary-light {\n syntax: '<color>';\n inherits: true;\n initial-value: rgba(34, 139, 230, 0.1);\n}\n@property --civitai-color-error {\n syntax: '<color>';\n inherits: true;\n initial-value: #fa5252;\n}\n@property --civitai-color-success {\n syntax: '<color>';\n inherits: true;\n initial-value: #299C7A;\n}\n@property --civitai-color-warning {\n syntax: '<color>';\n inherits: true;\n initial-value: #fd7e14;\n}\n@property --civitai-color-info {\n syntax: '<color>';\n inherits: true;\n initial-value: #228BE6;\n}\n@property --civitai-color-gray-0 {\n syntax: '<color>';\n inherits: true;\n initial-value: #f8f9fa;\n}\n@property --civitai-color-gray-1 {\n syntax: '<color>';\n inherits: true;\n initial-value: #f1f3f5;\n}\n@property --civitai-color-gray-2 {\n syntax: '<color>';\n inherits: true;\n initial-value: #e9ecef;\n}\n@property --civitai-color-gray-3 {\n syntax: '<color>';\n inherits: true;\n initial-value: #dee2e6;\n}\n@property --civitai-color-gray-4 {\n syntax: '<color>';\n inherits: true;\n initial-value: #ced4da;\n}\n@property --civitai-color-gray-5 {\n syntax: '<color>';\n inherits: true;\n initial-value: #adb5bd;\n}\n@property --civitai-color-gray-6 {\n syntax: '<color>';\n inherits: true;\n initial-value: #868e96;\n}\n@property --civitai-color-gray-7 {\n syntax: '<color>';\n inherits: true;\n initial-value: #495057;\n}\n@property --civitai-color-gray-8 {\n syntax: '<color>';\n inherits: true;\n initial-value: #343a40;\n}\n@property --civitai-color-gray-9 {\n syntax: '<color>';\n inherits: true;\n initial-value: #212529;\n}\n@property --civitai-color-track {\n syntax: '<color>';\n inherits: true;\n initial-value: #e9ecef;\n}\n@property --civitai-color-segmented-bg {\n syntax: '<color>';\n inherits: true;\n initial-value: #f1f3f5;\n}\n@property --civitai-color-media-placeholder {\n syntax: '<color>';\n inherits: true;\n initial-value: #e9ecef;\n}\n\n:root {\n color-scheme: light;\n --civitai-font: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;\n --civitai-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;\n --civitai-radius: 0.25rem;\n --civitai-color-text: #222;\n --civitai-color-text-dimmed: #868e96;\n --civitai-color-body: #fefefe;\n --civitai-color-surface: #fefefe;\n --civitai-color-surface-2: #fefefe;\n --civitai-color-border: #ced4da;\n --civitai-color-primary: #228BE6;\n --civitai-color-primary-hover: #1C7ED6;\n --civitai-color-primary-fg: #fefefe;\n --civitai-color-primary-light: rgba(34, 139, 230, 0.1);\n --civitai-color-error: #fa5252;\n --civitai-color-success: #299C7A;\n --civitai-color-warning: #fd7e14;\n --civitai-color-info: #228BE6;\n --civitai-color-gray-0: #f8f9fa;\n --civitai-color-gray-1: #f1f3f5;\n --civitai-color-gray-2: #e9ecef;\n --civitai-color-gray-3: #dee2e6;\n --civitai-color-gray-4: #ced4da;\n --civitai-color-gray-5: #adb5bd;\n --civitai-color-gray-6: #868e96;\n --civitai-color-gray-7: #495057;\n --civitai-color-gray-8: #343a40;\n --civitai-color-gray-9: #212529;\n --civitai-bp-xs: 480px;\n --civitai-bp-sm: 768px;\n --civitai-bp-md: 1024px;\n --civitai-bp-lg: 1184px;\n --civitai-bp-xl: 1440px;\n --civitai-card-border-width: 1px;\n --civitai-color-track: #e9ecef;\n --civitai-color-segmented-bg: #f1f3f5;\n --civitai-color-media-placeholder: #e9ecef;\n}\n\n[data-theme='light'] {\n color-scheme: light;\n --civitai-font: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;\n --civitai-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;\n --civitai-radius: 0.25rem;\n --civitai-color-text: #222;\n --civitai-color-text-dimmed: #868e96;\n --civitai-color-body: #fefefe;\n --civitai-color-surface: #fefefe;\n --civitai-color-surface-2: #fefefe;\n --civitai-color-border: #ced4da;\n --civitai-color-primary: #228BE6;\n --civitai-color-primary-hover: #1C7ED6;\n --civitai-color-primary-fg: #fefefe;\n --civitai-color-primary-light: rgba(34, 139, 230, 0.1);\n --civitai-color-error: #fa5252;\n --civitai-color-success: #299C7A;\n --civitai-color-warning: #fd7e14;\n --civitai-color-info: #228BE6;\n --civitai-color-gray-0: #f8f9fa;\n --civitai-color-gray-1: #f1f3f5;\n --civitai-color-gray-2: #e9ecef;\n --civitai-color-gray-3: #dee2e6;\n --civitai-color-gray-4: #ced4da;\n --civitai-color-gray-5: #adb5bd;\n --civitai-color-gray-6: #868e96;\n --civitai-color-gray-7: #495057;\n --civitai-color-gray-8: #343a40;\n --civitai-color-gray-9: #212529;\n --civitai-bp-xs: 480px;\n --civitai-bp-sm: 768px;\n --civitai-bp-md: 1024px;\n --civitai-bp-lg: 1184px;\n --civitai-bp-xl: 1440px;\n --civitai-card-border-width: 1px;\n --civitai-color-track: #e9ecef;\n --civitai-color-segmented-bg: #f1f3f5;\n --civitai-color-media-placeholder: #e9ecef;\n}\n\n[data-theme='dark'] {\n color-scheme: dark;\n --civitai-color-text: #C1C2C5;\n --civitai-color-text-dimmed: #8c8fa3;\n --civitai-color-body: #1A1B1E;\n --civitai-color-surface: #25262B;\n --civitai-color-surface-2: #25262B;\n --civitai-color-border: #373A40;\n --civitai-color-primary: #1971C2;\n --civitai-color-primary-hover: #1864AB;\n --civitai-color-primary-fg: #fefefe;\n --civitai-color-primary-light: rgba(34, 139, 230, 0.15);\n --civitai-color-error: #e03131;\n --civitai-color-success: #326D5C;\n --civitai-color-warning: #e8590c;\n --civitai-color-info: #1971C2;\n --civitai-card-border-width: 0;\n --civitai-color-track: #25262B;\n --civitai-color-segmented-bg: #25262B;\n --civitai-color-media-placeholder: #25262B;\n}\n\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme]) {\n color-scheme: dark;\n --civitai-color-text: #C1C2C5;\n --civitai-color-text-dimmed: #8c8fa3;\n --civitai-color-body: #1A1B1E;\n --civitai-color-surface: #25262B;\n --civitai-color-surface-2: #25262B;\n --civitai-color-border: #373A40;\n --civitai-color-primary: #1971C2;\n --civitai-color-primary-hover: #1864AB;\n --civitai-color-primary-fg: #fefefe;\n --civitai-color-primary-light: rgba(34, 139, 230, 0.15);\n --civitai-color-error: #e03131;\n --civitai-color-success: #326D5C;\n --civitai-color-warning: #e8590c;\n --civitai-color-info: #1971C2;\n --civitai-card-border-width: 0;\n --civitai-color-track: #25262B;\n --civitai-color-segmented-bg: #25262B;\n --civitai-color-media-placeholder: #25262B;\n }\n}\n\n/*\n * @civitai/components \u2014 attribute-driven, framework-agnostic component CSS.\n *\n * Contract: style is selected by `data-civitai-ui=\"<name>\"` + `data-variant` +\n * `data-size` (+ a few component-specific `data-*`). Theme via an ancestor\n * `[data-theme='light'|'dark']`. Tokens come from @civitai/theme's `--civitai-*`\n * custom properties (link `@civitai/theme/styles.css` alongside this file, or\n * call `injectStyles()` which injects both).\n *\n * ALL rules live in `@layer civitai.components` so a consumer's own unlayered\n * CSS always wins the cascade WITHOUT specificity fights. State colors are\n * derived with `color-mix()` from base tokens (no shade enumeration), and\n * structure uses native CSS nesting (no preprocessor).\n *\n * See MARKUP.md for the full per-component markup + ARIA contract.\n */\n@layer civitai.components {\n [data-civitai-ui] {\n box-sizing: border-box;\n font-family: var(--civitai-font);\n\n & *,\n & *::before,\n & *::after {\n box-sizing: border-box;\n }\n }\n\n /* ----- Button ----- */\n [data-civitai-ui='button'] {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n border: 1px solid transparent;\n border-radius: var(--civitai-radius);\n font-family: var(--civitai-font);\n font-weight: 600;\n line-height: 1;\n cursor: pointer;\n user-select: none;\n text-decoration: none;\n transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;\n\n /*\n * Documented DEFAULTS (MARKUP.md): variant=filled, size=md. Applied on the\n * BASE rule (unconditionally) so BARE markup \u2014 a `<button data-civitai-ui=\n * \"button\">` with no `data-variant`/`data-size` \u2014 renders the documented\n * default (filled + md), not an unstyled/zero-size button. The explicit\n * `[data-variant]` / `[data-size]` rules below OVERRIDE (all four variants\n * set their own bg/border/hover, so the base filled default only ever wins\n * for a bare button). Every non-filled variant also sets its own\n * `border-color`, so the base primary border never leaks to them.\n */\n background: var(--civitai-color-primary);\n color: var(--civitai-color-primary-fg);\n border-color: var(--civitai-color-primary);\n height: 36px;\n padding: 0 18px;\n font-size: 14px;\n\n &:hover:not(:disabled) {\n background: var(--civitai-color-primary-hover);\n border-color: var(--civitai-color-primary-hover);\n }\n\n &[data-size='sm'] { height: 30px; padding: 0 14px; font-size: 13px; }\n &[data-size='md'] { height: 36px; padding: 0 18px; font-size: 14px; }\n &[data-size='lg'] { height: 44px; padding: 0 22px; font-size: 16px; }\n &[data-full-width='true'] { width: 100%; }\n\n &[data-variant='filled'] {\n background: var(--civitai-color-primary);\n color: var(--civitai-color-primary-fg);\n border-color: var(--civitai-color-primary);\n\n &:hover:not(:disabled) {\n background: var(--civitai-color-primary-hover);\n border-color: var(--civitai-color-primary-hover);\n }\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-primary) 12%, transparent);\n color: var(--civitai-color-primary);\n border-color: transparent;\n\n &:hover:not(:disabled) {\n background: color-mix(in srgb, var(--civitai-color-primary) 22%, transparent);\n }\n }\n &[data-variant='outline'] {\n background: transparent;\n color: var(--civitai-color-primary);\n border-color: var(--civitai-color-primary);\n\n &:hover:not(:disabled) {\n background: color-mix(in srgb, var(--civitai-color-primary) 10%, transparent);\n }\n }\n &[data-variant='subtle'] {\n background: transparent;\n color: var(--civitai-color-primary);\n border-color: transparent;\n\n &:hover:not(:disabled) {\n background: color-mix(in srgb, var(--civitai-color-primary) 10%, transparent);\n }\n }\n &:disabled,\n &[aria-busy='true'] {\n opacity: 0.6;\n cursor: not-allowed;\n }\n & [data-civitai-ui-section] {\n display: inline-flex;\n align-items: center;\n }\n }\n\n /* ----- TextInput / Textarea / NumberInput / Select ----- */\n [data-civitai-ui='text-input'],\n [data-civitai-ui='textarea'],\n [data-civitai-ui='number-input'],\n [data-civitai-ui='select'] {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n [data-civitai-ui-label] {\n font-size: 14px;\n font-weight: 600;\n color: var(--civitai-color-text);\n }\n [data-civitai-ui-required] {\n color: var(--civitai-color-error);\n margin-left: 2px;\n }\n [data-civitai-ui-description] {\n font-size: 12px;\n color: var(--civitai-color-text-dimmed);\n }\n [data-civitai-ui-error] {\n font-size: 12px;\n color: var(--civitai-color-error);\n }\n [data-civitai-ui-control] {\n width: 100%;\n font-family: var(--civitai-font);\n font-size: 14px;\n color: var(--civitai-color-text);\n background: var(--civitai-color-surface);\n border: 1px solid var(--civitai-color-border);\n border-radius: var(--civitai-radius);\n padding: 8px 12px;\n transition: border-color 120ms ease;\n\n &:focus {\n outline: none;\n border-color: var(--civitai-color-primary);\n }\n &[aria-invalid='true'] {\n border-color: var(--civitai-color-error);\n }\n &:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n }\n textarea[data-civitai-ui-control] {\n resize: vertical;\n line-height: 1.5;\n }\n /*\n * Select (issue #181 F6): a native <select> reusing the shared\n * `-control` field chrome (border/background/radius/focus/invalid, above).\n * Keep the native disclosure caret (most robust + accessible); just make the\n * control feel interactive and reserve room on the right so the caret never\n * overlaps a long option label.\n */\n select[data-civitai-ui-control] {\n cursor: pointer;\n padding-right: 28px;\n line-height: 1.4;\n }\n select[data-civitai-ui-control]:disabled {\n cursor: not-allowed;\n }\n\n /* ----- Checkbox / Radio (issue #181 F6) ----- */\n /*\n * Themed native <input type=\"checkbox\">/<input type=\"radio\">: `accent-color`\n * carries the primary tint (native control, so keyboard/indeterminate/tab all\n * work for free), plus custom sizing, a token focus ring, and disabled state.\n * The box + its inline label sit in a `-choice` row; description + error live\n * below, reusing the shared field markers. The inputs deliberately do NOT\n * carry `data-civitai-ui-control` (that is the full-width field-input chrome).\n */\n [data-civitai-ui='checkbox'],\n [data-civitai-ui='radio'] {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n [data-civitai-ui-choice] {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n [data-civitai-ui='checkbox'] [data-civitai-ui-label],\n [data-civitai-ui='radio'] [data-civitai-ui-label] {\n font-weight: 500;\n cursor: pointer;\n user-select: none;\n }\n [data-civitai-ui='checkbox'] input[type='checkbox'],\n [data-civitai-ui='radio'] input[type='radio'] {\n accent-color: var(--civitai-color-primary);\n width: 16px;\n height: 16px;\n margin: 0;\n flex: none;\n cursor: pointer;\n }\n [data-civitai-ui='checkbox'] input[type='checkbox']:focus-visible,\n [data-civitai-ui='radio'] input[type='radio']:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 2px;\n }\n [data-civitai-ui='checkbox'] input[type='checkbox']:disabled,\n [data-civitai-ui='radio'] input[type='radio']:disabled {\n cursor: not-allowed;\n opacity: 0.6;\n }\n [data-civitai-ui='checkbox'] input[type='checkbox']:disabled ~ [data-civitai-ui-label],\n [data-civitai-ui='radio'] input[type='radio']:disabled ~ [data-civitai-ui-label] {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n /* ----- RadioGroup (issue #181 F6) \u2014 role=radiogroup layout ----- */\n [data-civitai-ui='radio-group'] {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n [data-civitai-ui-radio-options] {\n display: flex;\n flex-direction: column;\n gap: 8px;\n\n &[data-orientation='horizontal'] {\n flex-direction: row;\n flex-wrap: wrap;\n gap: 16px;\n }\n }\n /*\n * RadioGroup group-level error (0.2.0): mirrors the field `-error` treatment.\n * The message reuses the shared `[data-civitai-ui-error]` styling (12px, error\n * token); the invalid state (`data-invalid` on the group, set alongside\n * `aria-invalid`) tints the child radios' `accent-color` to the error token so\n * the invalid cue reads on the group, not just the message text \u2014 the group\n * analogue of a field control's error-colored border.\n */\n [data-civitai-ui='radio-group'] [data-civitai-ui-error] {\n font-size: 12px;\n color: var(--civitai-color-error);\n }\n [data-civitai-ui='radio-group'][data-invalid] input[type='radio'] {\n accent-color: var(--civitai-color-error);\n }\n\n /* ----- Card ----- */\n /*\n * F5 (issue #181): light resolves surface and body to the same #fefefe, so a\n * borderless card needs a default hairline to be visible at all; dark already\n * separates them and zeroes the width. `data-with-border` is the stronger,\n * explicit, opaque border and is unconditional.\n */\n [data-civitai-ui='card'] {\n background: var(--civitai-color-surface);\n border-radius: var(--civitai-radius);\n color: var(--civitai-color-text);\n border: var(--civitai-card-border-width, 1px) solid\n color-mix(in srgb, var(--civitai-color-border) 55%, transparent);\n\n &[data-with-border='true'] {\n border: 1px solid var(--civitai-color-border);\n }\n &[data-padding='sm'] { padding: 10px; }\n &[data-padding='md'] { padding: 16px; }\n &[data-padding='lg'] { padding: 24px; }\n }\n\n /* ----- Stack / Group ----- */\n [data-civitai-ui='stack'] {\n display: flex;\n flex-direction: column;\n gap: 12px;\n\n &[data-gap='sm'] { gap: 8px; }\n &[data-gap='md'] { gap: 16px; }\n &[data-gap='lg'] { gap: 24px; }\n }\n [data-civitai-ui='group'] {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 8px;\n\n /*\n * RESPONSIVE BASE. A group is a ROW, and a row of three or more controls\n * has no escape at phone widths \u2014 a block's slot is ~360px there, so the\n * row simply ran off the edge.\n *\n * \uD83D\uDD34 There are THREE `group` surfaces, and for TWO of them this is a NEW\n * DEFAULT \u2014 do not read it as the CSS merely catching up:\n * - `@civitai/blocks-react`'s `<Group>` defaults `wrap = true` and writes\n * `flex-wrap` as an INLINE style, so its consumers already wrapped.\n * For that one, and only that one, this is the CSS agreeing with a\n * default that was already shipping.\n * - `@civitai/components-react`'s `<Group>` writes NO inline style and has\n * NO `wrap` prop, so it resolves against this rule \u2014 a React consumer\n * that did NOT wrap before.\n * - bare `data-civitai-ui=\"group\"` markup, the framework-agnostic contract\n * this package exists to serve \u2014 likewise.\n * Nothing could see the disagreement, because each surface was only ever\n * tested against itself. TWO tests now pin it, one per React surface \u2014 there\n * is no single \"the parity test\":\n * - `civitai-blocks-react/test/Group.test.tsx` reads the wrap default off\n * ITS `<Group>` and compares it to this rule, so flipping either alone\n * fails;\n * - `civitai-components-react/test/html-vs-react-parity.browser.test.tsx`\n * (\"styling anchors \u2014 Group\") asserts the COMPUTED value on both the\n * React and the bare-markup render, which is what covers the other two\n * surfaces. A parity check alone cannot: deleting this rule moves both\n * of its arms identically, so only the absolute anchor sees it.\n *\n * \uD83D\uDD34 The three-surface distinction is load-bearing for the RELEASE, not just\n * the prose: \"existing callers notice\" is what `RELEASING.md` reserves\n * `major` for, and it is true for two of the three surfaces. Shipped as a\n * MINOR by maintainer decision \u2014 see the changeset for the reasoning and for\n * the upgrade note that decision makes load-bearing.\n *\n * Opt out with `data-nowrap=\"true\"`. An inline `flex-wrap` also still\n * outranks this layer, so existing markup that already sets it is untouched.\n */\n flex-wrap: wrap;\n\n /*\n * A flex item defaults to `min-width: auto`, which refuses to shrink below\n * its CONTENT width \u2014 so one long label (a model name, a prompt) pushes the\n * row past the slot even WITH wrap. `min-width: 0` lets it shrink.\n *\n * \uD83D\uDD34 SCOPE, measured: this only matters for a child whose computed\n * `overflow` is `visible`. Per CSS Flexbox \u00A74.5 an item with any other\n * `overflow` ALREADY has an automatic minimum size of 0 \u2014 so a child that\n * sets `overflow: hidden`, as any `text-overflow: ellipsis` child must, is\n * unaffected by this line. An earlier version of this comment claimed\n * ellipsis \"can never engage\" without it, which was exactly backwards; the\n * browser guard built on that belief used an ellipsis fixture and therefore\n * passed with this rule DELETED. Both are fixed \u2014 see\n * `civitai-blocks-react/test/responsive-group.browser.test.tsx`.\n *\n * Specificity is (0,1,0), not zero: `&` carries the parent selector's, and\n * `:where(*)` is specificity-identical to `*`, so that wrapper bought\n * nothing and has been dropped. What actually lets a CONSUMER's own rule\n * win is `@layer civitai.components`, which every rule in this file sits\n * inside \u2014 unlayered author CSS beats it regardless of specificity. Note\n * the limit: another rule INSIDE this layer does not get that protection,\n * so a future non-zero `min-width` on a nested component here must be more\n * specific or come later.\n */\n & > * { min-width: 0; }\n\n &[data-nowrap='true'] { flex-wrap: nowrap; }\n\n &[data-gap='sm'] { gap: 6px; }\n &[data-gap='md'] { gap: 16px; }\n &[data-gap='lg'] { gap: 24px; }\n }\n\n /* ----- Alert ----- */\n [data-civitai-ui='alert'] {\n display: flex;\n gap: 10px;\n align-items: flex-start;\n padding: 12px 14px;\n border-radius: var(--civitai-radius);\n border: 1px solid transparent;\n font-size: 14px;\n color: var(--civitai-color-text);\n\n /*\n * Documented DEFAULT (MARKUP.md): color=info (default intent). Applied on\n * the base rule so a BARE alert \u2014 `<div data-civitai-ui=\"alert\">` with no\n * `data-color` \u2014 renders the info intent (tinted bg + border), not just the\n * neutral chrome. The explicit `[data-color]` rules below OVERRIDE (success\n * / warning / error each set both bg + border-color), so the info default\n * only ever wins when `data-color` is omitted.\n */\n background: color-mix(in srgb, var(--civitai-color-info) 12%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-info) 35%, transparent);\n\n &[data-color='info'] {\n background: color-mix(in srgb, var(--civitai-color-info) 12%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-info) 35%, transparent);\n }\n &[data-color='success'] {\n background: color-mix(in srgb, var(--civitai-color-success) 12%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-success) 35%, transparent);\n }\n &[data-color='warning'] {\n background: color-mix(in srgb, var(--civitai-color-warning) 14%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-warning) 35%, transparent);\n }\n &[data-color='error'] {\n background: color-mix(in srgb, var(--civitai-color-error) 12%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-error) 35%, transparent);\n }\n & [data-civitai-ui-alert-body] {\n flex: 1;\n min-width: 0;\n }\n & [data-civitai-ui-alert-title] {\n font-weight: 600;\n margin-bottom: 2px;\n }\n & [data-civitai-ui-alert-close] {\n background: transparent;\n border: none;\n cursor: pointer;\n color: inherit;\n font-size: 16px;\n line-height: 1;\n padding: 0;\n opacity: 0.7;\n\n &:hover { opacity: 1; }\n }\n }\n\n /* ----- Loader ----- */\n [data-civitai-ui='loader'] {\n display: inline-block;\n border-radius: 50%;\n border-style: solid;\n border-color: color-mix(in srgb, currentColor 25%, transparent);\n border-top-color: currentColor;\n color: var(--civitai-color-primary);\n animation: civitai-ui-spin 0.7s linear infinite;\n\n /*\n * Documented DEFAULT (MARKUP.md): size=md. Applied on the base rule so a\n * BARE loader \u2014 `<span data-civitai-ui=\"loader\">` with no `data-size` \u2014 has\n * real dimensions (otherwise 0\u00D70 and invisible). sm/lg OVERRIDE below.\n */\n width: 22px;\n height: 22px;\n border-width: 3px;\n\n &[data-size='sm'] { width: 16px; height: 16px; border-width: 2px; }\n &[data-size='md'] { width: 22px; height: 22px; border-width: 3px; }\n &[data-size='lg'] { width: 32px; height: 32px; border-width: 4px; }\n }\n [data-civitai-ui='button'] [data-civitai-ui='loader'] {\n color: currentColor;\n }\n @keyframes civitai-ui-spin {\n to { transform: rotate(360deg); }\n }\n\n /* ----- Badge ----- */\n [data-civitai-ui='badge'] {\n display: inline-flex;\n align-items: center;\n border: 1px solid transparent;\n border-radius: 999px;\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n text-transform: uppercase;\n letter-spacing: 0.02em;\n\n /*\n * Documented DEFAULTS (MARKUP.md): variant=filled, size=md. Applied on the\n * base rule so a BARE badge \u2014 `<span data-civitai-ui=\"badge\">` with no\n * `data-variant`/`data-size` (MARKUP's own minimal example) \u2014 renders\n * filled + md (padding + primary fill), not an unstyled, zero-padding pill.\n * The explicit `[data-variant]`/`[data-size]` rules below OVERRIDE. Because\n * the base now carries a primary `border-color`, the `light` variant (which\n * previously relied on the base transparent border) explicitly resets it to\n * transparent so light badges are visually unchanged.\n */\n height: 22px;\n padding: 0 10px;\n font-size: 11px;\n background: var(--civitai-color-primary);\n color: var(--civitai-color-primary-fg);\n border-color: var(--civitai-color-primary);\n\n &[data-size='sm'] { height: 18px; padding: 0 8px; font-size: 10px; }\n &[data-size='md'] { height: 22px; padding: 0 10px; font-size: 11px; }\n &[data-size='lg'] { height: 26px; padding: 0 12px; font-size: 13px; }\n &[data-variant='filled'] {\n background: var(--civitai-color-primary);\n color: var(--civitai-color-primary-fg);\n border-color: var(--civitai-color-primary);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-primary) 14%, transparent);\n color: var(--civitai-color-primary);\n border-color: transparent;\n }\n &[data-variant='outline'] {\n background: transparent;\n color: var(--civitai-color-primary);\n border-color: var(--civitai-color-primary);\n }\n\n /*\n * Intent color via `data-color`, mirroring Alert's `data-color` contract\n * (info / success / warning / error). Absent `data-color` => the default\n * primary above (non-breaking). Each intent recolors the `filled`, `light`\n * and `outline` variants with the same `color-mix()` token approach Alert\n * uses; `filled` keeps the white `--civitai-color-primary-fg` text. These\n * `[data-color][data-variant]` rules out-specify the plain-variant rules\n * above, so order-independence holds.\n */\n &[data-color='info'] {\n &[data-variant='filled'] {\n background: var(--civitai-color-info);\n border-color: var(--civitai-color-info);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-info) 14%, transparent);\n color: var(--civitai-color-info);\n }\n &[data-variant='outline'] {\n color: var(--civitai-color-info);\n border-color: var(--civitai-color-info);\n }\n }\n &[data-color='success'] {\n &[data-variant='filled'] {\n background: var(--civitai-color-success);\n border-color: var(--civitai-color-success);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-success) 14%, transparent);\n color: var(--civitai-color-success);\n }\n &[data-variant='outline'] {\n color: var(--civitai-color-success);\n border-color: var(--civitai-color-success);\n }\n }\n &[data-color='warning'] {\n &[data-variant='filled'] {\n background: var(--civitai-color-warning);\n border-color: var(--civitai-color-warning);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-warning) 14%, transparent);\n color: var(--civitai-color-warning);\n }\n &[data-variant='outline'] {\n color: var(--civitai-color-warning);\n border-color: var(--civitai-color-warning);\n }\n }\n &[data-color='error'] {\n &[data-variant='filled'] {\n background: var(--civitai-color-error);\n border-color: var(--civitai-color-error);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-error) 14%, transparent);\n color: var(--civitai-color-error);\n }\n &[data-variant='outline'] {\n color: var(--civitai-color-error);\n border-color: var(--civitai-color-error);\n }\n }\n }\n\n /* ----- Slider ----- */\n /*\n * A themed native <input type=\"range\">: `accent-color` carries the primary\n * tint (native control, so keyboard arrow-key nav + ARIA come for free), plus\n * a full-width track, a token focus ring, and disabled/invalid states. Like\n * checkbox/radio, the range input deliberately does NOT carry\n * `data-civitai-ui-control` (that is the bordered field-input chrome). The\n * label/description/error reuse the shared field markers, laid out in a\n * column; an optional value read-out sits inline with the label in a header.\n */\n [data-civitai-ui='slider'] {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n [data-civitai-ui-slider-header] {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n }\n [data-civitai-ui-slider-value] {\n font-size: 13px;\n font-weight: 600;\n color: var(--civitai-color-text-dimmed);\n font-variant-numeric: tabular-nums;\n }\n [data-civitai-ui='slider'] input[type='range'] {\n -webkit-appearance: none;\n appearance: none;\n width: 100%;\n height: 6px;\n margin: 6px 0;\n padding: 0;\n border-radius: 999px;\n accent-color: var(--civitai-color-primary);\n background: var(--civitai-color-track, var(--civitai-color-gray-2));\n cursor: pointer;\n }\n [data-civitai-ui='slider'] input[type='range']:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 4px;\n }\n [data-civitai-ui='slider'] input[type='range']:disabled {\n cursor: not-allowed;\n opacity: 0.6;\n accent-color: var(--civitai-color-gray-5);\n }\n [data-civitai-ui='slider'][data-invalid] input[type='range'] {\n accent-color: var(--civitai-color-error);\n }\n\n /* ----- SegmentedControl / Tabs ----- */\n /*\n * A `role=tablist` of segment buttons (`role=tab`). Presentational chrome\n * only \u2014 the roving-tabindex + arrow-key nav + selection follow-focus live in\n * the React binding (or must be author-provided for hand HTML). The selected\n * segment (`aria-selected='true'`) lifts to the surface color with a soft\n * shadow; unselected segments read dimmed. Paired tab panels use\n * `data-civitai-ui-tabpanel` (role=tabpanel).\n */\n [data-civitai-ui='segmented-control'] {\n display: inline-flex;\n gap: 2px;\n padding: 4px;\n background: var(--civitai-color-segmented-bg, var(--civitai-color-gray-1));\n border-radius: var(--civitai-radius);\n max-width: 100%;\n overflow-x: auto;\n }\n [data-civitai-ui-segment] {\n -webkit-appearance: none;\n appearance: none;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: 0;\n background: transparent;\n color: var(--civitai-color-text-dimmed);\n font-family: var(--civitai-font);\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n cursor: pointer;\n border-radius: calc(var(--civitai-radius) - 1px);\n transition: background-color 120ms ease, color 120ms ease;\n\n /* Documented DEFAULT (MARKUP.md): size=md. On the base rule so a bare\n segment renders at md height, not zero-height. sm/lg override below. */\n height: 30px;\n padding: 0 14px;\n font-size: 13px;\n\n &[data-size='sm'] { height: 26px; padding: 0 10px; font-size: 12px; }\n &[data-size='md'] { height: 30px; padding: 0 14px; font-size: 13px; }\n &[data-size='lg'] { height: 38px; padding: 0 18px; font-size: 15px; }\n\n &:hover:not(:disabled):not([aria-selected='true']):not([aria-checked='true']) {\n color: var(--civitai-color-text);\n }\n /* Selected state \u2014 `aria-selected` in tabs mode (role=tab) OR `aria-checked`\n in toggle mode (role=radio). Same visual treatment for both. */\n &[aria-selected='true'],\n &[aria-checked='true'] {\n background: var(--civitai-color-surface);\n color: var(--civitai-color-text);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);\n }\n &:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 2px;\n }\n &:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n }\n }\n [data-civitai-ui-tabpanel] {\n color: var(--civitai-color-text);\n\n &:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 2px;\n }\n &[hidden] { display: none; }\n }\n\n /* ----- Toast ----- */\n /*\n * `toast-region` is the fixed-position `aria-live` host (bottom-right stack).\n * The behavior \u2014 enqueue, auto-dismiss timers, portal \u2014 lives in the React\n * binding's ToastProvider/useToast; the presentational `toast` is a standalone\n * component so hand HTML and React share one visual contract. Each toast is\n * opaque (it sits over content) with an intent-colored left accent, mirroring\n * Alert's `data-color` set.\n */\n [data-civitai-ui='toast-region'] {\n position: fixed;\n bottom: 16px;\n right: 16px;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 8px;\n width: min(92vw, 380px);\n pointer-events: none;\n }\n [data-civitai-ui='toast'] {\n pointer-events: auto;\n display: flex;\n gap: 10px;\n align-items: flex-start;\n padding: 12px 14px;\n border-radius: var(--civitai-radius);\n background: var(--civitai-color-surface);\n color: var(--civitai-color-text);\n border: 1px solid var(--civitai-color-border);\n border-left: 4px solid var(--civitai-color-border);\n box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);\n font-size: 14px;\n\n &[data-color='info'] { border-left-color: var(--civitai-color-info); }\n &[data-color='success'] { border-left-color: var(--civitai-color-success); }\n &[data-color='warning'] { border-left-color: var(--civitai-color-warning); }\n &[data-color='error'] { border-left-color: var(--civitai-color-error); }\n\n & [data-civitai-ui-toast-body] {\n flex: 1;\n min-width: 0;\n }\n & [data-civitai-ui-toast-title] {\n font-weight: 600;\n margin-bottom: 2px;\n }\n & [data-civitai-ui-toast-close] {\n background: transparent;\n border: none;\n cursor: pointer;\n color: inherit;\n font-size: 16px;\n line-height: 1;\n padding: 0;\n opacity: 0.7;\n\n &:hover { opacity: 1; }\n }\n }\n\n /* ----- Tooltip ----- */\n /*\n * A hover/focus tooltip: a positioned `role=tooltip` bubble revealed when the\n * wrapper is hovered or contains focus (pure-CSS reveal), or explicitly via\n * `data-open='true'` (the React binding also wires `aria-describedby` +\n * Escape-to-dismiss). The bubble is a dark chip (gray-9) with light text in\n * both themes.\n */\n [data-civitai-ui='tooltip'] {\n position: relative;\n display: inline-flex;\n }\n [data-civitai-ui-tooltip-bubble] {\n position: absolute;\n bottom: calc(100% + 6px);\n left: 50%;\n transform: translateX(-50%);\n z-index: 200;\n max-width: 260px;\n width: max-content;\n padding: 4px 8px;\n border-radius: var(--civitai-radius);\n background: var(--civitai-color-gray-9);\n color: var(--civitai-color-primary-fg);\n font-size: 12px;\n font-weight: 500;\n line-height: 1.4;\n text-align: center;\n pointer-events: none;\n opacity: 0;\n visibility: hidden;\n transition: opacity 120ms ease;\n }\n /*\n * Reveal on hover/focus or an explicit `data-open='true'` \u2014 BUT\n * `data-dismissed='true'` overrides (gates every reveal selector), so the\n * React binding's Escape-to-dismiss can force-HIDE the bubble even while the\n * pointer still hovers or focus is still within. The dismissed flag is cleared\n * on the next hover/focus so the tooltip can re-open normally.\n */\n [data-civitai-ui='tooltip']:hover [data-civitai-ui-tooltip-bubble]:not([data-dismissed='true']),\n [data-civitai-ui='tooltip']:focus-within [data-civitai-ui-tooltip-bubble]:not([data-dismissed='true']),\n [data-civitai-ui-tooltip-bubble][data-open='true']:not([data-dismissed='true']) {\n opacity: 1;\n visibility: visible;\n }\n\n /* ----- Image ----- */\n /*\n * A media container with a token placeholder background (visible while the\n * image loads), object-fit control, and a broken-image fallback. `data-status`\n * (loading|loaded|error) \u2014 set by the React binding's onLoad/onError, or by\n * the author for hand HTML \u2014 fades the <img> in on load and swaps to the\n * fallback overlay on error. Bare markup (no `data-status`) shows the image.\n */\n [data-civitai-ui='image'] {\n position: relative;\n display: block;\n overflow: hidden;\n background: var(--civitai-color-media-placeholder, var(--civitai-color-gray-2));\n border-radius: var(--civitai-radius);\n }\n [data-civitai-ui-image-img] {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n opacity: 1;\n transition: opacity 200ms ease;\n\n &[data-fit='contain'] { object-fit: contain; }\n &[data-fit='cover'] { object-fit: cover; }\n }\n [data-civitai-ui='image'][data-status='loading'] [data-civitai-ui-image-img],\n [data-civitai-ui='image'][data-status='error'] [data-civitai-ui-image-img] {\n opacity: 0;\n }\n [data-civitai-ui-image-fallback] {\n position: absolute;\n inset: 0;\n display: none;\n align-items: center;\n justify-content: center;\n padding: 8px;\n color: var(--civitai-color-text-dimmed);\n font-size: 13px;\n text-align: center;\n }\n [data-civitai-ui='image'][data-status='error'] [data-civitai-ui-image-fallback] {\n display: flex;\n }\n\n /* ----- Text ----- */\n /*\n * The typography primitive. Alone in this sheet it prescribes NO element of\n * its own: the author writes the tag the meaning calls for \u2014 `<h1>`..`<h6>`\n * for a heading, `<p>` for a paragraph, `<span>` for inline \u2014 and this rule\n * sizes and colours whatever that is. Nothing in the visual scale implies a\n * heading level and nothing in a heading level implies a size, which is what\n * lets an `<h2>` be the small caption of a card.\n *\n * `display` is deliberately NOT set, so `<p>`/`<h2>` stay block and `<span>`\n * stays inline: the element's own semantics keep deciding its box.\n *\n * `margin: 0` IS set, and it is a decision rather than a reset for tidiness.\n * The UA gives headings and paragraphs an em-relative margin that moves with\n * every `data-size`, spacing in this pack is owned by `stack`/`group`, and\n * without it `<civitai-text as=\"h2\">` and `<h2 data-civitai-ui=\"text\">` lay\n * out differently \u2014 which is the one thing the two tracks may not do.\n *\n * Documented DEFAULTS (MARKUP.md): size=md, weight=normal, no `data-color`.\n * On the BASE rule so BARE markup renders them, exactly as button and badge\n * do; the explicit `[data-size]`/`[data-weight]` rules below OVERRIDE.\n */\n [data-civitai-ui='text'] {\n margin: 0;\n /*\n * \uD83D\uDD34 `inherit`, NOT `var(--civitai-color-text)` \u2014 see the NO COLOUR AXIS\n * note at the end of this rule. A SPECIFIED value beats an INHERITED one\n * whatever the specificity, so the token here silently cancelled the\n * ancestor route this component's whole colour story rests on.\n */\n color: inherit;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.5;\n\n /*\n * THE TYPE SCALE \u2014 ONE scale, and the reason it runs this far is that the\n * package must not ship two that disagree.\n *\n * The bottom half is the UI ramp: `sm`/`md`/`lg` are byte-identical to\n * Button's own font-size ramp (13/14/16px), so one size name means one size\n * across the pack, and `xs` is the 12px the field description already uses.\n *\n * The top half is the HEADING ramp, and every step of it is a value\n * `utilities.css` already ships as `ci-fs-N` \u2014 so the two are the same\n * scale under two spellings, not two scales:\n *\n * lg 16px = ci-fs-6 2xl 24px = ci-fs-4 4xl 32px = ci-fs-2\n * xl 20px = ci-fs-5 3xl 28px = ci-fs-3 5xl 40px = ci-fs-1\n *\n * What that costs: the t-shirt names do not encode the `ci-fs-N` number,\n * and the two sequences run in OPPOSITE directions (5xl is ci-fs-1), so a\n * reader needs the mapping above. That was the cheaper trade \u2014 naming the\n * new steps `fs-1`..`fs-4` would have put two naming conventions inside one\n * attribute and reversed its direction halfway up. Nothing above `ci-fs-1`\n * is invented here; 40px is the top of both ladders.\n *\n * UNIT: this ramp is px (Button's unit), `ci-fs-*` is rem. They agree at\n * the default 16px root and diverge if a consumer changes it. Deliberate \u2014\n * mixing units within one ramp would make it non-monotonic under a changed\n * root, which is worse than this caveat. Already true of `lg`/`xl` before\n * the ramp was extended.\n *\n * Everything from `xl` up tightens its line-height to 1.25 \u2014 a 20px-plus\n * heading leaded at 1.5 reads as loose. Two leading values in total.\n */\n &[data-size='xs'] { font-size: 12px; }\n &[data-size='sm'] { font-size: 13px; }\n &[data-size='md'] { font-size: 14px; }\n &[data-size='lg'] { font-size: 16px; }\n &[data-size='xl'] { font-size: 20px; line-height: 1.25; }\n &[data-size='2xl'] { font-size: 24px; line-height: 1.25; }\n &[data-size='3xl'] { font-size: 28px; line-height: 1.25; }\n &[data-size='4xl'] { font-size: 32px; line-height: 1.25; }\n &[data-size='5xl'] { font-size: 40px; line-height: 1.25; }\n\n /* `normal` / `semibold` / `bold` are the weights `utilities.css` already\n spells (`ci-normal` 400, `ci-semibold` 600, `ci-bold` 700); `medium` is\n the 500 this sheet already uses for a radio-group and a toast title. */\n &[data-weight='normal'] { font-weight: 400; }\n &[data-weight='medium'] { font-weight: 500; }\n &[data-weight='semibold'] { font-weight: 600; }\n &[data-weight='bold'] { font-weight: 700; }\n\n /*\n * NO COLOUR AXIS, and that is the same predicate this component already\n * applies to alignment and truncation rather than a separate judgement.\n * Every value one would carry already exists as a utility that reaches this\n * element: `ci-muted` (the dimmed token), `ci-text-info` / `-success` /\n * `-warning` / `-error` (the intent enum), `ci-text-default` (the body\n * colour). `color` INHERITS, so a utility on this element or any ancestor\n * reaches the inner element of `<civitai-text>` through its shadow boundary\n * too \u2014 the inner element is `color: inherit` \u2014 which is exactly why a\n * `data-color` here would be a second copy of a predicate that already has\n * an implementation one layer down.\n *\n * \uD83D\uDD34 WHICH IS WHY THE BASE RULE SAYS `color: inherit` AND NOT THE TOKEN.\n * The ancestor half of that sentence was measurably FALSE while this rule\n * specified `var(--civitai-color-text)` \u2014 a *specified* value beats an\n * *inherited* one however far up the utility sits, so `ci-muted` on a wrapper\n * dimmed a plain `<p>` and left `<p data-civitai-ui=\"text\">` undimmed.\n * `ci-text-center` on that same wrapper always worked, because nothing here\n * re-specifies `text-align`. `<civitai-text>`'s `:host` had the identical\n * defect; both tracks changed together, as they must.\n *\n * THE TRADE, and it is NOT limited to a page that paints nothing: Text no\n * longer paints the token itself, so it takes whatever colour it inherits.\n * That decides what it renders on every page that DOES set a colour \u2014 the\n * larger population, and the one every in-repo consumer would be in once it\n * renders Text, which none does today (four starters colour `body` via\n * Tailwind; seven more colour a `[data-theme]` root).\n * Measured on both tracks, on the shape a block here actually has \u2014 a\n * `[data-theme='dark']` root carrying `color: #e6e6e6` \u2014 Text computes\n * `rgb(230, 230, 230)`, where restoring the removed declaration puts both\n * tracks back at the dark token `rgb(193, 194, 197)`. With no colour anywhere\n * it lands on the UA default `rgb(0, 0, 0)`. `ci-text-default` asks for the\n * token back, but it ships in a separate stylesheet that neither\n * `injectStyles()` nor `injectBlocksStyles()` injects \u2014 see the cost section\n * in `src/elements/civitai-text.ts` for every measurement and\n * `packages/civitai-components/MARKUP.md` for what a consumer has to load.\n * Both tracks are pinned in `test/civitai-text.browser.test.ts`.\n *\n * It is also the direction that stays open: this ships `minor` on a\n * published package, so adding the axis later is additive and taking it\n * away would not be.\n */\n }\n}\n\n\n/* ----- Select / Slider field wrappers -----\n The shared -control/-label/-required/-description/-error primitives come from\n @civitai/components (layered). Only the select/slider WRAPPERS live here \u2014 the\n design-system package scopes its field-wrapper rule to the presentational\n inputs (text-input/textarea/number-input). */\n[data-civitai-ui='select'],\n[data-civitai-ui='slider'] {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n/* ----- Modal ----- */\n[data-civitai-ui='modal-overlay'] {\n position: fixed;\n inset: 0;\n background: rgba(0, 0, 0, 0.55);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 32px 16px;\n overflow-y: auto;\n z-index: 1000;\n}\n[data-civitai-ui='modal'] {\n background: var(--civitai-color-surface);\n color: var(--civitai-color-text);\n border: 1px solid var(--civitai-color-border);\n border-radius: var(--civitai-radius);\n box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);\n width: 100%;\n max-width: 440px;\n outline: none;\n}\n[data-civitai-ui='modal'][data-size='sm'] { max-width: 340px; }\n[data-civitai-ui='modal'][data-size='md'] { max-width: 440px; }\n[data-civitai-ui='modal'][data-size='lg'] { max-width: 620px; }\n[data-civitai-ui='modal'] [data-civitai-ui-modal-header] {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 16px 18px;\n border-bottom: 1px solid var(--civitai-color-border);\n}\n[data-civitai-ui='modal'] [data-civitai-ui-modal-title] {\n font-size: 16px;\n font-weight: 700;\n margin: 0;\n}\n[data-civitai-ui='modal'] [data-civitai-ui-modal-close] {\n background: transparent;\n border: none;\n cursor: pointer;\n color: var(--civitai-color-text-dimmed);\n font-size: 20px;\n line-height: 1;\n padding: 0;\n}\n[data-civitai-ui='modal'] [data-civitai-ui-modal-close]:hover {\n color: var(--civitai-color-text);\n}\n[data-civitai-ui='modal'] [data-civitai-ui-modal-body] {\n padding: 18px;\n}\n\n/* ----- Slider ----- */\n[data-civitai-ui='slider'] [data-civitai-ui-label] {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 8px;\n}\n[data-civitai-ui-slider-value] {\n font-weight: 500;\n color: var(--civitai-color-text-dimmed);\n font-variant-numeric: tabular-nums;\n}\n[data-civitai-ui-range] {\n width: 100%;\n margin: 0;\n accent-color: var(--civitai-color-primary);\n cursor: pointer;\n}\n[data-civitai-ui-range]:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n[data-civitai-ui-range]:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 2px;\n}\n\n/* ----- Collapse ----- */\n[data-civitai-ui='collapse'] [data-civitai-ui-collapse-trigger] {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n padding: 6px 0;\n background: transparent;\n border: none;\n font-family: var(--civitai-font);\n font-size: 14px;\n font-weight: 600;\n color: var(--civitai-color-text);\n text-align: left;\n cursor: pointer;\n}\n[data-civitai-ui='collapse'] [data-civitai-ui-collapse-trigger]:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n[data-civitai-ui='collapse'] [data-civitai-ui-collapse-chevron] {\n display: inline-block;\n width: 1em;\n color: var(--civitai-color-text-dimmed);\n}\n[data-civitai-ui='collapse'] [data-civitai-ui-collapse-region] {\n padding-top: 4px;\n}\n\n/* ----- ResourceCard -----\n Two variants off ONE box. data-variant='card' stacks (grid tile),\n data-variant='row' runs inline (compact list line). Every selector is\n double-qualified with [data-civitai-ui='resource-card'] on purpose:\n data-variant is also emitted by Button and Badge, so a bare\n [data-variant='card'] would reach across the whole pack.\n NOTE: this block lives inside a JS TEMPLATE LITERAL. No backticks, and no\n dollar-brace, or the string ends here and the file stops parsing as\n TypeScript (backticks in this comment did exactly that once). */\n[data-civitai-ui='resource-card'] {\n display: flex;\n box-sizing: border-box;\n /* The positioned ancestor for the overlay slot. It lives on the ROOT, not on\n the thumbnail frame, so the overlay can be a SIBLING of the hit <button>\n rather than a descendant of it \u2014 see the overlay prop's doc for why that\n distinction is the whole point of the slot. */\n position: relative;\n font-family: var(--civitai-font);\n color: var(--civitai-color-text);\n background: var(--civitai-color-surface);\n border: 1px solid var(--civitai-color-border);\n border-radius: var(--civitai-radius);\n overflow: hidden;\n}\n[data-civitai-ui='resource-card'][data-variant='card'] {\n flex-direction: column;\n align-items: stretch;\n}\n[data-civitai-ui='resource-card'][data-variant='row'] {\n flex-direction: row;\n align-items: center;\n gap: 8px;\n padding: 6px 8px;\n}\n[data-civitai-ui='resource-card'][data-selected='true'] {\n border-color: var(--civitai-color-primary);\n}\n[data-civitai-ui='resource-card'][data-disabled='true'] {\n opacity: 0.6;\n}\n/* The hit area. A <button> when interactive, a <div> when not \u2014 both are reset\n to the same box so the two variants lay out identically either way. */\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-hit] {\n display: flex;\n /* \uD83D\uDD34 A flex ITEM defaults to min-width:auto, which refuses to shrink below its\n content \u2014 so without this the name's nowrap+ellipsis never engages and a\n long model name blows the card out of its grid cell instead of truncating. */\n min-width: 0;\n box-sizing: border-box;\n margin: 0;\n border: none;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n}\n[data-civitai-ui='resource-card'][data-variant='card'] [data-civitai-ui-resource-hit] {\n flex-direction: column;\n align-items: stretch;\n gap: 6px;\n padding: 6px;\n}\n[data-civitai-ui='resource-card'][data-variant='row'] [data-civitai-ui-resource-hit] {\n flex: 1 1 auto;\n flex-direction: row;\n align-items: center;\n gap: 8px;\n padding: 0;\n}\n[data-civitai-ui='resource-card'] button[data-civitai-ui-resource-hit] {\n cursor: pointer;\n}\n[data-civitai-ui='resource-card'] button[data-civitai-ui-resource-hit]:disabled {\n cursor: not-allowed;\n}\n[data-civitai-ui='resource-card'] button[data-civitai-ui-resource-hit]:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: -2px;\n}\n/* The thumbnail frame. Rendered whether or not there is an image \u2014 see the\n component's thumbnailUrl doc: BlockResourceInfo has no image field, so \"no\n image\" is the COMMON case and the frame must keep its box regardless. */\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-thumb] {\n display: flex;\n align-items: center;\n justify-content: center;\n flex: none;\n overflow: hidden;\n background: var(--civitai-color-surface-2);\n border-radius: calc(var(--civitai-radius) - 1px);\n color: var(--civitai-color-text-dimmed);\n}\n[data-civitai-ui='resource-card'][data-variant='card'] [data-civitai-ui-resource-thumb] {\n /* \uD83D\uDD34 aspect-ratio, not a fixed height: the tile is grid-sized by the caller,\n and this is the line that stops a thumbnail-less card collapsing to a\n text-height sliver. */\n width: 100%;\n aspect-ratio: 1 / 1;\n font-size: 11px;\n}\n[data-civitai-ui='resource-card'][data-variant='row'] [data-civitai-ui-resource-thumb] {\n width: 36px;\n height: 36px;\n font-size: 9px;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-thumb] img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n display: block;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-placeholder] {\n padding: 0 4px;\n text-align: center;\n line-height: 1.2;\n}\n/* Positioned from the ROOT, over the thumbnail corner. 10px = the hit area's\n 6px padding plus a 4px inset inside the frame; the three move together, and\n the browser tier asserts the result lands inside the frame's own rect rather\n than trusting the arithmetic.\n pointer-events: none is load-bearing, not polish: this slot is STATUS, and a\n pill that can swallow a click meant for the card is the same class of bug as\n nesting a control inside the hit <button>. A consumer who really wants an\n interactive badge opts back in with pointer-events: auto on their own node. */\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-overlay] {\n position: absolute;\n top: 10px;\n right: 10px;\n z-index: 1;\n pointer-events: none;\n display: flex;\n align-items: center;\n gap: 4px;\n max-width: calc(100% - 20px);\n font-size: 11px;\n line-height: 1;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-nameline] {\n display: flex;\n align-items: center;\n gap: 4px;\n min-width: 0;\n}\n/* The non-colour half of the selected affordance (WCAG 1.4.1). The border-hue\n change on the root is reinforcement; this glyph is what carries it. */\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-selected] {\n flex: none;\n font-size: 12px;\n font-weight: 700;\n line-height: 1;\n color: var(--civitai-color-primary);\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-text] {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n flex: 1 1 auto;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-name] {\n display: block;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-meta] {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 4px 6px;\n min-width: 0;\n font-size: 11px;\n color: var(--civitai-color-text-dimmed);\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-actions] {\n display: flex;\n align-items: center;\n flex: none;\n gap: 6px;\n}\n[data-civitai-ui='resource-card'][data-variant='card'] [data-civitai-ui-resource-actions] {\n padding: 0 6px 6px;\n}\n\n/* ----- SegmentedControl ----- */\n[data-civitai-ui='segmented-control'] {\n display: inline-flex;\n flex-direction: row;\n gap: 2px;\n padding: 3px;\n background: var(--civitai-color-surface-2);\n border: 1px solid var(--civitai-color-border);\n border-radius: var(--civitai-radius);\n vertical-align: middle;\n}\n[data-civitai-ui='segmented-control'][data-full-width='true'] {\n display: flex;\n width: 100%;\n}\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment] {\n flex: 0 0 auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: calc(var(--civitai-radius) - 3px);\n background: transparent;\n color: var(--civitai-color-text-dimmed);\n font-family: var(--civitai-font);\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n cursor: pointer;\n user-select: none;\n transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;\n}\n[data-civitai-ui='segmented-control'][data-full-width='true'] [data-civitai-ui-segment] {\n flex: 1 1 0;\n}\n[data-civitai-ui='segmented-control'][data-size='sm'] [data-civitai-ui-segment] { height: 24px; padding: 0 12px; font-size: 13px; }\n[data-civitai-ui='segmented-control'][data-size='md'] [data-civitai-ui-segment] { height: 30px; padding: 0 16px; font-size: 14px; }\n[data-civitai-ui='segmented-control'][data-size='lg'] [data-civitai-ui-segment] { height: 38px; padding: 0 20px; font-size: 16px; }\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment]:hover:not(:disabled):not([data-active]) {\n color: var(--civitai-color-text);\n background: color-mix(in srgb, var(--civitai-color-text) 6%, transparent);\n}\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment][data-active] {\n background: var(--civitai-color-surface);\n color: var(--civitai-color-primary);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);\n}\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment]:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 1px;\n}\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment]:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n}\n[data-civitai-ui='segmented-control'][data-disabled='true'] {\n opacity: 0.7;\n}\n";
|
|
8
|
+
export declare const BLOCKS_UI_STYLES = "/* AUTOGENERATED by @civitai/theme (scripts/build-tokens.ts). DO NOT EDIT.\n Regenerate: pnpm --filter @civitai/theme build. Guarded by generation-parity test. */\n@property --civitai-color-text {\n syntax: '<color>';\n inherits: true;\n initial-value: #222;\n}\n@property --civitai-color-text-dimmed {\n syntax: '<color>';\n inherits: true;\n initial-value: #868e96;\n}\n@property --civitai-color-body {\n syntax: '<color>';\n inherits: true;\n initial-value: #fefefe;\n}\n@property --civitai-color-surface {\n syntax: '<color>';\n inherits: true;\n initial-value: #fefefe;\n}\n@property --civitai-color-surface-2 {\n syntax: '<color>';\n inherits: true;\n initial-value: #fefefe;\n}\n@property --civitai-color-border {\n syntax: '<color>';\n inherits: true;\n initial-value: #ced4da;\n}\n@property --civitai-color-primary {\n syntax: '<color>';\n inherits: true;\n initial-value: #228BE6;\n}\n@property --civitai-color-primary-hover {\n syntax: '<color>';\n inherits: true;\n initial-value: #1C7ED6;\n}\n@property --civitai-color-primary-fg {\n syntax: '<color>';\n inherits: true;\n initial-value: #fefefe;\n}\n@property --civitai-color-primary-light {\n syntax: '<color>';\n inherits: true;\n initial-value: rgba(34, 139, 230, 0.1);\n}\n@property --civitai-color-error {\n syntax: '<color>';\n inherits: true;\n initial-value: #fa5252;\n}\n@property --civitai-color-success {\n syntax: '<color>';\n inherits: true;\n initial-value: #299C7A;\n}\n@property --civitai-color-warning {\n syntax: '<color>';\n inherits: true;\n initial-value: #fd7e14;\n}\n@property --civitai-color-info {\n syntax: '<color>';\n inherits: true;\n initial-value: #228BE6;\n}\n@property --civitai-color-gray-0 {\n syntax: '<color>';\n inherits: true;\n initial-value: #f8f9fa;\n}\n@property --civitai-color-gray-1 {\n syntax: '<color>';\n inherits: true;\n initial-value: #f1f3f5;\n}\n@property --civitai-color-gray-2 {\n syntax: '<color>';\n inherits: true;\n initial-value: #e9ecef;\n}\n@property --civitai-color-gray-3 {\n syntax: '<color>';\n inherits: true;\n initial-value: #dee2e6;\n}\n@property --civitai-color-gray-4 {\n syntax: '<color>';\n inherits: true;\n initial-value: #ced4da;\n}\n@property --civitai-color-gray-5 {\n syntax: '<color>';\n inherits: true;\n initial-value: #adb5bd;\n}\n@property --civitai-color-gray-6 {\n syntax: '<color>';\n inherits: true;\n initial-value: #868e96;\n}\n@property --civitai-color-gray-7 {\n syntax: '<color>';\n inherits: true;\n initial-value: #495057;\n}\n@property --civitai-color-gray-8 {\n syntax: '<color>';\n inherits: true;\n initial-value: #343a40;\n}\n@property --civitai-color-gray-9 {\n syntax: '<color>';\n inherits: true;\n initial-value: #212529;\n}\n@property --civitai-color-track {\n syntax: '<color>';\n inherits: true;\n initial-value: #e9ecef;\n}\n@property --civitai-color-segmented-bg {\n syntax: '<color>';\n inherits: true;\n initial-value: #f1f3f5;\n}\n@property --civitai-color-media-placeholder {\n syntax: '<color>';\n inherits: true;\n initial-value: #e9ecef;\n}\n\n:root {\n color-scheme: light;\n --civitai-font: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;\n --civitai-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;\n --civitai-radius: 0.25rem;\n --civitai-color-text: #222;\n --civitai-color-text-dimmed: #868e96;\n --civitai-color-body: #fefefe;\n --civitai-color-surface: #fefefe;\n --civitai-color-surface-2: #fefefe;\n --civitai-color-border: #ced4da;\n --civitai-color-primary: #228BE6;\n --civitai-color-primary-hover: #1C7ED6;\n --civitai-color-primary-fg: #fefefe;\n --civitai-color-primary-light: rgba(34, 139, 230, 0.1);\n --civitai-color-error: #fa5252;\n --civitai-color-success: #299C7A;\n --civitai-color-warning: #fd7e14;\n --civitai-color-info: #228BE6;\n --civitai-color-gray-0: #f8f9fa;\n --civitai-color-gray-1: #f1f3f5;\n --civitai-color-gray-2: #e9ecef;\n --civitai-color-gray-3: #dee2e6;\n --civitai-color-gray-4: #ced4da;\n --civitai-color-gray-5: #adb5bd;\n --civitai-color-gray-6: #868e96;\n --civitai-color-gray-7: #495057;\n --civitai-color-gray-8: #343a40;\n --civitai-color-gray-9: #212529;\n --civitai-bp-xs: 480px;\n --civitai-bp-sm: 768px;\n --civitai-bp-md: 1024px;\n --civitai-bp-lg: 1184px;\n --civitai-bp-xl: 1440px;\n --civitai-card-border-width: 1px;\n --civitai-color-track: #e9ecef;\n --civitai-color-segmented-bg: #f1f3f5;\n --civitai-color-media-placeholder: #e9ecef;\n}\n\n[data-theme='light'] {\n color-scheme: light;\n --civitai-font: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;\n --civitai-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;\n --civitai-radius: 0.25rem;\n --civitai-color-text: #222;\n --civitai-color-text-dimmed: #868e96;\n --civitai-color-body: #fefefe;\n --civitai-color-surface: #fefefe;\n --civitai-color-surface-2: #fefefe;\n --civitai-color-border: #ced4da;\n --civitai-color-primary: #228BE6;\n --civitai-color-primary-hover: #1C7ED6;\n --civitai-color-primary-fg: #fefefe;\n --civitai-color-primary-light: rgba(34, 139, 230, 0.1);\n --civitai-color-error: #fa5252;\n --civitai-color-success: #299C7A;\n --civitai-color-warning: #fd7e14;\n --civitai-color-info: #228BE6;\n --civitai-color-gray-0: #f8f9fa;\n --civitai-color-gray-1: #f1f3f5;\n --civitai-color-gray-2: #e9ecef;\n --civitai-color-gray-3: #dee2e6;\n --civitai-color-gray-4: #ced4da;\n --civitai-color-gray-5: #adb5bd;\n --civitai-color-gray-6: #868e96;\n --civitai-color-gray-7: #495057;\n --civitai-color-gray-8: #343a40;\n --civitai-color-gray-9: #212529;\n --civitai-bp-xs: 480px;\n --civitai-bp-sm: 768px;\n --civitai-bp-md: 1024px;\n --civitai-bp-lg: 1184px;\n --civitai-bp-xl: 1440px;\n --civitai-card-border-width: 1px;\n --civitai-color-track: #e9ecef;\n --civitai-color-segmented-bg: #f1f3f5;\n --civitai-color-media-placeholder: #e9ecef;\n}\n\n[data-theme='dark'] {\n color-scheme: dark;\n --civitai-color-text: #C1C2C5;\n --civitai-color-text-dimmed: #8c8fa3;\n --civitai-color-body: #1A1B1E;\n --civitai-color-surface: #25262B;\n --civitai-color-surface-2: #25262B;\n --civitai-color-border: #373A40;\n --civitai-color-primary: #1971C2;\n --civitai-color-primary-hover: #1864AB;\n --civitai-color-primary-fg: #fefefe;\n --civitai-color-primary-light: rgba(34, 139, 230, 0.15);\n --civitai-color-error: #e03131;\n --civitai-color-success: #326D5C;\n --civitai-color-warning: #e8590c;\n --civitai-color-info: #1971C2;\n --civitai-card-border-width: 0;\n --civitai-color-track: #25262B;\n --civitai-color-segmented-bg: #25262B;\n --civitai-color-media-placeholder: #25262B;\n}\n\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme]) {\n color-scheme: dark;\n --civitai-color-text: #C1C2C5;\n --civitai-color-text-dimmed: #8c8fa3;\n --civitai-color-body: #1A1B1E;\n --civitai-color-surface: #25262B;\n --civitai-color-surface-2: #25262B;\n --civitai-color-border: #373A40;\n --civitai-color-primary: #1971C2;\n --civitai-color-primary-hover: #1864AB;\n --civitai-color-primary-fg: #fefefe;\n --civitai-color-primary-light: rgba(34, 139, 230, 0.15);\n --civitai-color-error: #e03131;\n --civitai-color-success: #326D5C;\n --civitai-color-warning: #e8590c;\n --civitai-color-info: #1971C2;\n --civitai-card-border-width: 0;\n --civitai-color-track: #25262B;\n --civitai-color-segmented-bg: #25262B;\n --civitai-color-media-placeholder: #25262B;\n }\n}\n\n/*\n * @civitai/components \u2014 attribute-driven, framework-agnostic component CSS.\n *\n * Contract: style is selected by `data-civitai-ui=\"<name>\"` + `data-variant` +\n * `data-size` (+ a few component-specific `data-*`). Theme via an ancestor\n * `[data-theme='light'|'dark']`. Tokens come from @civitai/theme's `--civitai-*`\n * custom properties (link `@civitai/theme/styles.css` alongside this file, or\n * call `injectStyles()` which injects both).\n *\n * ALL rules live in `@layer civitai.components` so a consumer's own unlayered\n * CSS always wins the cascade WITHOUT specificity fights. State colors are\n * derived with `color-mix()` from base tokens (no shade enumeration), and\n * structure uses native CSS nesting (no preprocessor).\n *\n * See MARKUP.md for the full per-component markup + ARIA contract.\n */\n@layer civitai.components {\n [data-civitai-ui] {\n box-sizing: border-box;\n font-family: var(--civitai-font);\n\n & *,\n & *::before,\n & *::after {\n box-sizing: border-box;\n }\n }\n\n /* ----- Button ----- */\n [data-civitai-ui='button'] {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n border: 1px solid transparent;\n border-radius: var(--civitai-radius);\n font-family: var(--civitai-font);\n font-weight: 600;\n line-height: 1;\n cursor: pointer;\n user-select: none;\n text-decoration: none;\n transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;\n\n /*\n * Documented DEFAULTS (MARKUP.md): variant=filled, size=md. Applied on the\n * BASE rule (unconditionally) so BARE markup \u2014 a `<button data-civitai-ui=\n * \"button\">` with no `data-variant`/`data-size` \u2014 renders the documented\n * default (filled + md), not an unstyled/zero-size button. The explicit\n * `[data-variant]` / `[data-size]` rules below OVERRIDE (all four variants\n * set their own bg/border/hover, so the base filled default only ever wins\n * for a bare button). Every non-filled variant also sets its own\n * `border-color`, so the base primary border never leaks to them.\n */\n background: var(--civitai-color-primary);\n color: var(--civitai-color-primary-fg);\n border-color: var(--civitai-color-primary);\n height: 36px;\n padding: 0 18px;\n font-size: 14px;\n\n &:hover:not(:disabled) {\n background: var(--civitai-color-primary-hover);\n border-color: var(--civitai-color-primary-hover);\n }\n\n &[data-size='sm'] { height: 30px; padding: 0 14px; font-size: 13px; }\n &[data-size='md'] { height: 36px; padding: 0 18px; font-size: 14px; }\n &[data-size='lg'] { height: 44px; padding: 0 22px; font-size: 16px; }\n &[data-full-width='true'] { width: 100%; }\n\n &[data-variant='filled'] {\n background: var(--civitai-color-primary);\n color: var(--civitai-color-primary-fg);\n border-color: var(--civitai-color-primary);\n\n &:hover:not(:disabled) {\n background: var(--civitai-color-primary-hover);\n border-color: var(--civitai-color-primary-hover);\n }\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-primary) 12%, transparent);\n color: var(--civitai-color-primary);\n border-color: transparent;\n\n &:hover:not(:disabled) {\n background: color-mix(in srgb, var(--civitai-color-primary) 22%, transparent);\n }\n }\n &[data-variant='outline'] {\n background: transparent;\n color: var(--civitai-color-primary);\n border-color: var(--civitai-color-primary);\n\n &:hover:not(:disabled) {\n background: color-mix(in srgb, var(--civitai-color-primary) 10%, transparent);\n }\n }\n &[data-variant='subtle'] {\n background: transparent;\n color: var(--civitai-color-primary);\n border-color: transparent;\n\n &:hover:not(:disabled) {\n background: color-mix(in srgb, var(--civitai-color-primary) 10%, transparent);\n }\n }\n &:disabled,\n &[aria-busy='true'] {\n opacity: 0.6;\n cursor: not-allowed;\n }\n & [data-civitai-ui-section] {\n display: inline-flex;\n align-items: center;\n }\n }\n\n /* ----- TextInput / Textarea / NumberInput / Select ----- */\n [data-civitai-ui='text-input'],\n [data-civitai-ui='textarea'],\n [data-civitai-ui='number-input'],\n [data-civitai-ui='select'] {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n [data-civitai-ui-label] {\n font-size: 14px;\n font-weight: 600;\n color: var(--civitai-color-text);\n }\n [data-civitai-ui-required] {\n color: var(--civitai-color-error);\n margin-left: 2px;\n }\n [data-civitai-ui-description] {\n font-size: 12px;\n color: var(--civitai-color-text-dimmed);\n }\n [data-civitai-ui-error] {\n font-size: 12px;\n color: var(--civitai-color-error);\n }\n [data-civitai-ui-control] {\n width: 100%;\n font-family: var(--civitai-font);\n font-size: 14px;\n color: var(--civitai-color-text);\n background: var(--civitai-color-surface);\n border: 1px solid var(--civitai-color-border);\n border-radius: var(--civitai-radius);\n padding: 8px 12px;\n transition: border-color 120ms ease;\n\n &:focus {\n outline: none;\n border-color: var(--civitai-color-primary);\n }\n &[aria-invalid='true'] {\n border-color: var(--civitai-color-error);\n }\n &:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n }\n textarea[data-civitai-ui-control] {\n resize: vertical;\n line-height: 1.5;\n }\n /*\n * Select (issue #181 F6): a native <select> reusing the shared\n * `-control` field chrome (border/background/radius/focus/invalid, above).\n * Keep the native disclosure caret (most robust + accessible); just make the\n * control feel interactive and reserve room on the right so the caret never\n * overlaps a long option label.\n */\n select[data-civitai-ui-control] {\n cursor: pointer;\n padding-right: 28px;\n line-height: 1.4;\n }\n select[data-civitai-ui-control]:disabled {\n cursor: not-allowed;\n }\n\n /* ----- Checkbox / Radio (issue #181 F6) ----- */\n /*\n * Themed native <input type=\"checkbox\">/<input type=\"radio\">: `accent-color`\n * carries the primary tint (native control, so keyboard/indeterminate/tab all\n * work for free), plus custom sizing, a token focus ring, and disabled state.\n * The box + its inline label sit in a `-choice` row; description + error live\n * below, reusing the shared field markers. The inputs deliberately do NOT\n * carry `data-civitai-ui-control` (that is the full-width field-input chrome).\n */\n [data-civitai-ui='checkbox'],\n [data-civitai-ui='radio'] {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n [data-civitai-ui-choice] {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n [data-civitai-ui='checkbox'] [data-civitai-ui-label],\n [data-civitai-ui='radio'] [data-civitai-ui-label] {\n font-weight: 500;\n cursor: pointer;\n user-select: none;\n }\n [data-civitai-ui='checkbox'] input[type='checkbox'],\n [data-civitai-ui='radio'] input[type='radio'] {\n accent-color: var(--civitai-color-primary);\n width: 16px;\n height: 16px;\n margin: 0;\n flex: none;\n cursor: pointer;\n }\n [data-civitai-ui='checkbox'] input[type='checkbox']:focus-visible,\n [data-civitai-ui='radio'] input[type='radio']:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 2px;\n }\n [data-civitai-ui='checkbox'] input[type='checkbox']:disabled,\n [data-civitai-ui='radio'] input[type='radio']:disabled {\n cursor: not-allowed;\n opacity: 0.6;\n }\n [data-civitai-ui='checkbox'] input[type='checkbox']:disabled ~ [data-civitai-ui-label],\n [data-civitai-ui='radio'] input[type='radio']:disabled ~ [data-civitai-ui-label] {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n /* ----- RadioGroup (issue #181 F6) \u2014 role=radiogroup layout ----- */\n [data-civitai-ui='radio-group'] {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n [data-civitai-ui-radio-options] {\n display: flex;\n flex-direction: column;\n gap: 8px;\n\n &[data-orientation='horizontal'] {\n flex-direction: row;\n flex-wrap: wrap;\n gap: 16px;\n }\n }\n /*\n * RadioGroup group-level error (0.2.0): mirrors the field `-error` treatment.\n * The message reuses the shared `[data-civitai-ui-error]` styling (12px, error\n * token); the invalid state (`data-invalid` on the group, set alongside\n * `aria-invalid`) tints the child radios' `accent-color` to the error token so\n * the invalid cue reads on the group, not just the message text \u2014 the group\n * analogue of a field control's error-colored border.\n */\n [data-civitai-ui='radio-group'] [data-civitai-ui-error] {\n font-size: 12px;\n color: var(--civitai-color-error);\n }\n [data-civitai-ui='radio-group'][data-invalid] input[type='radio'] {\n accent-color: var(--civitai-color-error);\n }\n\n /* ----- Card ----- */\n /*\n * F5 (issue #181): light resolves surface and body to the same #fefefe, so a\n * borderless card needs a default hairline to be visible at all; dark already\n * separates them and zeroes the width. `data-with-border` is the stronger,\n * explicit, opaque border and is unconditional.\n */\n [data-civitai-ui='card'] {\n background: var(--civitai-color-surface);\n border-radius: var(--civitai-radius);\n color: var(--civitai-color-text);\n border: var(--civitai-card-border-width, 1px) solid\n color-mix(in srgb, var(--civitai-color-border) 55%, transparent);\n\n &[data-with-border='true'] {\n border: 1px solid var(--civitai-color-border);\n }\n &[data-padding='sm'] { padding: 10px; }\n &[data-padding='md'] { padding: 16px; }\n &[data-padding='lg'] { padding: 24px; }\n }\n\n /* ----- Stack / Group ----- */\n [data-civitai-ui='stack'] {\n display: flex;\n flex-direction: column;\n gap: 12px;\n\n &[data-gap='sm'] { gap: 8px; }\n &[data-gap='md'] { gap: 16px; }\n &[data-gap='lg'] { gap: 24px; }\n }\n [data-civitai-ui='group'] {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 8px;\n\n /*\n * RESPONSIVE BASE. A group is a ROW, and a row of three or more controls\n * has no escape at phone widths \u2014 a block's slot is ~360px there, so the\n * row simply ran off the edge.\n *\n * \uD83D\uDD34 There are THREE `group` surfaces, and for TWO of them this is a NEW\n * DEFAULT \u2014 do not read it as the CSS merely catching up:\n * - `@civitai/blocks-react`'s `<Group>` defaults `wrap = true` and writes\n * `flex-wrap` as an INLINE style, so its consumers already wrapped.\n * For that one, and only that one, this is the CSS agreeing with a\n * default that was already shipping.\n * - `@civitai/components-react`'s `<Group>` writes NO inline style and has\n * NO `wrap` prop, so it resolves against this rule \u2014 a React consumer\n * that did NOT wrap before. \uD83D\uDD34 THIS SURFACE NO LONGER EXISTS: see below.\n * - bare `data-civitai-ui=\"group\"` markup, the framework-agnostic contract\n * this package exists to serve \u2014 likewise.\n * Nothing could see the disagreement, because each surface was only ever\n * tested against itself. TWO tests now pin it \u2014 there is no single \"the\n * parity test\":\n * - `civitai-blocks-react/test/Group.test.tsx` reads the wrap default off\n * ITS `<Group>` and compares it to this rule, so flipping either alone\n * fails;\n * - `civitai-components/test/presentational-parity.browser.test.ts`,\n * case **`group`** \u2014 compares `<civitai-group>` against the bare\n * `data-civitai-ui=\"group\"` markup with `flexWrap` among the properties\n * it reads. \uD83D\uDD34 It is `group`, NOT `group/nowrap`: that case sets\n * `nowrap` on BOTH arms, so both resolve to `flex-wrap: nowrap` and it\n * stays green through a deletion of the rule below. Mutate the rule and\n * check the wrong case and you will conclude it is unguarded.\n *\n * \uD83D\uDD34 THE MIDDLE SURFACE IS GONE, and so is the test that used to pin it.\n * `@civitai/components-react@0.9.0` deleted its hand-written `<Group>` when\n * the custom elements superseded the React layer, taking\n * `html-vs-react-parity.browser.test.tsx` (\"styling anchors \u2014 Group\") with\n * it. TWO surfaces now resolve against this rule, not three: bare markup,\n * and blocks-react's `<Group>`. `<civitai-group>` is a third surface that\n * does NOT read this sheet at all \u2014 shadow DOM, carrying its own\n * `flex-wrap` at `src/elements/civitai-group.ts:20`.\n *\n * That independence is why the replacement is not the weak kind of parity\n * check this comment used to warn about. The warning was right about the\n * OLD test: both its arms consumed THIS sheet, so deleting the rule moved\n * them identically and only an absolute anchor could see it.\n * `presentational-parity`'s arms are independent IMPLEMENTATIONS, so\n * deleting `flex-wrap` here moves the legacy arm alone and the comparison\n * fails. WATCHED, not inferred: deleting `flex-wrap: wrap` from this block\n * in `styles.generated.ts` (the string the browser test injects) fails\n * `light / group` and `dark / group` with\n * `flexWrap: expected 'wrap' to be 'nowrap'` \u2014 2 failed / 108 passed;\n * restoring it returns 110/110. (Re-measured after #477 added 21 Text\n * parity cases; the kill is what matters, the totals move with the file.)\n *\n * \uD83D\uDD34 The three-surface distinction is load-bearing for the RELEASE, not just\n * the prose: \"existing callers notice\" is what `RELEASING.md` reserves\n * `major` for, and it is true for two of the three surfaces. Shipped as a\n * MINOR by maintainer decision \u2014 see the changeset for the reasoning and for\n * the upgrade note that decision makes load-bearing.\n *\n * Opt out with `data-nowrap=\"true\"`. An inline `flex-wrap` also still\n * outranks this layer, so existing markup that already sets it is untouched.\n */\n flex-wrap: wrap;\n\n /*\n * A flex item defaults to `min-width: auto`, which refuses to shrink below\n * its CONTENT width \u2014 so one long label (a model name, a prompt) pushes the\n * row past the slot even WITH wrap. `min-width: 0` lets it shrink.\n *\n * \uD83D\uDD34 SCOPE, measured: this only matters for a child whose computed\n * `overflow` is `visible`. Per CSS Flexbox \u00A74.5 an item with any other\n * `overflow` ALREADY has an automatic minimum size of 0 \u2014 so a child that\n * sets `overflow: hidden`, as any `text-overflow: ellipsis` child must, is\n * unaffected by this line. An earlier version of this comment claimed\n * ellipsis \"can never engage\" without it, which was exactly backwards; the\n * browser guard built on that belief used an ellipsis fixture and therefore\n * passed with this rule DELETED. Both are fixed \u2014 see\n * `civitai-blocks-react/test/responsive-group.browser.test.tsx`.\n *\n * Specificity is (0,1,0), not zero: `&` carries the parent selector's, and\n * `:where(*)` is specificity-identical to `*`, so that wrapper bought\n * nothing and has been dropped. What actually lets a CONSUMER's own rule\n * win is `@layer civitai.components`, which every rule in this file sits\n * inside \u2014 unlayered author CSS beats it regardless of specificity. Note\n * the limit: another rule INSIDE this layer does not get that protection,\n * so a future non-zero `min-width` on a nested component here must be more\n * specific or come later.\n */\n & > * { min-width: 0; }\n\n &[data-nowrap='true'] { flex-wrap: nowrap; }\n\n &[data-gap='sm'] { gap: 6px; }\n &[data-gap='md'] { gap: 16px; }\n &[data-gap='lg'] { gap: 24px; }\n }\n\n /* ----- Alert ----- */\n [data-civitai-ui='alert'] {\n display: flex;\n gap: 10px;\n align-items: flex-start;\n padding: 12px 14px;\n border-radius: var(--civitai-radius);\n border: 1px solid transparent;\n font-size: 14px;\n color: var(--civitai-color-text);\n\n /*\n * Documented DEFAULT (MARKUP.md): color=info (default intent). Applied on\n * the base rule so a BARE alert \u2014 `<div data-civitai-ui=\"alert\">` with no\n * `data-color` \u2014 renders the info intent (tinted bg + border), not just the\n * neutral chrome. The explicit `[data-color]` rules below OVERRIDE (success\n * / warning / error each set both bg + border-color), so the info default\n * only ever wins when `data-color` is omitted.\n */\n background: color-mix(in srgb, var(--civitai-color-info) 12%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-info) 35%, transparent);\n\n &[data-color='info'] {\n background: color-mix(in srgb, var(--civitai-color-info) 12%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-info) 35%, transparent);\n }\n &[data-color='success'] {\n background: color-mix(in srgb, var(--civitai-color-success) 12%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-success) 35%, transparent);\n }\n &[data-color='warning'] {\n background: color-mix(in srgb, var(--civitai-color-warning) 14%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-warning) 35%, transparent);\n }\n &[data-color='error'] {\n background: color-mix(in srgb, var(--civitai-color-error) 12%, transparent);\n border-color: color-mix(in srgb, var(--civitai-color-error) 35%, transparent);\n }\n & [data-civitai-ui-alert-body] {\n flex: 1;\n min-width: 0;\n }\n & [data-civitai-ui-alert-title] {\n font-weight: 600;\n margin-bottom: 2px;\n }\n & [data-civitai-ui-alert-close] {\n background: transparent;\n border: none;\n cursor: pointer;\n color: inherit;\n font-size: 16px;\n line-height: 1;\n padding: 0;\n opacity: 0.7;\n\n &:hover { opacity: 1; }\n }\n }\n\n /* ----- Loader ----- */\n [data-civitai-ui='loader'] {\n display: inline-block;\n border-radius: 50%;\n border-style: solid;\n border-color: color-mix(in srgb, currentColor 25%, transparent);\n border-top-color: currentColor;\n color: var(--civitai-color-primary);\n animation: civitai-ui-spin 0.7s linear infinite;\n\n /*\n * Documented DEFAULT (MARKUP.md): size=md. Applied on the base rule so a\n * BARE loader \u2014 `<span data-civitai-ui=\"loader\">` with no `data-size` \u2014 has\n * real dimensions (otherwise 0\u00D70 and invisible). sm/lg OVERRIDE below.\n */\n width: 22px;\n height: 22px;\n border-width: 3px;\n\n &[data-size='sm'] { width: 16px; height: 16px; border-width: 2px; }\n &[data-size='md'] { width: 22px; height: 22px; border-width: 3px; }\n &[data-size='lg'] { width: 32px; height: 32px; border-width: 4px; }\n }\n [data-civitai-ui='button'] [data-civitai-ui='loader'] {\n color: currentColor;\n }\n @keyframes civitai-ui-spin {\n to { transform: rotate(360deg); }\n }\n\n /* ----- Badge ----- */\n [data-civitai-ui='badge'] {\n display: inline-flex;\n align-items: center;\n border: 1px solid transparent;\n border-radius: 999px;\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n text-transform: uppercase;\n letter-spacing: 0.02em;\n\n /*\n * Documented DEFAULTS (MARKUP.md): variant=filled, size=md. Applied on the\n * base rule so a BARE badge \u2014 `<span data-civitai-ui=\"badge\">` with no\n * `data-variant`/`data-size` (MARKUP's own minimal example) \u2014 renders\n * filled + md (padding + primary fill), not an unstyled, zero-padding pill.\n * The explicit `[data-variant]`/`[data-size]` rules below OVERRIDE. Because\n * the base now carries a primary `border-color`, the `light` variant (which\n * previously relied on the base transparent border) explicitly resets it to\n * transparent so light badges are visually unchanged.\n */\n height: 22px;\n padding: 0 10px;\n font-size: 11px;\n background: var(--civitai-color-primary);\n color: var(--civitai-color-primary-fg);\n border-color: var(--civitai-color-primary);\n\n &[data-size='sm'] { height: 18px; padding: 0 8px; font-size: 10px; }\n &[data-size='md'] { height: 22px; padding: 0 10px; font-size: 11px; }\n &[data-size='lg'] { height: 26px; padding: 0 12px; font-size: 13px; }\n &[data-variant='filled'] {\n background: var(--civitai-color-primary);\n color: var(--civitai-color-primary-fg);\n border-color: var(--civitai-color-primary);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-primary) 14%, transparent);\n color: var(--civitai-color-primary);\n border-color: transparent;\n }\n &[data-variant='outline'] {\n background: transparent;\n color: var(--civitai-color-primary);\n border-color: var(--civitai-color-primary);\n }\n\n /*\n * Intent color via `data-color`, mirroring Alert's `data-color` contract\n * (info / success / warning / error). Absent `data-color` => the default\n * primary above (non-breaking). Each intent recolors the `filled`, `light`\n * and `outline` variants with the same `color-mix()` token approach Alert\n * uses; `filled` keeps the white `--civitai-color-primary-fg` text. These\n * `[data-color][data-variant]` rules out-specify the plain-variant rules\n * above, so order-independence holds.\n */\n &[data-color='info'] {\n &[data-variant='filled'] {\n background: var(--civitai-color-info);\n border-color: var(--civitai-color-info);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-info) 14%, transparent);\n color: var(--civitai-color-info);\n }\n &[data-variant='outline'] {\n color: var(--civitai-color-info);\n border-color: var(--civitai-color-info);\n }\n }\n &[data-color='success'] {\n &[data-variant='filled'] {\n background: var(--civitai-color-success);\n border-color: var(--civitai-color-success);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-success) 14%, transparent);\n color: var(--civitai-color-success);\n }\n &[data-variant='outline'] {\n color: var(--civitai-color-success);\n border-color: var(--civitai-color-success);\n }\n }\n &[data-color='warning'] {\n &[data-variant='filled'] {\n background: var(--civitai-color-warning);\n border-color: var(--civitai-color-warning);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-warning) 14%, transparent);\n color: var(--civitai-color-warning);\n }\n &[data-variant='outline'] {\n color: var(--civitai-color-warning);\n border-color: var(--civitai-color-warning);\n }\n }\n &[data-color='error'] {\n &[data-variant='filled'] {\n background: var(--civitai-color-error);\n border-color: var(--civitai-color-error);\n }\n &[data-variant='light'] {\n background: color-mix(in srgb, var(--civitai-color-error) 14%, transparent);\n color: var(--civitai-color-error);\n }\n &[data-variant='outline'] {\n color: var(--civitai-color-error);\n border-color: var(--civitai-color-error);\n }\n }\n }\n\n /* ----- Slider ----- */\n /*\n * A themed native <input type=\"range\">: `accent-color` carries the primary\n * tint (native control, so keyboard arrow-key nav + ARIA come for free), plus\n * a full-width track, a token focus ring, and disabled/invalid states. Like\n * checkbox/radio, the range input deliberately does NOT carry\n * `data-civitai-ui-control` (that is the bordered field-input chrome). The\n * label/description/error reuse the shared field markers, laid out in a\n * column; an optional value read-out sits inline with the label in a header.\n */\n [data-civitai-ui='slider'] {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n [data-civitai-ui-slider-header] {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n }\n [data-civitai-ui-slider-value] {\n font-size: 13px;\n font-weight: 600;\n color: var(--civitai-color-text-dimmed);\n font-variant-numeric: tabular-nums;\n }\n [data-civitai-ui='slider'] input[type='range'] {\n -webkit-appearance: none;\n appearance: none;\n width: 100%;\n height: 6px;\n margin: 6px 0;\n padding: 0;\n border-radius: 999px;\n accent-color: var(--civitai-color-primary);\n background: var(--civitai-color-track, var(--civitai-color-gray-2));\n cursor: pointer;\n }\n [data-civitai-ui='slider'] input[type='range']:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 4px;\n }\n [data-civitai-ui='slider'] input[type='range']:disabled {\n cursor: not-allowed;\n opacity: 0.6;\n accent-color: var(--civitai-color-gray-5);\n }\n [data-civitai-ui='slider'][data-invalid] input[type='range'] {\n accent-color: var(--civitai-color-error);\n }\n\n /* ----- SegmentedControl / Tabs ----- */\n /*\n * A `role=tablist` of segment buttons (`role=tab`). Presentational chrome\n * only \u2014 the roving-tabindex + arrow-key nav + selection follow-focus live in\n * the React binding (or must be author-provided for hand HTML). The selected\n * segment (`aria-selected='true'`) lifts to the surface color with a soft\n * shadow; unselected segments read dimmed. Paired tab panels use\n * `data-civitai-ui-tabpanel` (role=tabpanel).\n */\n [data-civitai-ui='segmented-control'] {\n display: inline-flex;\n gap: 2px;\n padding: 4px;\n background: var(--civitai-color-segmented-bg, var(--civitai-color-gray-1));\n border-radius: var(--civitai-radius);\n max-width: 100%;\n overflow-x: auto;\n }\n [data-civitai-ui-segment] {\n -webkit-appearance: none;\n appearance: none;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: 0;\n background: transparent;\n color: var(--civitai-color-text-dimmed);\n font-family: var(--civitai-font);\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n cursor: pointer;\n border-radius: calc(var(--civitai-radius) - 1px);\n transition: background-color 120ms ease, color 120ms ease;\n\n /* Documented DEFAULT (MARKUP.md): size=md. On the base rule so a bare\n segment renders at md height, not zero-height. sm/lg override below. */\n height: 30px;\n padding: 0 14px;\n font-size: 13px;\n\n &[data-size='sm'] { height: 26px; padding: 0 10px; font-size: 12px; }\n &[data-size='md'] { height: 30px; padding: 0 14px; font-size: 13px; }\n &[data-size='lg'] { height: 38px; padding: 0 18px; font-size: 15px; }\n\n &:hover:not(:disabled):not([aria-selected='true']):not([aria-checked='true']) {\n color: var(--civitai-color-text);\n }\n /* Selected state \u2014 `aria-selected` in tabs mode (role=tab) OR `aria-checked`\n in toggle mode (role=radio). Same visual treatment for both. */\n &[aria-selected='true'],\n &[aria-checked='true'] {\n background: var(--civitai-color-surface);\n color: var(--civitai-color-text);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);\n }\n &:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 2px;\n }\n &:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n }\n }\n [data-civitai-ui-tabpanel] {\n color: var(--civitai-color-text);\n\n &:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 2px;\n }\n &[hidden] { display: none; }\n }\n\n /* ----- Toast ----- */\n /*\n * `toast-region` is the fixed-position `aria-live` host (bottom-right stack).\n * The behavior \u2014 enqueue, auto-dismiss timers, portal \u2014 lives in the React\n * binding's ToastProvider/useToast; the presentational `toast` is a standalone\n * component so hand HTML and React share one visual contract. Each toast is\n * opaque (it sits over content) with an intent-colored left accent, mirroring\n * Alert's `data-color` set.\n */\n [data-civitai-ui='toast-region'] {\n position: fixed;\n bottom: 16px;\n right: 16px;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 8px;\n width: min(92vw, 380px);\n pointer-events: none;\n }\n [data-civitai-ui='toast'] {\n pointer-events: auto;\n display: flex;\n gap: 10px;\n align-items: flex-start;\n padding: 12px 14px;\n border-radius: var(--civitai-radius);\n background: var(--civitai-color-surface);\n color: var(--civitai-color-text);\n border: 1px solid var(--civitai-color-border);\n border-left: 4px solid var(--civitai-color-border);\n box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);\n font-size: 14px;\n\n &[data-color='info'] { border-left-color: var(--civitai-color-info); }\n &[data-color='success'] { border-left-color: var(--civitai-color-success); }\n &[data-color='warning'] { border-left-color: var(--civitai-color-warning); }\n &[data-color='error'] { border-left-color: var(--civitai-color-error); }\n\n & [data-civitai-ui-toast-body] {\n flex: 1;\n min-width: 0;\n }\n & [data-civitai-ui-toast-title] {\n font-weight: 600;\n margin-bottom: 2px;\n }\n & [data-civitai-ui-toast-close] {\n background: transparent;\n border: none;\n cursor: pointer;\n color: inherit;\n font-size: 16px;\n line-height: 1;\n padding: 0;\n opacity: 0.7;\n\n &:hover { opacity: 1; }\n }\n }\n\n /* ----- Tooltip ----- */\n /*\n * A hover/focus tooltip: a positioned `role=tooltip` bubble revealed when the\n * wrapper is hovered or contains focus (pure-CSS reveal), or explicitly via\n * `data-open='true'` (the React binding also wires `aria-describedby` +\n * Escape-to-dismiss). The bubble is a dark chip (gray-9) with light text in\n * both themes.\n */\n [data-civitai-ui='tooltip'] {\n position: relative;\n display: inline-flex;\n }\n [data-civitai-ui-tooltip-bubble] {\n position: absolute;\n bottom: calc(100% + 6px);\n left: 50%;\n transform: translateX(-50%);\n z-index: 200;\n max-width: 260px;\n width: max-content;\n padding: 4px 8px;\n border-radius: var(--civitai-radius);\n background: var(--civitai-color-gray-9);\n color: var(--civitai-color-primary-fg);\n font-size: 12px;\n font-weight: 500;\n line-height: 1.4;\n text-align: center;\n pointer-events: none;\n opacity: 0;\n visibility: hidden;\n transition: opacity 120ms ease;\n }\n /*\n * Reveal on hover/focus or an explicit `data-open='true'` \u2014 BUT\n * `data-dismissed='true'` overrides (gates every reveal selector), so the\n * React binding's Escape-to-dismiss can force-HIDE the bubble even while the\n * pointer still hovers or focus is still within. The dismissed flag is cleared\n * on the next hover/focus so the tooltip can re-open normally.\n */\n [data-civitai-ui='tooltip']:hover [data-civitai-ui-tooltip-bubble]:not([data-dismissed='true']),\n [data-civitai-ui='tooltip']:focus-within [data-civitai-ui-tooltip-bubble]:not([data-dismissed='true']),\n [data-civitai-ui-tooltip-bubble][data-open='true']:not([data-dismissed='true']) {\n opacity: 1;\n visibility: visible;\n }\n\n /* ----- Image ----- */\n /*\n * A media container with a token placeholder background (visible while the\n * image loads), object-fit control, and a broken-image fallback. `data-status`\n * (loading|loaded|error) \u2014 set by the React binding's onLoad/onError, or by\n * the author for hand HTML \u2014 fades the <img> in on load and swaps to the\n * fallback overlay on error. Bare markup (no `data-status`) shows the image.\n */\n [data-civitai-ui='image'] {\n position: relative;\n display: block;\n overflow: hidden;\n background: var(--civitai-color-media-placeholder, var(--civitai-color-gray-2));\n border-radius: var(--civitai-radius);\n }\n [data-civitai-ui-image-img] {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n opacity: 1;\n transition: opacity 200ms ease;\n\n &[data-fit='contain'] { object-fit: contain; }\n &[data-fit='cover'] { object-fit: cover; }\n }\n [data-civitai-ui='image'][data-status='loading'] [data-civitai-ui-image-img],\n [data-civitai-ui='image'][data-status='error'] [data-civitai-ui-image-img] {\n opacity: 0;\n }\n [data-civitai-ui-image-fallback] {\n position: absolute;\n inset: 0;\n display: none;\n align-items: center;\n justify-content: center;\n padding: 8px;\n color: var(--civitai-color-text-dimmed);\n font-size: 13px;\n text-align: center;\n }\n [data-civitai-ui='image'][data-status='error'] [data-civitai-ui-image-fallback] {\n display: flex;\n }\n\n /* ----- Text ----- */\n /*\n * The typography primitive. Alone in this sheet it prescribes NO element of\n * its own: the author writes the tag the meaning calls for \u2014 `<h1>`..`<h6>`\n * for a heading, `<p>` for a paragraph, `<span>` for inline \u2014 and this rule\n * sizes and colours whatever that is. Nothing in the visual scale implies a\n * heading level and nothing in a heading level implies a size, which is what\n * lets an `<h2>` be the small caption of a card.\n *\n * `display` is deliberately NOT set, so `<p>`/`<h2>` stay block and `<span>`\n * stays inline: the element's own semantics keep deciding its box.\n *\n * `margin: 0` IS set, and it is a decision rather than a reset for tidiness.\n * The UA gives headings and paragraphs an em-relative margin that moves with\n * every `data-size`, spacing in this pack is owned by `stack`/`group`, and\n * without it `<civitai-text as=\"h2\">` and `<h2 data-civitai-ui=\"text\">` lay\n * out differently \u2014 which is the one thing the two tracks may not do.\n *\n * Documented DEFAULTS (MARKUP.md): size=md, weight=normal, no `data-color`.\n * On the BASE rule so BARE markup renders them, exactly as button and badge\n * do; the explicit `[data-size]`/`[data-weight]` rules below OVERRIDE.\n */\n [data-civitai-ui='text'] {\n margin: 0;\n /*\n * \uD83D\uDD34 `inherit`, NOT `var(--civitai-color-text)` \u2014 see the NO COLOUR AXIS\n * note at the end of this rule. A SPECIFIED value beats an INHERITED one\n * whatever the specificity, so the token here silently cancelled the\n * ancestor route this component's whole colour story rests on.\n */\n color: inherit;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.5;\n\n /*\n * THE TYPE SCALE \u2014 ONE scale, and the reason it runs this far is that the\n * package must not ship two that disagree.\n *\n * The bottom half is the UI ramp: `sm`/`md`/`lg` are byte-identical to\n * Button's own font-size ramp (13/14/16px), so one size name means one size\n * across the pack, and `xs` is the 12px the field description already uses.\n *\n * The top half is the HEADING ramp, and every step of it is a value\n * `utilities.css` already ships as `ci-fs-N` \u2014 so the two are the same\n * scale under two spellings, not two scales:\n *\n * lg 16px = ci-fs-6 2xl 24px = ci-fs-4 4xl 32px = ci-fs-2\n * xl 20px = ci-fs-5 3xl 28px = ci-fs-3 5xl 40px = ci-fs-1\n *\n * What that costs: the t-shirt names do not encode the `ci-fs-N` number,\n * and the two sequences run in OPPOSITE directions (5xl is ci-fs-1), so a\n * reader needs the mapping above. That was the cheaper trade \u2014 naming the\n * new steps `fs-1`..`fs-4` would have put two naming conventions inside one\n * attribute and reversed its direction halfway up. Nothing above `ci-fs-1`\n * is invented here; 40px is the top of both ladders.\n *\n * UNIT: this ramp is px (Button's unit), `ci-fs-*` is rem. They agree at\n * the default 16px root and diverge if a consumer changes it. Deliberate \u2014\n * mixing units within one ramp would make it non-monotonic under a changed\n * root, which is worse than this caveat. Already true of `lg`/`xl` before\n * the ramp was extended.\n *\n * Everything from `xl` up tightens its line-height to 1.25 \u2014 a 20px-plus\n * heading leaded at 1.5 reads as loose. Two leading values in total.\n */\n &[data-size='xs'] { font-size: 12px; }\n &[data-size='sm'] { font-size: 13px; }\n &[data-size='md'] { font-size: 14px; }\n &[data-size='lg'] { font-size: 16px; }\n &[data-size='xl'] { font-size: 20px; line-height: 1.25; }\n &[data-size='2xl'] { font-size: 24px; line-height: 1.25; }\n &[data-size='3xl'] { font-size: 28px; line-height: 1.25; }\n &[data-size='4xl'] { font-size: 32px; line-height: 1.25; }\n &[data-size='5xl'] { font-size: 40px; line-height: 1.25; }\n\n /* `normal` / `semibold` / `bold` are the weights `utilities.css` already\n spells (`ci-normal` 400, `ci-semibold` 600, `ci-bold` 700); `medium` is\n the 500 this sheet already uses for a radio-group and a toast title. */\n &[data-weight='normal'] { font-weight: 400; }\n &[data-weight='medium'] { font-weight: 500; }\n &[data-weight='semibold'] { font-weight: 600; }\n &[data-weight='bold'] { font-weight: 700; }\n\n /*\n * NO COLOUR AXIS, and that is the same predicate this component already\n * applies to alignment and truncation rather than a separate judgement.\n * Every value one would carry already exists as a utility that reaches this\n * element: `ci-muted` (the dimmed token), `ci-text-info` / `-success` /\n * `-warning` / `-error` (the intent enum), `ci-text-default` (the body\n * colour). `color` INHERITS, so a utility on this element or any ancestor\n * reaches the inner element of `<civitai-text>` through its shadow boundary\n * too \u2014 the inner element is `color: inherit` \u2014 which is exactly why a\n * `data-color` here would be a second copy of a predicate that already has\n * an implementation one layer down.\n *\n * \uD83D\uDD34 WHICH IS WHY THE BASE RULE SAYS `color: inherit` AND NOT THE TOKEN.\n * The ancestor half of that sentence was measurably FALSE while this rule\n * specified `var(--civitai-color-text)` \u2014 a *specified* value beats an\n * *inherited* one however far up the utility sits, so `ci-muted` on a wrapper\n * dimmed a plain `<p>` and left `<p data-civitai-ui=\"text\">` undimmed.\n * `ci-text-center` on that same wrapper always worked, because nothing here\n * re-specifies `text-align`. `<civitai-text>`'s `:host` had the identical\n * defect; both tracks changed together, as they must.\n *\n * THE TRADE, and it is NOT limited to a page that paints nothing: Text no\n * longer paints the token itself, so it takes whatever colour it inherits.\n * That decides what it renders on every page that DOES set a colour \u2014 the\n * larger population, and the one every in-repo consumer would be in once it\n * renders Text, which none does today (four starters colour `body` via\n * Tailwind; seven more colour a `[data-theme]` root).\n * Measured on both tracks, on the shape a block here actually has \u2014 a\n * `[data-theme='dark']` root carrying `color: #e6e6e6` \u2014 Text computes\n * `rgb(230, 230, 230)`, where restoring the removed declaration puts both\n * tracks back at the dark token `rgb(193, 194, 197)`. With no colour anywhere\n * it lands on the UA default `rgb(0, 0, 0)`. `ci-text-default` asks for the\n * token back, but it ships in a separate stylesheet that neither\n * `injectStyles()` nor `injectBlocksStyles()` injects \u2014 see the cost section\n * in `src/elements/civitai-text.ts` for every measurement and\n * `packages/civitai-components/MARKUP.md` for what a consumer has to load.\n * Both tracks are pinned in `test/civitai-text.browser.test.ts`.\n *\n * It is also the direction that stays open: this ships `minor` on a\n * published package, so adding the axis later is additive and taking it\n * away would not be.\n */\n }\n}\n\n\n/* ----- Select / Slider field wrappers -----\n The shared -control/-label/-required/-description/-error primitives come from\n @civitai/components (layered). Only the select/slider WRAPPERS live here \u2014 the\n design-system package scopes its field-wrapper rule to the presentational\n inputs (text-input/textarea/number-input). */\n[data-civitai-ui='select'],\n[data-civitai-ui='slider'] {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n/* ----- Modal ----- */\n[data-civitai-ui='modal-overlay'] {\n position: fixed;\n inset: 0;\n background: rgba(0, 0, 0, 0.55);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 32px 16px;\n overflow-y: auto;\n z-index: 1000;\n}\n[data-civitai-ui='modal'] {\n background: var(--civitai-color-surface);\n color: var(--civitai-color-text);\n border: 1px solid var(--civitai-color-border);\n border-radius: var(--civitai-radius);\n box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);\n width: 100%;\n max-width: 440px;\n outline: none;\n}\n[data-civitai-ui='modal'][data-size='sm'] { max-width: 340px; }\n[data-civitai-ui='modal'][data-size='md'] { max-width: 440px; }\n[data-civitai-ui='modal'][data-size='lg'] { max-width: 620px; }\n[data-civitai-ui='modal'] [data-civitai-ui-modal-header] {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 16px 18px;\n border-bottom: 1px solid var(--civitai-color-border);\n}\n[data-civitai-ui='modal'] [data-civitai-ui-modal-title] {\n font-size: 16px;\n font-weight: 700;\n margin: 0;\n}\n[data-civitai-ui='modal'] [data-civitai-ui-modal-close] {\n background: transparent;\n border: none;\n cursor: pointer;\n color: var(--civitai-color-text-dimmed);\n font-size: 20px;\n line-height: 1;\n padding: 0;\n}\n[data-civitai-ui='modal'] [data-civitai-ui-modal-close]:hover {\n color: var(--civitai-color-text);\n}\n[data-civitai-ui='modal'] [data-civitai-ui-modal-body] {\n padding: 18px;\n}\n\n/* ----- Slider ----- */\n[data-civitai-ui='slider'] [data-civitai-ui-label] {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 8px;\n}\n[data-civitai-ui-slider-value] {\n font-weight: 500;\n color: var(--civitai-color-text-dimmed);\n font-variant-numeric: tabular-nums;\n}\n[data-civitai-ui-range] {\n width: 100%;\n margin: 0;\n accent-color: var(--civitai-color-primary);\n cursor: pointer;\n}\n[data-civitai-ui-range]:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n[data-civitai-ui-range]:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 2px;\n}\n\n/* ----- Collapse ----- */\n[data-civitai-ui='collapse'] [data-civitai-ui-collapse-trigger] {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n padding: 6px 0;\n background: transparent;\n border: none;\n font-family: var(--civitai-font);\n font-size: 14px;\n font-weight: 600;\n color: var(--civitai-color-text);\n text-align: left;\n cursor: pointer;\n}\n[data-civitai-ui='collapse'] [data-civitai-ui-collapse-trigger]:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n[data-civitai-ui='collapse'] [data-civitai-ui-collapse-chevron] {\n display: inline-block;\n width: 1em;\n color: var(--civitai-color-text-dimmed);\n}\n[data-civitai-ui='collapse'] [data-civitai-ui-collapse-region] {\n padding-top: 4px;\n}\n\n/* ----- ResourceCard -----\n Two variants off ONE box. data-variant='card' stacks (grid tile),\n data-variant='row' runs inline (compact list line). Every selector is\n double-qualified with [data-civitai-ui='resource-card'] on purpose:\n data-variant is also emitted by Button and Badge, so a bare\n [data-variant='card'] would reach across the whole pack.\n NOTE: this block lives inside a JS TEMPLATE LITERAL. No backticks, and no\n dollar-brace, or the string ends here and the file stops parsing as\n TypeScript (backticks in this comment did exactly that once). */\n[data-civitai-ui='resource-card'] {\n display: flex;\n box-sizing: border-box;\n /* The positioned ancestor for the overlay slot. It lives on the ROOT, not on\n the thumbnail frame, so the overlay can be a SIBLING of the hit <button>\n rather than a descendant of it \u2014 see the overlay prop's doc for why that\n distinction is the whole point of the slot. */\n position: relative;\n font-family: var(--civitai-font);\n color: var(--civitai-color-text);\n background: var(--civitai-color-surface);\n border: 1px solid var(--civitai-color-border);\n border-radius: var(--civitai-radius);\n overflow: hidden;\n}\n[data-civitai-ui='resource-card'][data-variant='card'] {\n flex-direction: column;\n align-items: stretch;\n}\n[data-civitai-ui='resource-card'][data-variant='row'] {\n flex-direction: row;\n align-items: center;\n gap: 8px;\n padding: 6px 8px;\n}\n[data-civitai-ui='resource-card'][data-selected='true'] {\n border-color: var(--civitai-color-primary);\n}\n[data-civitai-ui='resource-card'][data-disabled='true'] {\n opacity: 0.6;\n}\n/* The hit area. A <button> when interactive, a <div> when not \u2014 both are reset\n to the same box so the two variants lay out identically either way. */\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-hit] {\n display: flex;\n /* \uD83D\uDD34 A flex ITEM defaults to min-width:auto, which refuses to shrink below its\n content \u2014 so without this the name's nowrap+ellipsis never engages and a\n long model name blows the card out of its grid cell instead of truncating. */\n min-width: 0;\n box-sizing: border-box;\n margin: 0;\n border: none;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n}\n[data-civitai-ui='resource-card'][data-variant='card'] [data-civitai-ui-resource-hit] {\n flex-direction: column;\n align-items: stretch;\n gap: 6px;\n padding: 6px;\n}\n[data-civitai-ui='resource-card'][data-variant='row'] [data-civitai-ui-resource-hit] {\n flex: 1 1 auto;\n flex-direction: row;\n align-items: center;\n gap: 8px;\n padding: 0;\n}\n[data-civitai-ui='resource-card'] button[data-civitai-ui-resource-hit] {\n cursor: pointer;\n}\n[data-civitai-ui='resource-card'] button[data-civitai-ui-resource-hit]:disabled {\n cursor: not-allowed;\n}\n[data-civitai-ui='resource-card'] button[data-civitai-ui-resource-hit]:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: -2px;\n}\n/* The thumbnail frame. Rendered whether or not there is an image \u2014 see the\n component's thumbnailUrl doc: BlockResourceInfo has no image field, so \"no\n image\" is the COMMON case and the frame must keep its box regardless. */\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-thumb] {\n display: flex;\n align-items: center;\n justify-content: center;\n flex: none;\n overflow: hidden;\n background: var(--civitai-color-surface-2);\n border-radius: calc(var(--civitai-radius) - 1px);\n color: var(--civitai-color-text-dimmed);\n}\n[data-civitai-ui='resource-card'][data-variant='card'] [data-civitai-ui-resource-thumb] {\n /* \uD83D\uDD34 aspect-ratio, not a fixed height: the tile is grid-sized by the caller,\n and this is the line that stops a thumbnail-less card collapsing to a\n text-height sliver. */\n width: 100%;\n aspect-ratio: 1 / 1;\n font-size: 11px;\n}\n[data-civitai-ui='resource-card'][data-variant='row'] [data-civitai-ui-resource-thumb] {\n width: 36px;\n height: 36px;\n font-size: 9px;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-thumb] img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n display: block;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-placeholder] {\n padding: 0 4px;\n text-align: center;\n line-height: 1.2;\n}\n/* Positioned from the ROOT, over the thumbnail corner. 10px = the hit area's\n 6px padding plus a 4px inset inside the frame; the three move together, and\n the browser tier asserts the result lands inside the frame's own rect rather\n than trusting the arithmetic.\n pointer-events: none is load-bearing, not polish: this slot is STATUS, and a\n pill that can swallow a click meant for the card is the same class of bug as\n nesting a control inside the hit <button>. A consumer who really wants an\n interactive badge opts back in with pointer-events: auto on their own node. */\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-overlay] {\n position: absolute;\n top: 10px;\n right: 10px;\n z-index: 1;\n pointer-events: none;\n display: flex;\n align-items: center;\n gap: 4px;\n max-width: calc(100% - 20px);\n font-size: 11px;\n line-height: 1;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-nameline] {\n display: flex;\n align-items: center;\n gap: 4px;\n min-width: 0;\n}\n/* The non-colour half of the selected affordance (WCAG 1.4.1). The border-hue\n change on the root is reinforcement; this glyph is what carries it. */\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-selected] {\n flex: none;\n font-size: 12px;\n font-weight: 700;\n line-height: 1;\n color: var(--civitai-color-primary);\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-text] {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n flex: 1 1 auto;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-name] {\n display: block;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-meta] {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 4px 6px;\n min-width: 0;\n font-size: 11px;\n color: var(--civitai-color-text-dimmed);\n}\n[data-civitai-ui='resource-card'] [data-civitai-ui-resource-actions] {\n display: flex;\n align-items: center;\n flex: none;\n gap: 6px;\n}\n[data-civitai-ui='resource-card'][data-variant='card'] [data-civitai-ui-resource-actions] {\n padding: 0 6px 6px;\n}\n\n/* ----- SegmentedControl ----- */\n[data-civitai-ui='segmented-control'] {\n display: inline-flex;\n flex-direction: row;\n gap: 2px;\n padding: 3px;\n background: var(--civitai-color-surface-2);\n border: 1px solid var(--civitai-color-border);\n border-radius: var(--civitai-radius);\n vertical-align: middle;\n}\n[data-civitai-ui='segmented-control'][data-full-width='true'] {\n display: flex;\n width: 100%;\n}\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment] {\n flex: 0 0 auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: calc(var(--civitai-radius) - 3px);\n background: transparent;\n color: var(--civitai-color-text-dimmed);\n font-family: var(--civitai-font);\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n cursor: pointer;\n user-select: none;\n transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;\n}\n[data-civitai-ui='segmented-control'][data-full-width='true'] [data-civitai-ui-segment] {\n flex: 1 1 0;\n}\n[data-civitai-ui='segmented-control'][data-size='sm'] [data-civitai-ui-segment] { height: 24px; padding: 0 12px; font-size: 13px; }\n[data-civitai-ui='segmented-control'][data-size='md'] [data-civitai-ui-segment] { height: 30px; padding: 0 16px; font-size: 14px; }\n[data-civitai-ui='segmented-control'][data-size='lg'] [data-civitai-ui-segment] { height: 38px; padding: 0 20px; font-size: 16px; }\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment]:hover:not(:disabled):not([data-active]) {\n color: var(--civitai-color-text);\n background: color-mix(in srgb, var(--civitai-color-text) 6%, transparent);\n}\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment][data-active] {\n background: var(--civitai-color-surface);\n color: var(--civitai-color-primary);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);\n}\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment]:focus-visible {\n outline: 2px solid var(--civitai-color-primary);\n outline-offset: 1px;\n}\n[data-civitai-ui='segmented-control'] [data-civitai-ui-segment]:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n}\n[data-civitai-ui='segmented-control'][data-disabled='true'] {\n opacity: 0.7;\n}\n";
|
|
9
9
|
/**
|
|
10
10
|
* Inject the pack's stylesheets into a document's `<head>`, idempotently.
|
|
11
11
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civitai/blocks-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"node": ">=20"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@civitai/
|
|
41
|
-
"@civitai/
|
|
40
|
+
"@civitai/theme": "^0.4.0",
|
|
41
|
+
"@civitai/components": "^0.8.1"
|
|
42
42
|
},
|
|
43
43
|
"comment-peerDependencies": "This floor is DERIVED, not chosen — the lowest published app-sdk that exports every symbol this package imports. Do not edit it without reading ./PEER_FLOOR.md (in-repo, unpublished) and tests/guards/blocks-react-peer-floor.test.mjs.",
|
|
44
44
|
"peerDependencies": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"typescript": "^5.9.2",
|
|
59
59
|
"vite": "^8.0.14",
|
|
60
60
|
"vitest": "^4.1.11",
|
|
61
|
-
"@civitai/app-sdk": "^0.
|
|
61
|
+
"@civitai/app-sdk": "^0.52.0"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public"
|