@nebulr-group/bridge-svelte 0.9.0-beta.3 → 0.9.0-beta.5

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.
@@ -284,7 +284,7 @@
284
284
 
285
285
  .bbn-cta:hover {
286
286
  background: currentColor;
287
- color: white;
287
+ color: var(--bridge-bg, #ffffff);
288
288
  }
289
289
 
290
290
  /* Chassis variants */
@@ -300,28 +300,29 @@
300
300
 
301
301
  .bbn-chassis-card {
302
302
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
303
- border: 1px solid rgba(0, 0, 0, 0.08);
303
+ border: 1px solid var(--bridge-border, #d1d5db);
304
304
  }
305
305
 
306
306
  /* Severity tokens */
307
307
  .bbn-severity-info {
308
- background: #dbeafe;
309
- color: #1e40af;
308
+ background: var(--bridge-alert-info-bg, #eff6ff);
309
+ color: var(--bridge-alert-info-fg, #1e40af);
310
310
  }
311
311
 
312
312
  .bbn-severity-warn {
313
- background: #fef3c7;
314
- color: #92400e;
313
+ background: var(--bridge-alert-warning-bg, #fffbeb);
314
+ color: var(--bridge-alert-warning-fg, #92400e);
315
315
  }
316
316
 
317
317
  .bbn-severity-critical {
318
- background: #fee2e2;
319
- color: #991b1b;
318
+ background: var(--bridge-alert-error-bg, #fef2f2);
319
+ color: var(--bridge-alert-error-fg, #991b1b);
320
320
  }
321
321
 
322
+ /* Locked inverts the surface: foreground as background, and vice versa. */
322
323
  .bbn-severity-locked {
323
- background: #1f2937;
324
- color: #f9fafb;
324
+ background: var(--bridge-foreground, #111827);
325
+ color: var(--bridge-bg, #ffffff);
325
326
  }
326
327
 
327
328
  @media (prefers-reduced-motion: reduce) {
@@ -339,7 +340,7 @@
339
340
  align-items: center;
340
341
  justify-content: center;
341
342
  padding: 1rem;
342
- background: rgba(15, 23, 42, 0.72);
343
+ background: var(--bridge-paywall-bg, rgba(15, 23, 42, 0.72));
343
344
  backdrop-filter: blur(2px);
344
345
  }
345
346
 
@@ -351,8 +352,8 @@
351
352
  width: 100%;
352
353
  padding: 1.5rem;
353
354
  border-radius: 0.75rem;
354
- background: #1f2937;
355
- color: #f9fafb;
355
+ background: var(--bridge-foreground, #111827);
356
+ color: var(--bridge-bg, #ffffff);
356
357
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
357
358
  text-align: center;
358
359
  }
@@ -381,7 +382,7 @@
381
382
  }
382
383
 
383
384
  .bbl-cta:hover {
384
- background: #f9fafb;
385
- color: #1f2937;
385
+ background: var(--bridge-bg, #ffffff);
386
+ color: var(--bridge-foreground, #111827);
386
387
  }
387
388
  </style>
@@ -81,7 +81,7 @@
81
81
  overflow-y: auto;
82
82
  padding: 2rem;
83
83
  border-radius: 0.75rem;
84
- background: var(--bridge-paywall-panel-bg, #ffffff);
84
+ background: var(--bridge-paywall-panel-bg, var(--bridge-bg, #ffffff));
85
85
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
86
86
  }
87
87
 
@@ -283,7 +283,7 @@
283
283
  margin-top: 0.25rem;
284
284
  width: 100%;
285
285
  height: 0.375rem;
286
- background: rgba(0, 0, 0, 0.08);
286
+ background: color-mix(in srgb, currentColor 15%, transparent);
287
287
  border-radius: 999px;
288
288
  overflow: hidden;
289
289
  }
@@ -309,7 +309,7 @@
309
309
 
310
310
  .bqb-cta:hover {
311
311
  background: currentColor;
312
- color: white;
312
+ color: var(--bridge-bg, #ffffff);
313
313
  }
314
314
 
315
315
  /* Chassis — rail only for US-11. */
@@ -320,13 +320,13 @@
320
320
 
321
321
  /* Severity tokens — mirror BridgeBillingNotice for visual consistency. */
322
322
  .bqb-severity-warn {
323
- background: #fef3c7;
324
- color: #92400e;
323
+ background: var(--bridge-alert-warning-bg, #fffbeb);
324
+ color: var(--bridge-alert-warning-fg, #92400e);
325
325
  }
326
326
 
327
327
  .bqb-severity-critical {
328
- background: #fee2e2;
329
- color: #991b1b;
328
+ background: var(--bridge-alert-error-bg, #fef2f2);
329
+ color: var(--bridge-alert-error-fg, #991b1b);
330
330
  }
331
331
 
332
332
  @media (prefers-reduced-motion: reduce) {
@@ -79,38 +79,38 @@
79
79
  font-size: 0.75rem;
80
80
  font-weight: 500;
81
81
  text-transform: lowercase;
82
- background: #e5e7eb;
83
- color: #374151;
82
+ background: var(--bridge-muted-bg, var(--bridge-bg-muted, #f3f4f6));
83
+ color: var(--bridge-foreground, #111827);
84
84
  }
85
85
 
86
86
  .bss-badge-active {
87
- background: #d1fae5;
88
- color: #065f46;
87
+ background: var(--bridge-alert-success-bg, #f0fdf4);
88
+ color: var(--bridge-alert-success-fg, #166534);
89
89
  }
90
90
 
91
91
  .bss-badge-trial {
92
- background: #dbeafe;
93
- color: #1e40af;
92
+ background: var(--bridge-alert-info-bg, #eff6ff);
93
+ color: var(--bridge-alert-info-fg, #1e40af);
94
94
  }
95
95
 
96
96
  .bss-badge-past_due,
97
97
  .bss-badge-cancel_at_period_end {
98
- background: #fef3c7;
99
- color: #92400e;
98
+ background: var(--bridge-alert-warning-bg, #fffbeb);
99
+ color: var(--bridge-alert-warning-fg, #92400e);
100
100
  }
101
101
 
102
102
  .bss-badge-canceled {
103
- background: #fee2e2;
104
- color: #991b1b;
103
+ background: var(--bridge-alert-error-bg, #fef2f2);
104
+ color: var(--bridge-alert-error-fg, #991b1b);
105
105
  }
106
106
 
107
107
  .bss-loading,
108
108
  .bss-empty {
109
- color: #6b7280;
109
+ color: var(--bridge-muted, #6b7280);
110
110
  font-style: italic;
111
111
  }
112
112
 
113
113
  .bss-error {
114
- color: #b91c1c;
114
+ color: var(--bridge-alert-error-fg, #991b1b);
115
115
  }
116
116
  </style>
@@ -176,8 +176,8 @@ export function startBridgeRuntime(options = {}) {
176
176
  // of parking. A signed-out session has nothing to refresh. Loop safety:
177
177
  // the refreshed token lands in the tokenStore subscription below, whose
178
178
  // reauthorize() is a no-op while that episode is still connecting, and
179
- // the reconnect it produces is flagged self-induced so setOnOpen does not
180
- // refresh a second time.
179
+ // the reconcile after the reconnect (TBP-700) mints once more and stops
180
+ // there — a token that changes nothing does not replace the socket.
181
181
  refreshAuthToken: options.realtime?.refreshAuthToken ??
182
182
  (async () => {
183
183
  if (!_currentAuthToken)
@@ -191,11 +191,10 @@ export function startBridgeRuntime(options = {}) {
191
191
  }
192
192
  }),
193
193
  });
194
- let _connectedOnce = false;
195
- // Set just before we call _realtime.reauthorize() so the resulting
196
- // reconnect's setOnOpen handler knows the token is already fresh and skips
197
- // its proactive refresh — see the loop note in setOnOpen below.
198
- let _reauthInFlight = false;
194
+ // TBP-700 — bookkeeping for reconcileUserState() below.
195
+ let _reconciling = 0;
196
+ let _reconcileSawChange = false;
197
+ let _reconcileSwapsInARow = 0;
199
198
  // TBP-660 — catch up after ANY reconnect, including the one our own
200
199
  // reauthorize() causes. AppSync Events has no replay, so a push published
201
200
  // while the socket is being replaced is gone for good — and a plan change
@@ -240,11 +239,12 @@ export function startBridgeRuntime(options = {}) {
240
239
  return;
241
240
  const data = (await res.json());
242
241
  // Stopped, or the session changed while this was in flight: the answer
243
- // describes a session we no longer have. A token change reauthorizes,
244
- // and that reconnect catches up again with the right token.
245
- if (_realtime !== rt || _currentAuthToken !== token)
242
+ // describes a session we no longer have. A new token for the SAME user,
243
+ // workspace and app is not a new session — the reconcile below rotates
244
+ // the token on every open, concurrently with this read (TBP-700).
245
+ if (_realtime !== rt || !sameSession(token, _currentAuthToken))
246
246
  return;
247
- const { planChanged, entitlementsChanged } = applyCatchUpSnapshot(data);
247
+ const { planChanged, entitlementsChanged, initial } = applyCatchUpSnapshot(data);
248
248
  if (entitlementsChanged && data?.tenant?.entitlements) {
249
249
  // Keep auth-core's copy (what flag targeting reads) in step too.
250
250
  try {
@@ -254,6 +254,14 @@ export function startBridgeRuntime(options = {}) {
254
254
  }
255
255
  // TBP-654 — a recovered change must reach the route guard exactly like
256
256
  // the push it replaces would have. Nothing changed → nothing to redo.
257
+ //
258
+ // TBP-700 — and filling empty stores for the first time recovers no
259
+ // change: nothing was decided against a plan this page never knew. It
260
+ // used to count as a plan change, which refreshed the token and swapped
261
+ // the socket right after every first connect — the window a role change
262
+ // was lost in on stage. The access token is reconciled below instead.
263
+ if (initial)
264
+ return;
257
265
  if (planChanged)
258
266
  authorizationChanged('subscription.plan_changed');
259
267
  else if (entitlementsChanged)
@@ -335,7 +343,7 @@ export function startBridgeRuntime(options = {}) {
335
343
  const data = (await res.json());
336
344
  // Stopped, or the session changed while this was in flight: the
337
345
  // answer describes a workspace we no longer have.
338
- if (_realtime !== rt || _currentAuthToken !== token)
346
+ if (_realtime !== rt || !sameSession(token, _currentAuthToken))
339
347
  return;
340
348
  if (quotas.getAll().get(metric) !== before)
341
349
  return; // a push won the race
@@ -348,6 +356,57 @@ export function startBridgeRuntime(options = {}) {
348
356
  }
349
357
  }));
350
358
  };
359
+ // TBP-700 — the same repair for the user's own state: role, privileges,
360
+ // anything that bumps the server's tokenVersion and publishes
361
+ // `user.state_changed`. That push is lost exactly like the others when it
362
+ // is published while the socket is being replaced, and before this nothing
363
+ // recovered it: the session snapshot's `user` is read from the token being
364
+ // presented, and a reconnect our own reauthorize() caused skipped the token
365
+ // refresh (the TBP-644 loop guard). On stage a role change landed in that
366
+ // window in 4 of 4 runs once the first connect started swapping its socket
367
+ // (TBP-686), and the user kept the old role until a reload.
368
+ //
369
+ // The server's current tokenVersion and claims are only available by
370
+ // minting: the refresh endpoint reads them from the database, uncached. So
371
+ // every connect and reconnect ends with ONE refresh, and the token that
372
+ // comes back is compared with the one we had:
373
+ // - same authority (only iat/exp/jti moved): nothing was missed, and the
374
+ // socket that was just subscribed stays — no swap, so no loop;
375
+ // - anything else (tv, role, plan…): that IS the missed change. It is
376
+ // handled like the push would have been — the route guard re-checks and
377
+ // the socket is re-authorized with the new token — and the reconnect
378
+ // reconciles again, which then finds nothing new.
379
+ //
380
+ // `fresh: true` (auth-core, TBP-700) keeps this from joining a
381
+ // refresh that started before the channels were live, whose token could
382
+ // predate the very change we are looking for. An older auth-core ignores
383
+ // the option and joins it — best effort, as before.
384
+ const reconcileUserState = async () => {
385
+ if (_realtime !== rt || !_currentAuthToken)
386
+ return; // signed out: no user state
387
+ let auth;
388
+ try {
389
+ auth = getBridgeAuth();
390
+ }
391
+ catch {
392
+ return; // BridgeAuth not constructed — nothing to refresh with.
393
+ }
394
+ _reconciling += 1;
395
+ _reconcileSawChange = false;
396
+ try {
397
+ const refresh = auth.refreshTokens;
398
+ await refresh.call(auth, { fresh: true });
399
+ }
400
+ catch {
401
+ // Best-effort: the TOKEN_VERSION_STALE retry in bridgeAuthFetch and the
402
+ // next reconnect are the fallbacks.
403
+ }
404
+ finally {
405
+ _reconciling -= 1;
406
+ if (!_reconcileSawChange)
407
+ _reconcileSwapsInARow = 0;
408
+ }
409
+ };
351
410
  const requestCatchUp = () => {
352
411
  if (!_currentAuthToken)
353
412
  return; // signed out: no tenant or user scope to fetch
@@ -356,9 +415,9 @@ export function startBridgeRuntime(options = {}) {
356
415
  return;
357
416
  }
358
417
  _catchUpInFlight = true;
359
- // Both repairs ride the one in-flight/queued gate, so an open storm still
360
- // costs one round of requests, and neither can starve the other.
361
- void Promise.all([catchUpSessionSnapshot(), catchUpQuotaSnapshots()]).catch(() => {
418
+ // Every repair rides the one in-flight/queued gate, so an open storm still
419
+ // costs one round of requests, and none can starve the others.
420
+ void Promise.all([catchUpSessionSnapshot(), catchUpQuotaSnapshots(), reconcileUserState()]).catch(() => {
362
421
  // Both halves are already best-effort internally; this is the backstop
363
422
  // that keeps a repair failure from surfacing as an unhandled rejection
364
423
  // in the consuming app. `.finally` below still drains the queue.
@@ -370,41 +429,29 @@ export function startBridgeRuntime(options = {}) {
370
429
  }
371
430
  });
372
431
  };
432
+ // TBP-700 — when to catch up. Everything above only works if it reads the
433
+ // server AFTER every channel is live: anything published before that is
434
+ // already in the database and the read sees it, anything after arrives on
435
+ // the socket. `open` fires on the FIRST accepted subscription, with the user
436
+ // channel possibly still pending, so an auth-core that can say "all
437
+ // subscribed" is asked; an older one falls back to `open`.
438
+ //
439
+ // One trigger for the initial connect, a genuine reconnect and the one our
440
+ // own reauthorize() causes alike: losing pushes is a property of the socket
441
+ // swap, whoever caused it (TBP-660), and the first connect is where the
442
+ // snapshot push always loses its race (TBP-686). `requestCatchUp` returns
443
+ // early when signed out and coalesces concurrent calls.
444
+ const realtimeWithSubscribed = _realtime;
445
+ const catchUpOnSubscribed = typeof realtimeWithSubscribed.setOnSubscribed === 'function';
446
+ if (catchUpOnSubscribed)
447
+ realtimeWithSubscribed.setOnSubscribed(() => requestCatchUp());
373
448
  _realtime.setOnOpen(() => {
374
449
  _setRealtimeStatus('open');
375
- // On reconnect (not initial connect), proactively refresh tokens.
376
- // If the WS was down when tokenVersion was bumped on the server, the
377
- // client missed the user.state_changed broadcast. Refreshing here
378
- // syncs tokens before the first post-reconnect request can fail with
379
- // TOKEN_VERSION_STALE.
380
- //
381
- // EXCEPT when this reconnect was caused by our OWN reauthorize() below
382
- // (a token-only refresh). In that case the token is already current, so
383
- // refreshing again would mint yet another JWT, which the tokenStore
384
- // subscription would see as a change and reauthorize() again → reconnect
385
- // → setOnOpen → refresh → … an unbounded loop that hammers /auth/token
386
- // (observed ~32 cycles/sec, jamming the page's main thread and stalling
387
- // every downstream wait). Only genuine, externally-triggered reconnects
388
- // (network blips, server restarts) should trigger the catch-up refresh.
389
- const causedByReauthorize = _reauthInFlight;
390
- _reauthInFlight = false;
391
- if (_connectedOnce && !causedByReauthorize) {
392
- getBridgeAuth().refreshTokens().catch(() => { });
393
- }
394
- // TBP-660 — the TOKEN refresh above is skipped for a self-induced
395
- // reconnect; the STATE catch-up is not. Losing pushes is a property of
396
- // the socket swap, whoever caused it, and the catch-up cannot loop.
397
- //
398
- // TBP-686 — and it runs on the FIRST connect too. It used to be gated on
399
- // `_connectedOnce`, which read as "only repair a reconnect" — but the
400
- // first connect is exactly where the snapshot goes missing: the server
401
- // publishes it fire-and-forget during authorize, before the subscription
402
- // is live, so it loses the race and there is nothing to replay. The result
403
- // was a signed-in page with a null workspace id, name and branding for the
404
- // whole session. `requestCatchUp` already returns early when signed out
405
- // and coalesces concurrent calls, so this costs one request per session.
406
- requestCatchUp();
407
- _connectedOnce = true;
450
+ // No token refresh here any more (TBP-700): the reconcile in
451
+ // requestCatchUp mints on EVERY open, the self-induced ones included, and
452
+ // cannot loop — see reconcileUserState.
453
+ if (!catchUpOnSubscribed)
454
+ requestCatchUp();
408
455
  for (const fn of _onOpenSubs) {
409
456
  try {
410
457
  fn();
@@ -437,10 +484,6 @@ export function startBridgeRuntime(options = {}) {
437
484
  // open/close/degraded mirrors above keep `realtimeStatus` working there).
438
485
  _realtime.setOnStatusChange?.((status) => {
439
486
  _setRealtimeStatusDetail(status);
440
- // A parked client never opens, so a reauthorize that ended in a refusal
441
- // must not leave the self-induced flag set for the next genuine reconnect.
442
- if (status.state === 'unauthorized')
443
- _reauthInFlight = false;
444
487
  for (const fn of _onStatusSubs) {
445
488
  try {
446
489
  fn(status);
@@ -560,10 +603,7 @@ export function startBridgeRuntime(options = {}) {
560
603
  // (none → A) and sign-out (A → none). Keying this on rotation only meant a
561
604
  // session that signed in after page load kept the anonymous connection —
562
605
  // or stayed parked after a refusal — until something else reconnected it.
563
- // Flagged self-induced so setOnOpen skips its catch-up refresh (see the
564
- // loop note there): the token we reconnect with is already current.
565
606
  const reauthorizeForTokenChange = () => {
566
- _reauthInFlight = true;
567
607
  void _realtime.reauthorize();
568
608
  };
569
609
  // `subscribe` emits the current value synchronously, before `start()` below.
@@ -572,7 +612,30 @@ export function startBridgeRuntime(options = {}) {
572
612
  _unsubscribeAuth = tokenStore.subscribe((tokens) => {
573
613
  const prevAuthToken = _currentAuthToken;
574
614
  _currentAuthToken = tokens?.accessToken ?? undefined;
575
- const tokenChanged = _tokenSubscriptionLive && prevAuthToken !== _currentAuthToken;
615
+ let tokenChanged = _tokenSubscriptionLive && prevAuthToken !== _currentAuthToken;
616
+ let reauthorize = tokenChanged;
617
+ // TBP-700 — the token minted by the post-(re)connect reconcile.
618
+ if (tokenChanged && _reconciling > 0) {
619
+ if (_realtime.getState?.() === 'open' && sameAuthorization(prevAuthToken, _currentAuthToken)) {
620
+ // Only its timing moved: nothing was missed, every verdict taken with
621
+ // the old token stands, and the socket that was just subscribed has
622
+ // exactly this authority. Replacing it would open the very window
623
+ // this repair exists for — and loop.
624
+ tokenChanged = false;
625
+ reauthorize = false;
626
+ }
627
+ else {
628
+ // The server moved on while we were not listening: the lost
629
+ // `user.state_changed`, recovered. Re-authorize like the push would
630
+ // have — bounded, so a claim that differs on every mint cannot turn
631
+ // this into a reconnect loop.
632
+ _reconcileSawChange = true;
633
+ if (_reconcileSwapsInARow >= MAX_RECONCILE_SWAPS)
634
+ reauthorize = false;
635
+ else
636
+ _reconcileSwapsInARow += 1;
637
+ }
638
+ }
576
639
  // TBP-654 + TBP-653 — a new token (sign-in, refresh after a plan change,
577
640
  // sign-out) invalidates every verdict taken with the old one, and the
578
641
  // current route is re-evaluated: a signed-out session on a protected page
@@ -616,7 +679,7 @@ export function startBridgeRuntime(options = {}) {
616
679
  // setUserId is a no-op when the user is unchanged (token-only refresh),
617
680
  // and a setter-driven reconnect waits out a backoff and cannot lift a
618
681
  // parked refusal — so reauthorize explicitly on every value change.
619
- if (tokenChanged)
682
+ if (reauthorize)
620
683
  reauthorizeForTokenChange();
621
684
  });
622
685
  _tokenSubscriptionLive = true;
@@ -739,6 +802,48 @@ export function __resetBridgeRuntime() {
739
802
  _realtime = undefined;
740
803
  }
741
804
  // ── helpers ─────────────────────────────────────────────────────────────────
805
+ /**
806
+ * TBP-700 — how many reconcile-driven socket replacements may follow one
807
+ * another before the reconcile stops replacing the socket (it still adopts the
808
+ * token). A genuine missed change costs one; a second is a change published
809
+ * during that swap. More than a few in a row means a claim differs on every
810
+ * mint, and replacing the socket forever would be the TBP-644 loop again.
811
+ */
812
+ const MAX_RECONCILE_SWAPS = 3;
813
+ /** Claims that differ on every mint without meaning anything changed. */
814
+ const PER_MINT_CLAIMS = new Set(['iat', 'exp', 'nbf', 'jti', 'auth_time']);
815
+ /** Same user, workspace and app — the scope a catch-up read belongs to. */
816
+ function sameSession(a, b) {
817
+ if (!a || !b)
818
+ return false;
819
+ if (a === b)
820
+ return true;
821
+ const ca = decodeJwtPayload(a);
822
+ const cb = decodeJwtPayload(b);
823
+ if (!ca || !cb)
824
+ return false;
825
+ return ca.sub === cb.sub && ca.tid === cb.tid && ca.aid === cb.aid;
826
+ }
827
+ /**
828
+ * TBP-700 — two tokens carry the same authority: every claim is equal except
829
+ * the per-mint ones. Unreadable tokens are never the same.
830
+ */
831
+ function sameAuthorization(a, b) {
832
+ if (!a || !b)
833
+ return false;
834
+ const ca = decodeJwtPayload(a);
835
+ const cb = decodeJwtPayload(b);
836
+ if (!ca || !cb)
837
+ return false;
838
+ const keys = new Set([...Object.keys(ca), ...Object.keys(cb)]);
839
+ for (const k of keys) {
840
+ if (PER_MINT_CLAIMS.has(k))
841
+ continue;
842
+ if (JSON.stringify(ca[k]) !== JSON.stringify(cb[k]))
843
+ return false;
844
+ }
845
+ return true;
846
+ }
742
847
  /** Decode a JWT payload without signature verification (client context only). */
743
848
  function decodeJwtPayload(token) {
744
849
  try {
@@ -104,6 +104,13 @@ export declare function applyEntitlementsChanged(msg: {
104
104
  export declare function applyCatchUpSnapshot(data: SessionSnapshotData): {
105
105
  planChanged: boolean;
106
106
  entitlementsChanged: boolean;
107
+ /**
108
+ * TBP-700 — the tenant stores were empty: this filled them for the first
109
+ * time rather than changing anything the page had already seen. The
110
+ * `*Changed` flags still say whether the values moved, so a caller can seed
111
+ * other copies; a first fill is not a recovered change.
112
+ */
113
+ initial: boolean;
107
114
  };
108
115
  /** Test-only: reset every snapshot store to `null`. Vitest hook. */
109
116
  export declare function __resetSnapshotStores(): void;
@@ -104,6 +104,7 @@ export function applyCatchUpSnapshot(data) {
104
104
  const subAfter = get(_tenantSubscription);
105
105
  const entAfter = get(_tenantEntitlements);
106
106
  return {
107
+ initial: subBefore === null && entBefore === null,
107
108
  planChanged: (subBefore?.plan?.slug ?? null) !== (subAfter?.plan?.slug ?? null) ||
108
109
  (subBefore?.status ?? null) !== (subAfter?.status ?? null),
109
110
  entitlementsChanged: !sameFlags(entBefore, entAfter),
package/dist/styles.css CHANGED
@@ -1,12 +1,54 @@
1
1
  /*
2
- * bridge-svelte default styles — plain baseline
2
+ * bridge-svelte default styles
3
3
  *
4
- * Import this file to get the default appearance for all bridge-svelte components:
5
4
  * import '@nebulr-group/bridge-svelte/styles';
6
5
  *
7
- * Without this file, components render as plain unstyled HTML — the headless contract.
8
- * This file provides structural CSS only. Visual design (colors, shadows, radii) is
9
- * intentionally left to the consumer or a future design-system layer.
6
+ * Without this file, components render as plain unstyled HTML — the headless
7
+ * contract. With it, they get their structure plus a small default look driven
8
+ * entirely by the `--bridge-*` tokens below.
9
+ *
10
+ * ── THE TOKEN CONTRACT ────────────────────────────────────────────────────
11
+ *
12
+ * The `--bridge-*` custom properties are the supported way to restyle every
13
+ * Bridge page and component. Set them in your own stylesheet:
14
+ *
15
+ * :root { --bridge-primary: #0f766e; --bridge-border-radius: 10px; }
16
+ *
17
+ * The defaults are declared on `:where(:root)`, which has zero specificity, so
18
+ * YOUR `:root` wins whatever order the two stylesheets load in. Set a token on
19
+ * a wrapper instead of `:root` to theme one area.
20
+ *
21
+ * Colour
22
+ * --bridge-primary primary buttons, selected tab, active workspace
23
+ * --bridge-primary-hover primary button hover (derived from primary)
24
+ * --bridge-primary-fg text on primary surfaces
25
+ * --bridge-primary-light tint behind the active workspace (derived)
26
+ * --bridge-input-focus input focus ring (defaults to primary)
27
+ * --bridge-bg dialogs, menus, panels
28
+ * --bridge-foreground text on those surfaces, emphasised names
29
+ * --bridge-muted secondary text: hints, table headings
30
+ * --bridge-muted-bg subtle fills: tab tracks, hovers, code boxes
31
+ * --bridge-border input, table, card and secondary button borders
32
+ * --bridge-border-radius corners of inputs, buttons, alerts, cards, dialogs
33
+ * --bridge-overlay backdrop behind dialogs
34
+ * --bridge-alert-error-{bg,fg,border} errors, critical billing notices
35
+ * --bridge-alert-success-{bg,fg,border} confirmations, active plan badge
36
+ * --bridge-alert-info-{bg,fg,border} info notices, trial badge
37
+ * --bridge-alert-warning-{bg,fg,border} warnings, quota nearing its limit
38
+ *
39
+ * Layout
40
+ * --bridge-auth-page-padding default container of the sign-in pages
41
+ * --bridge-billing-page-width subscription pages, <BridgePaywallPage>
42
+ * --bridge-billing-page-padding subscription pages, <BridgePaywallPage>
43
+ * --bridge-paywall-bg backdrop of <BridgePaywall> / the lockscreen
44
+ * --bridge-paywall-panel-bg panel of <BridgePaywall>
45
+ *
46
+ * Deprecated aliases — still honoured when set on :root, removed in a later
47
+ * major: --bridge-primary-foreground → --bridge-primary-fg
48
+ * --bridge-bg-muted → --bridge-muted-bg
49
+ *
50
+ * Fonts and body text colour are not tokens: Bridge pages render inside your
51
+ * layout and inherit them.
10
52
  *
11
53
  * State selectors:
12
54
  * [data-active="true"] — active tab
@@ -19,21 +61,37 @@
19
61
  * [data-variant="danger"] — danger variant
20
62
  */
21
63
 
22
- /* ─── CSS variable defaults (consumer can override at any scope) ─────────── */
64
+ /* ─── Token defaults — zero specificity, so the app's :root always wins ──── */
23
65
 
24
- :root {
66
+ :where(:root) {
67
+ /* Derived tokens (primary-hover, primary-fg, primary-light, input-focus,
68
+ muted-bg, paywall-panel-bg) have no value here on purpose: each falls back
69
+ to its source where it is used, so setting --bridge-primary on a wrapper
70
+ re-derives them for that wrapper too. */
25
71
  --bridge-primary: #4f46e5;
26
- --bridge-primary-hover: #4338ca;
27
- --bridge-primary-fg: #ffffff;
72
+ --bridge-bg: #ffffff;
73
+ --bridge-foreground: #111827;
74
+ --bridge-muted: #6b7280;
28
75
  --bridge-border: #d1d5db;
29
76
  --bridge-border-radius: 6px;
30
- --bridge-input-focus: #4f46e5;
77
+ --bridge-overlay: rgba(15, 23, 42, 0.45);
31
78
  --bridge-alert-error-bg: #fef2f2;
32
79
  --bridge-alert-error-fg: #991b1b;
33
80
  --bridge-alert-error-border: #fca5a5;
34
81
  --bridge-alert-success-bg: #f0fdf4;
35
82
  --bridge-alert-success-fg: #166534;
36
83
  --bridge-alert-success-border: #86efac;
84
+ --bridge-alert-info-bg: #eff6ff;
85
+ --bridge-alert-info-fg: #1e40af;
86
+ --bridge-alert-info-border: #bfdbfe;
87
+ --bridge-alert-warning-bg: #fffbeb;
88
+ --bridge-alert-warning-fg: #92400e;
89
+ --bridge-alert-warning-border: #fcd34d;
90
+
91
+ --bridge-auth-page-padding: 3rem 1rem;
92
+ --bridge-billing-page-width: 60rem;
93
+ --bridge-billing-page-padding: 3rem 1rem;
94
+ --bridge-paywall-bg: rgba(15, 23, 42, 0.72);
37
95
  }
38
96
 
39
97
  /* ─── Input visual defaults ──────────────────────────────────────────────── */
@@ -47,8 +105,8 @@
47
105
  :where(input[type="text"]:focus, input[type="email"]:focus,
48
106
  input[type="password"]:focus, input[type="tel"]:focus,
49
107
  .bridge-input:focus) {
50
- border-color: var(--bridge-input-focus);
51
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--bridge-input-focus) 20%, transparent);
108
+ border-color: var(--bridge-input-focus, var(--bridge-primary));
109
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--bridge-input-focus, var(--bridge-primary)) 20%, transparent);
52
110
  }
53
111
 
54
112
  /* ─── Buttons ────────────────────────────────────────────────────────────── */
@@ -92,13 +150,13 @@
92
150
 
93
151
  :where(.bridge-btn-primary) {
94
152
  background-color: var(--bridge-primary);
95
- color: var(--bridge-primary-fg);
153
+ color: var(--bridge-primary-fg, var(--bridge-primary-foreground, #ffffff));
96
154
  border: 1px solid var(--bridge-primary);
97
155
  border-radius: var(--bridge-border-radius);
98
156
  }
99
157
  :where(.bridge-btn-primary:hover:not(:disabled)) {
100
- background-color: var(--bridge-primary-hover);
101
- border-color: var(--bridge-primary-hover);
158
+ background-color: var(--bridge-primary-hover, color-mix(in srgb, var(--bridge-primary) 85%, #000000));
159
+ border-color: var(--bridge-primary-hover, color-mix(in srgb, var(--bridge-primary) 85%, #000000));
102
160
  }
103
161
  :where(.bridge-btn-secondary, .bridge-btn-outline) {
104
162
  background-color: transparent;
@@ -275,12 +333,12 @@
275
333
  border: none;
276
334
  cursor: pointer;
277
335
  padding: 0.25rem;
278
- color: var(--bridge-muted, #6b7280);
336
+ color: var(--bridge-muted);
279
337
  line-height: 1;
280
338
  }
281
339
 
282
340
  .bridge-password-toggle:hover {
283
- color: var(--bridge-foreground, #374151);
341
+ color: var(--bridge-foreground);
284
342
  }
285
343
 
286
344
  /* ─── Auth form helpers ───────────────────────────────────────────────────── */
@@ -318,13 +376,13 @@
318
376
  /* Roughly the height of the credentials form it replaces, so the card does
319
377
  not collapse and then re-expand under the cursor. */
320
378
  min-height: 8rem;
321
- color: var(--bridge-muted, #6b7280);
379
+ color: var(--bridge-muted);
322
380
  font-size: 0.875rem;
323
381
  }
324
382
 
325
383
  .bridge-mfa-help {
326
384
  font-size: 0.8125rem;
327
- color: var(--bridge-muted, #6b7280);
385
+ color: var(--bridge-muted);
328
386
  margin: 0.75rem 0 0.25rem;
329
387
  text-align: center;
330
388
  }
@@ -358,7 +416,7 @@
358
416
  gap: 0.75rem;
359
417
  margin-bottom: 1rem;
360
418
  padding: 0.75rem;
361
- background: var(--bridge-bg-muted, #f5f5f5);
419
+ background: var(--bridge-muted-bg, var(--bridge-bg-muted, #f3f4f6));
362
420
  border-radius: 0.5rem;
363
421
  }
364
422
 
@@ -446,6 +504,19 @@
446
504
  font-family: inherit;
447
505
  }
448
506
 
507
+ :where(.bridge-team-tabs) {
508
+ border-bottom: 1px solid var(--bridge-border);
509
+ }
510
+
511
+ :where(.bridge-team-tab) {
512
+ color: var(--bridge-muted);
513
+ }
514
+
515
+ :where(.bridge-team-tab[data-active="true"]) {
516
+ color: var(--bridge-primary);
517
+ box-shadow: inset 0 -2px 0 var(--bridge-primary);
518
+ }
519
+
449
520
  /* ─── TeamUserList ───────────────────────────────────────────────────────── */
450
521
 
451
522
  .bridge-team-users-header {
@@ -633,6 +704,22 @@
633
704
  min-width: 160px;
634
705
  }
635
706
 
707
+ :where(.bridge-team-actions-menu) {
708
+ background: var(--bridge-bg);
709
+ color: var(--bridge-foreground);
710
+ border: 1px solid var(--bridge-border);
711
+ border-radius: var(--bridge-border-radius);
712
+ }
713
+
714
+ :where(.bridge-team-table th) {
715
+ color: var(--bridge-muted);
716
+ border-bottom: 1px solid var(--bridge-border);
717
+ }
718
+
719
+ :where(.bridge-team-table td) {
720
+ border-bottom: 1px solid var(--bridge-border);
721
+ }
722
+
636
723
  .bridge-team-actions-item {
637
724
  display: block;
638
725
  width: 100%;
@@ -647,15 +734,24 @@
647
734
 
648
735
  /* ─── Team Dialogs ───────────────────────────────────────────────────────── */
649
736
 
650
- .bridge-team-dialog {
737
+ /* The team dialogs carry [data-bridge-team-dialog]; the upgrade dialog the class. */
738
+ .bridge-team-dialog,
739
+ [data-bridge-team-dialog] {
651
740
  border: none;
652
741
  padding: 0;
653
742
  max-width: 400px;
654
743
  width: 90%;
655
744
  }
656
745
 
657
- .bridge-team-dialog::backdrop {
658
- background: rgba(0, 0, 0, 0.4);
746
+ :where(.bridge-team-dialog, [data-bridge-team-dialog]) {
747
+ background: var(--bridge-bg);
748
+ color: var(--bridge-foreground);
749
+ border-radius: var(--bridge-border-radius);
750
+ }
751
+
752
+ .bridge-team-dialog::backdrop,
753
+ [data-bridge-team-dialog]::backdrop {
754
+ background: var(--bridge-overlay);
659
755
  }
660
756
 
661
757
  .bridge-team-dialog-content {
@@ -764,25 +860,25 @@
764
860
  letter-spacing: 0.05em;
765
861
  font-weight: 500;
766
862
  text-align: left;
767
- border-bottom: 1px solid var(--bridge-border, #e5e7eb);
768
- color: var(--bridge-muted, #6b7280);
863
+ border-bottom: 1px solid var(--bridge-border);
864
+ color: var(--bridge-muted);
769
865
  }
770
866
 
771
867
  [data-bridge-api-tokens] .bridge-api-td,
772
868
  [data-bridge-api-tokens] .bridge-api-td-name,
773
869
  [data-bridge-api-tokens] .bridge-api-td-muted {
774
870
  padding: 0.75rem 1rem;
775
- border-bottom: 1px solid var(--bridge-border, #e5e7eb);
871
+ border-bottom: 1px solid var(--bridge-border);
776
872
  }
777
873
 
778
874
  [data-bridge-api-tokens] .bridge-api-td-muted {
779
- color: var(--bridge-muted, #6b7280);
875
+ color: var(--bridge-muted);
780
876
  }
781
877
 
782
878
  [data-bridge-api-tokens] .bridge-api-td-actions {
783
879
  padding: 0.75rem 1rem;
784
880
  text-align: right;
785
- border-bottom: 1px solid var(--bridge-border, #e5e7eb);
881
+ border-bottom: 1px solid var(--bridge-border);
786
882
  }
787
883
 
788
884
  [data-bridge-api-tokens] .bridge-api-privileges {
@@ -792,7 +888,7 @@
792
888
  }
793
889
 
794
890
  [data-bridge-api-tokens] .bridge-api-inline-form {
795
- border: 1px solid var(--bridge-border, #e5e7eb);
891
+ border: 1px solid var(--bridge-border);
796
892
  border-radius: 0.5rem;
797
893
  padding: 1rem 1.25rem;
798
894
  }
@@ -957,9 +1053,9 @@
957
1053
  gap: 0.25rem;
958
1054
  margin-bottom: 1.25rem;
959
1055
  padding: 0.25rem;
960
- border: 1px solid var(--bridge-border, #e2e8f0);
961
- border-radius: var(--bridge-border-radius, 0.5rem);
962
- background: var(--bridge-muted-bg, var(--bridge-bg-muted, #f8fafc));
1056
+ border: 1px solid var(--bridge-border);
1057
+ border-radius: var(--bridge-border-radius);
1058
+ background: var(--bridge-muted-bg, var(--bridge-bg-muted, #f3f4f6));
963
1059
  }
964
1060
 
965
1061
  .bridge-plan-interval-tab {
@@ -970,19 +1066,19 @@
970
1066
  font: inherit;
971
1067
  font-size: 0.8125rem;
972
1068
  padding: 0.375rem 0.875rem;
973
- border-radius: calc(var(--bridge-border-radius, 0.5rem) - 0.2rem);
974
- color: var(--bridge-muted, #64748b);
1069
+ border-radius: calc(var(--bridge-border-radius) - 0.2rem);
1070
+ color: var(--bridge-muted);
975
1071
  }
976
1072
 
977
1073
  .bridge-plan-interval-tab[data-active="true"] {
978
- background: var(--bridge-primary, #2563eb);
979
- color: var(--bridge-primary-fg, var(--bridge-primary-foreground, #fff));
1074
+ background: var(--bridge-primary);
1075
+ color: var(--bridge-primary-fg, var(--bridge-primary-foreground, #ffffff));
980
1076
  }
981
1077
 
982
1078
  .bridge-plan-unavailable {
983
1079
  margin: 0;
984
1080
  font-size: 0.8125rem;
985
- color: var(--bridge-muted, #64748b);
1081
+ color: var(--bridge-muted);
986
1082
  }
987
1083
 
988
1084
  /* Plan-change confirmation dialog + success notice (TBP-33) */
@@ -993,7 +1089,7 @@
993
1089
  .bridge-plan-confirm-backdrop {
994
1090
  position: fixed;
995
1091
  inset: 0;
996
- background: rgba(15, 23, 42, 0.45);
1092
+ background: var(--bridge-overlay);
997
1093
  display: grid;
998
1094
  place-items: center;
999
1095
  z-index: 1000;
@@ -1001,9 +1097,9 @@
1001
1097
  }
1002
1098
 
1003
1099
  .bridge-plan-confirm {
1004
- background: var(--bridge-bg, #fff);
1005
- border: 1px solid var(--bridge-border, #e2e8f0);
1006
- border-radius: var(--bridge-border-radius, 0.5rem);
1100
+ background: var(--bridge-bg);
1101
+ border: 1px solid var(--bridge-border);
1102
+ border-radius: var(--bridge-border-radius);
1007
1103
  box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.35);
1008
1104
  max-width: 26rem;
1009
1105
  width: 100%;
@@ -1028,7 +1124,7 @@
1028
1124
  .bridge-plan-confirm-note {
1029
1125
  margin: 0;
1030
1126
  font-size: 0.8125rem;
1031
- color: var(--bridge-muted, #64748b);
1127
+ color: var(--bridge-muted);
1032
1128
  line-height: 1.5;
1033
1129
  }
1034
1130
 
@@ -1129,12 +1225,12 @@
1129
1225
  }
1130
1226
 
1131
1227
  [data-bridge-workspace-item]:hover:not(:disabled) {
1132
- background: var(--bridge-muted-bg, #f3f4f6);
1228
+ background: var(--bridge-muted-bg, var(--bridge-bg-muted, #f3f4f6));
1133
1229
  }
1134
1230
 
1135
1231
  [data-bridge-workspace-item][data-active="true"] {
1136
- background: var(--bridge-primary-light, #eff6ff);
1137
- border-color: var(--bridge-primary, #3b82f6);
1232
+ background: var(--bridge-primary-light, color-mix(in srgb, var(--bridge-primary) 10%, #ffffff));
1233
+ border-color: var(--bridge-primary);
1138
1234
  }
1139
1235
 
1140
1236
  [data-bridge-workspace-item][data-loading="true"] {
@@ -1153,8 +1249,8 @@
1153
1249
  width: 2rem;
1154
1250
  height: 2rem;
1155
1251
  border-radius: 0.375rem;
1156
- background: var(--bridge-primary, #3b82f6);
1157
- color: var(--bridge-primary-foreground, #ffffff);
1252
+ background: var(--bridge-primary);
1253
+ color: var(--bridge-primary-fg, var(--bridge-primary-foreground, #ffffff));
1158
1254
  font-size: 0.875rem;
1159
1255
  font-weight: 600;
1160
1256
  flex-shrink: 0;
@@ -1178,7 +1274,7 @@
1178
1274
  [data-bridge-workspace-name] {
1179
1275
  font-size: 0.875rem;
1180
1276
  font-weight: 500;
1181
- color: var(--bridge-foreground, #111827);
1277
+ color: var(--bridge-foreground);
1182
1278
  white-space: nowrap;
1183
1279
  overflow: hidden;
1184
1280
  text-overflow: ellipsis;
@@ -1186,7 +1282,7 @@
1186
1282
 
1187
1283
  [data-bridge-workspace-user] {
1188
1284
  font-size: 0.75rem;
1189
- color: var(--bridge-muted, #6b7280);
1285
+ color: var(--bridge-muted);
1190
1286
  white-space: nowrap;
1191
1287
  overflow: hidden;
1192
1288
  text-overflow: ellipsis;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebulr-group/bridge-svelte",
3
- "version": "0.9.0-beta.3",
3
+ "version": "0.9.0-beta.5",
4
4
  "description": "Bridge Svelte library, This library helps you to add bridge authentication and feature flags, and payments to your svelte application.",
5
5
  "author": "Iman Pouya",
6
6
  "license": "MIT",
@@ -79,7 +79,7 @@
79
79
  "typescript": "^6.0.0",
80
80
  "vite": "^6.2.6",
81
81
  "vitest": "^4.1.4",
82
- "@nebulr-group/bridge-auth-core": "0.8.0-beta.0"
82
+ "@nebulr-group/bridge-auth-core": "0.8.0-beta.2"
83
83
  },
84
84
  "keywords": [
85
85
  "svelte",