fontdue-js 3.2.0 → 3.2.2

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.
@@ -5,7 +5,10 @@ import _TestFontsForm_Query from "../../__generated__/TestFontsForm_Query.graphq
5
5
  import _TestFontsFormUpdateCustomerMutation from "../../__generated__/TestFontsFormUpdateCustomerMutation.graphql.js";
6
6
  import React, { useState, useRef, useCallback } from 'react';
7
7
  import { commitMutation, graphql, useRelayEnvironment, useLazyLoadQuery, usePreloadedQuery } from 'react-relay';
8
- import { ReCAPTCHA } from 'react-google-recaptcha';
8
+ // The script-injecting reCAPTCHA wrapper (loads Google's api.js). Importing the
9
+ // package's barebone named export instead was the FD-917 root cause; see
10
+ // ../Recaptcha for the full explanation.
11
+ import { ReCAPTCHA } from '../Recaptcha.js';
9
12
  import TextField from '../TextField/index.js';
10
13
  import { DownloadFonts } from '../Icons/index.js';
11
14
  import TestFontsFormQueryNode from '../../__generated__/TestFontsForm_Query.graphql.js';
@@ -13,6 +16,7 @@ import Checkbox from '../Checkbox/index.js';
13
16
  import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
14
17
  import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
15
18
  import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
19
+ import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
16
20
  const updateCustomerMutation = (_TestFontsFormUpdateCustomerMutation.hash && _TestFontsFormUpdateCustomerMutation.hash !== "ba56958399f0893bd667ff02c33a6975" && console.error("The definition of 'TestFontsFormUpdateCustomerMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TestFontsFormUpdateCustomerMutation);
17
21
  const TestFontsDownloading = _ref => {
18
22
  let {
@@ -46,11 +50,13 @@ const TestFontsFormComponent = _ref2 => {
46
50
  const [pendingSubmit, setPendingSubmit] = useState(false);
47
51
  const downloadForm = useRef(null);
48
52
  const recaptchaRef = useRef(null);
53
+ const recaptchaTimeout = useRecaptchaTimeout();
49
54
  const environment = useRelayEnvironment();
50
55
  if (!data.viewer) return null;
51
56
  const recaptchaEnabled = ((_data$viewer$settings = data.viewer.settings) === null || _data$viewer$settings === void 0 ? void 0 : _data$viewer$settings.recaptchaEnabled) ?? false;
52
57
  const recaptchaSiteKey = (_data$viewer$settings2 = data.viewer.settings) === null || _data$viewer$settings2 === void 0 ? void 0 : _data$viewer$settings2.recaptchaSiteKey;
53
58
  const submitForm = useCallback(token => {
59
+ recaptchaTimeout.clear();
54
60
  setSubmitting(true);
55
61
  setPendingSubmit(false);
56
62
  setError(null);
@@ -92,7 +98,7 @@ const TestFontsFormComponent = _ref2 => {
92
98
  setRecaptchaToken(null);
93
99
  }
94
100
  });
95
- }, [environment, name, email, newsletterOptIn]);
101
+ }, [environment, name, email, newsletterOptIn, recaptchaTimeout]);
96
102
  const handleRecaptchaChange = useCallback(token => {
97
103
  setRecaptchaToken(token);
98
104
  // If we were waiting for a token to submit, do it now
@@ -103,6 +109,20 @@ const TestFontsFormComponent = _ref2 => {
103
109
  const handleRecaptchaExpired = useCallback(() => {
104
110
  setRecaptchaToken(null);
105
111
  }, []);
112
+
113
+ // Recover cleanly when the reCAPTCHA widget can't produce a token, so the
114
+ // button doesn't sit on “Submitting…” forever if the widget fails to load.
115
+ const abortRecaptcha = useCallback(() => {
116
+ var _recaptchaRef$current3;
117
+ recaptchaTimeout.clear();
118
+ setPendingSubmit(false);
119
+ setRecaptchaToken(null);
120
+ (_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.reset();
121
+ setError(RECAPTCHA_UNAVAILABLE_MESSAGE);
122
+ }, [recaptchaTimeout]);
123
+ const handleRecaptchaErrored = useCallback(() => {
124
+ if (pendingSubmit) abortRecaptcha();
125
+ }, [pendingSubmit, abortRecaptcha]);
106
126
  const handleSubmit = e => {
107
127
  e.preventDefault();
108
128
  if (!eulaAgreed) {
@@ -112,10 +132,17 @@ const TestFontsFormComponent = _ref2 => {
112
132
 
113
133
  // If reCAPTCHA is enabled but no token, execute it and wait for callback
114
134
  if (recaptchaEnabled && recaptchaSiteKey && !recaptchaToken) {
115
- var _recaptchaRef$current3;
135
+ var _recaptchaRef$current4;
116
136
  setPendingSubmit(true);
117
137
  setError(null);
118
- (_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.execute();
138
+ // Safety net: give up if no token arrives and the reCAPTCHA script never
139
+ // loaded (a challenge in progress keeps `grecaptcha` defined, so we don't
140
+ // cancel on a user who's mid-challenge).
141
+ recaptchaTimeout.start(() => {
142
+ if (isRecaptchaScriptLoaded()) return;
143
+ abortRecaptcha();
144
+ });
145
+ (_recaptchaRef$current4 = recaptchaRef.current) === null || _recaptchaRef$current4 === void 0 ? void 0 : _recaptchaRef$current4.execute();
119
146
  return;
120
147
  }
121
148
  submitForm(recaptchaToken);
@@ -181,7 +208,8 @@ const TestFontsFormComponent = _ref2 => {
181
208
  sitekey: recaptchaSiteKey,
182
209
  size: "invisible",
183
210
  onChange: handleRecaptchaChange,
184
- onExpired: handleRecaptchaExpired
211
+ onExpired: handleRecaptchaExpired,
212
+ onErrored: handleRecaptchaErrored
185
213
  }), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
186
214
  className: "submit-button",
187
215
  type: "submit",
@@ -10,6 +10,11 @@ const isDirection = str => {
10
10
  };
11
11
  const parseBool = input => input === 'true' ? true : false;
12
12
 
13
+ // Split a comma-separated attribute into trimmed, non-empty tokens, so that
14
+ // `axes="wght, opsz"` behaves the same as `axes="wght,opsz"` and a trailing
15
+ // comma doesn't produce a bogus empty entry.
16
+ const splitList = input => (input === null || input === void 0 ? void 0 : input.split(',').map(token => token.trim()).filter(Boolean)) ?? [];
17
+
13
18
  // parse a string like "wdth 1000, ital 0.5" into
14
19
  // [{ axis: "wdth", value: 1000 }, { axis: "ital", value: 0.5 }]
15
20
  function parseVariableSettings(input) {
@@ -45,12 +50,12 @@ export const TypeTesterStandaloneElement = _ref => {
45
50
  lineHeight: lineHeight ? parseFloat(lineHeight) : undefined,
46
51
  letterSpacing: letterSpacing ? parseFloat(letterSpacing) : undefined,
47
52
  fontSize: fontSize ? parseFloat(fontSize) : undefined,
48
- axes: (axes === null || axes === void 0 ? void 0 : axes.split(',')) ?? [],
49
- features: (features === null || features === void 0 ? void 0 : features.split(',')) ?? [],
50
- featureSettings: featuresSelected === null || featuresSelected === void 0 ? void 0 : featuresSelected.split(',').map(feature => ({
53
+ axes: splitList(axes),
54
+ features: splitList(features),
55
+ featureSettings: featuresSelected ? splitList(featuresSelected).map(feature => ({
51
56
  feature,
52
57
  value: '1'
53
- })),
58
+ })) : undefined,
54
59
  alignment: isAlignment(alignment) ? alignment : undefined,
55
60
  direction: isDirection(direction) ? direction : undefined,
56
61
  variableSettings: parseVariableSettings(variableSettings)
@@ -17,6 +17,22 @@ const TypeTesterVariableAxes = _ref => {
17
17
  id
18
18
  });
19
19
  const fontStyle = useFragment((_TypeTesterVariableAxes_fontStyle.hash && _TypeTesterVariableAxes_fontStyle.hash !== "a73e45dbb7a154107456088125a01f88" && console.error("The definition of 'TypeTesterVariableAxes_fontStyle' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TypeTesterVariableAxes_fontStyle), fontStyleKey);
20
+
21
+ // Warn when axes are requested that the font style doesn't actually expose —
22
+ // a common silent misconfiguration. The `axes` attribute only whitelists which
23
+ // of the font's variable axes to show; it can't surface axes the font file
24
+ // doesn't contain (e.g. pointing a tester at a static style, or a wrong/mis-cased
25
+ // tag). Without this hint the sliders just fail to appear with no explanation.
26
+ const requestedAxesKey = (axes ?? []).filter(Boolean).join(',');
27
+ const availableAxesKey = (fontStyle.variableAxes ?? []).map(variableAxis => variableAxis === null || variableAxis === void 0 ? void 0 : variableAxis.axis).filter(notEmpty).join(',');
28
+ React.useEffect(() => {
29
+ if (!requestedAxesKey) return;
30
+ const available = new Set(availableAxesKey ? availableAxesKey.split(',') : []);
31
+ const missing = requestedAxesKey.split(',').filter(axis => !available.has(axis));
32
+ if (missing.length === 0) return;
33
+ const label = missing.length === 1 ? 'axis' : 'axes';
34
+ console.warn(`fontdue-type-tester: variable ${label} ${missing.map(axis => `"${axis}"`).join(', ')} ` + `requested via the "axes" attribute, but ` + (available.size === 0 ? `this font style exposes no variable axes (is it a variable font?). No axis sliders will be shown.` : `this font style only exposes ${[...available].map(axis => `"${axis}"`).join(', ')}. ` + `No slider will be shown for the missing ${label}.`));
35
+ }, [requestedAxesKey, availableAxesKey]);
20
36
  if (!variableSettings) return null;
21
37
  if (!axes) return null;
22
38
  const handleChange = (axis, value) => {
@@ -63,7 +63,10 @@ import _StoreModalUnifiedCheckout_viewer from "../../__generated__/StoreModalUni
63
63
  */
64
64
 
65
65
  import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
66
- import { ReCAPTCHA } from 'react-google-recaptcha';
66
+ // The script-injecting reCAPTCHA wrapper (loads Google's api.js). Importing the
67
+ // package's barebone named export instead was the FD-917 root cause; see
68
+ // ../Recaptcha for the full explanation.
69
+ import { ReCAPTCHA } from '../Recaptcha.js';
67
70
  import * as Sentry from '@sentry/react';
68
71
  import { commitMutation, graphql, useFragment, useRelayEnvironment } from 'react-relay';
69
72
  import AddressFields from '../Cart/AddressFields.js';
@@ -76,6 +79,7 @@ import { Check, X } from '../Icons/index.js';
76
79
  import ConfigContext from '../ConfigContext.js';
77
80
  import { Price } from '../Price/index.js';
78
81
  import { sendOrderTracking } from '../Cart/orderTracking.js';
82
+ import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
79
83
 
80
84
  /* const LICENSEE_REQUIRED_FIELDS = ['organization', 'country'] as (keyof Identity)[]; */
81
85
 
@@ -297,7 +301,11 @@ export default function StoreModalUnifiedCheckout(_ref2) {
297
301
  // reCAPTCHA state
298
302
  const [recaptchaToken, setRecaptchaToken] = useState(null);
299
303
  const [pendingCustomerSubmit, setPendingCustomerSubmit] = useState(null);
304
+ // Error shown inside the contact-information section (separate from the
305
+ // payment-section `error`, which isn't rendered until a customer exists).
306
+ const [customerError, setCustomerError] = useState(null);
300
307
  const recaptchaRef = useRef(null);
308
+ const recaptchaTimeout = useRecaptchaTimeout();
301
309
  const recaptchaEnabled = ((_viewer$settings = viewer.settings) === null || _viewer$settings === void 0 ? void 0 : _viewer$settings.recaptchaEnabled) ?? false;
302
310
  const recaptchaSiteKey = (_viewer$settings2 = viewer.settings) === null || _viewer$settings2 === void 0 ? void 0 : _viewer$settings2.recaptchaSiteKey;
303
311
  const onCompleted = (res, errors, onSuccess, onError) => {
@@ -417,7 +425,9 @@ export default function StoreModalUnifiedCheckout(_ref2) {
417
425
 
418
426
  // Submit customer data with optional reCAPTCHA token
419
427
  const submitCustomer = useCallback((token, callbacks) => {
428
+ recaptchaTimeout.clear();
420
429
  setPendingCustomerSubmit(null);
430
+ setCustomerError(null);
421
431
  if (!billingIdentity.name && !billingIdentity.email) {
422
432
  setBillingIdentity({
423
433
  ...billingIdentity,
@@ -444,7 +454,7 @@ export default function StoreModalUnifiedCheckout(_ref2) {
444
454
  (_recaptchaRef$current2 = recaptchaRef.current) === null || _recaptchaRef$current2 === void 0 ? void 0 : _recaptchaRef$current2.reset();
445
455
  setRecaptchaToken(null);
446
456
  });
447
- }, [customer, billingIdentity, updateCustomer]);
457
+ }, [customer, billingIdentity, updateCustomer, recaptchaTimeout]);
448
458
  const handleRecaptchaChange = useCallback(token => {
449
459
  setRecaptchaToken(token);
450
460
  // If we were waiting for a token to submit, do it now
@@ -455,14 +465,43 @@ export default function StoreModalUnifiedCheckout(_ref2) {
455
465
  const handleRecaptchaExpired = useCallback(() => {
456
466
  setRecaptchaToken(null);
457
467
  }, []);
468
+
469
+ // Give up cleanly when the reCAPTCHA widget can't produce a token. Without
470
+ // this, a submit that's waiting on `execute()` would sit on “Submitting…”
471
+ // forever (the widget failing to load is a silent no-op). Reset the section
472
+ // and show an error so the buyer can retry.
473
+ const abortCustomerRecaptcha = useCallback(callbacks => {
474
+ var _recaptchaRef$current3;
475
+ recaptchaTimeout.clear();
476
+ setPendingCustomerSubmit(null);
477
+ setRecaptchaToken(null);
478
+ (_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.reset();
479
+ setCustomerError(RECAPTCHA_UNAVAILABLE_MESSAGE);
480
+ callbacks.onError();
481
+ }, [recaptchaTimeout]);
482
+ const handleRecaptchaErrored = useCallback(() => {
483
+ // Widget loaded but errored during execution — recover the same way.
484
+ if (pendingCustomerSubmit) {
485
+ abortCustomerRecaptcha(pendingCustomerSubmit.callbacks);
486
+ }
487
+ }, [pendingCustomerSubmit, abortCustomerRecaptcha]);
458
488
  const handleCustomerSubmit = callbacks => {
459
489
  // If reCAPTCHA is enabled but no token, execute it and wait for callback
460
490
  if (recaptchaEnabled && recaptchaSiteKey && !recaptchaToken) {
461
- var _recaptchaRef$current3;
491
+ var _recaptchaRef$current4;
492
+ setCustomerError(null);
462
493
  setPendingCustomerSubmit({
463
494
  callbacks
464
495
  });
465
- (_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.execute();
496
+ // Safety net: if no token arrives (e.g. Google's api.js never loaded),
497
+ // don't hang. When the timer fires we only give up if the reCAPTCHA
498
+ // script never loaded — if it did, the buyer may be mid-challenge, so we
499
+ // keep waiting for their `onChange` rather than cancelling on them.
500
+ recaptchaTimeout.start(() => {
501
+ if (isRecaptchaScriptLoaded()) return;
502
+ abortCustomerRecaptcha(callbacks);
503
+ });
504
+ (_recaptchaRef$current4 = recaptchaRef.current) === null || _recaptchaRef$current4 === void 0 ? void 0 : _recaptchaRef$current4.execute();
466
505
  return;
467
506
  }
468
507
  submitCustomer(recaptchaToken, callbacks);
@@ -661,12 +700,15 @@ export default function StoreModalUnifiedCheckout(_ref2) {
661
700
  onChange: handleCustomerChange,
662
701
  optInLabel: (_viewer$settings3 = viewer.settings) === null || _viewer$settings3 === void 0 ? void 0 : _viewer$settings3.newsletterOptInLabel,
663
702
  errors: customerErrorsObject
664
- }), recaptchaEnabled && recaptchaSiteKey && /*#__PURE__*/React.createElement(ReCAPTCHA, {
703
+ }), customerError && /*#__PURE__*/React.createElement("div", {
704
+ className: "store-modal__cart__error"
705
+ }, customerError), recaptchaEnabled && recaptchaSiteKey && /*#__PURE__*/React.createElement(ReCAPTCHA, {
665
706
  ref: recaptchaRef,
666
707
  sitekey: recaptchaSiteKey,
667
708
  size: "invisible",
668
709
  onChange: handleRecaptchaChange,
669
- onExpired: handleRecaptchaExpired
710
+ onExpired: handleRecaptchaExpired,
711
+ onErrored: handleRecaptchaErrored
670
712
  }))
671
713
  }), order.customer && !hasCountryMismatch ? /*#__PURE__*/React.createElement(StoreModalLicenseeIsBillingIdentityElement, {
672
714
  disabled: false,
package/dist/fontdue.css CHANGED
@@ -3480,16 +3480,24 @@ textarea.text-field__input {
3480
3480
 
3481
3481
  .fontdue-admin-toolbar {
3482
3482
  position: fixed;
3483
- right: 16px;
3484
- bottom: 16px;
3483
+ left: 50%;
3484
+ bottom: calc(14px + env(safe-area-inset-bottom, 0px));
3485
+ transform: translateX(-50%);
3485
3486
  z-index: 2147483000;
3486
- font-family: inherit;
3487
+ display: flex;
3488
+ flex-direction: column;
3489
+ align-items: center;
3490
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
3487
3491
  font-size: 13px;
3488
3492
  line-height: 1.45;
3493
+ text-align: left;
3489
3494
  color: #fff;
3490
- --fdat-surface: #0a0a0a;
3495
+ --fdat-surface: #101014;
3491
3496
  --fdat-border: rgba(255, 255, 255, 0.16);
3492
3497
  --fdat-muted: rgba(255, 255, 255, 0.5);
3498
+ --fdat-accent: #5b5bd6;
3499
+ --fdat-well: rgba(255, 255, 255, 0.05);
3500
+ --fdat-well-on: rgba(91, 91, 214, 0.18);
3493
3501
  }
3494
3502
  .fontdue-admin-toolbar * {
3495
3503
  box-sizing: border-box;
@@ -3518,43 +3526,79 @@ textarea.text-field__input {
3518
3526
  display: inline-flex;
3519
3527
  align-items: center;
3520
3528
  gap: 8px;
3521
- padding: 8px 12px;
3529
+ padding: 8px 16px;
3522
3530
  font-size: 13px;
3523
- background: var(--fdat-surface);
3531
+ background: #0a0a0a;
3524
3532
  color: #fff;
3525
3533
  border: 1px solid var(--fdat-border);
3534
+ border-radius: 999px;
3535
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
3536
+ white-space: nowrap;
3526
3537
  }
3527
3538
  .fontdue-admin-toolbar__button:hover {
3528
3539
  border-color: rgba(255, 255, 255, 0.4);
3529
3540
  }
3530
3541
  .fontdue-admin-toolbar__button:focus-visible {
3531
3542
  outline: 1px solid #fff;
3532
- outline-offset: 1px;
3543
+ outline-offset: 2px;
3544
+ }
3545
+ .fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__button {
3546
+ background: var(--fdat-accent);
3547
+ border-color: var(--fdat-accent);
3548
+ }
3549
+ .fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__button:hover {
3550
+ border-color: #fff;
3533
3551
  }
3534
3552
 
3535
- .fontdue-admin-toolbar__dot {
3536
- width: 6px;
3537
- height: 6px;
3538
- background: var(--fdat-muted);
3553
+ .fontdue-admin-toolbar__mark {
3554
+ flex: none;
3555
+ width: 16px;
3556
+ height: 16px;
3557
+ display: grid;
3558
+ place-items: center;
3559
+ background: var(--fdat-accent);
3560
+ color: #fff;
3561
+ border-radius: 5px;
3562
+ font-family: Georgia, "Times New Roman", serif;
3563
+ font-size: 10px;
3564
+ line-height: 1;
3539
3565
  }
3540
- .fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__dot {
3566
+ .fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__button .fontdue-admin-toolbar__mark {
3541
3567
  background: #fff;
3568
+ color: var(--fdat-accent);
3542
3569
  }
3543
3570
 
3544
3571
  .fontdue-admin-toolbar__panel {
3545
- width: 248px;
3546
- margin-bottom: 8px;
3547
- padding: 14px;
3572
+ width: 280px;
3573
+ max-width: calc(100vw - 24px);
3574
+ margin-bottom: 10px;
3575
+ padding: 16px;
3548
3576
  background: var(--fdat-surface);
3549
3577
  border: 1px solid var(--fdat-border);
3578
+ border-radius: 14px;
3579
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
3550
3580
  }
3551
3581
 
3552
3582
  .fontdue-admin-toolbar__header {
3553
3583
  display: flex;
3554
3584
  justify-content: space-between;
3555
- align-items: baseline;
3585
+ align-items: center;
3556
3586
  gap: 10px;
3557
- margin-bottom: 12px;
3587
+ margin-bottom: 14px;
3588
+ }
3589
+
3590
+ .fontdue-admin-toolbar__brand {
3591
+ display: inline-flex;
3592
+ align-items: center;
3593
+ gap: 8px;
3594
+ font-size: 12.5px;
3595
+ font-weight: 700;
3596
+ }
3597
+ .fontdue-admin-toolbar__brand .fontdue-admin-toolbar__mark {
3598
+ width: 19px;
3599
+ height: 19px;
3600
+ border-radius: 6px;
3601
+ font-size: 11.5px;
3558
3602
  }
3559
3603
 
3560
3604
  .fontdue-admin-toolbar__title {
@@ -3569,16 +3613,93 @@ textarea.text-field__input {
3569
3613
  }
3570
3614
 
3571
3615
  .fontdue-admin-toolbar__toggle {
3616
+ position: relative;
3572
3617
  display: flex;
3618
+ justify-content: space-between;
3573
3619
  align-items: center;
3574
- gap: 8px;
3620
+ gap: 12px;
3621
+ padding: 11px 12px;
3622
+ background: var(--fdat-well);
3623
+ border-radius: 10px;
3575
3624
  cursor: pointer;
3576
3625
  }
3577
3626
  .fontdue-admin-toolbar__toggle input {
3627
+ position: absolute;
3628
+ inset: 0;
3629
+ width: 100%;
3630
+ height: 100%;
3578
3631
  margin: 0;
3579
- accent-color: #fff;
3632
+ opacity: 0;
3580
3633
  cursor: pointer;
3581
3634
  }
3635
+ .fontdue-admin-toolbar__toggle input:disabled {
3636
+ cursor: default;
3637
+ }
3638
+ .fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__toggle {
3639
+ background: var(--fdat-well-on);
3640
+ }
3641
+ .fontdue-admin-toolbar__toggle:has(input:focus-visible) {
3642
+ outline: 1px solid #fff;
3643
+ outline-offset: 2px;
3644
+ }
3645
+
3646
+ .fontdue-admin-toolbar__toggle-text {
3647
+ display: flex;
3648
+ flex-direction: column;
3649
+ gap: 2px;
3650
+ }
3651
+ .fontdue-admin-toolbar__toggle-text strong {
3652
+ font-size: 12.5px;
3653
+ font-weight: 700;
3654
+ }
3655
+ .fontdue-admin-toolbar__toggle-text span {
3656
+ font-size: 10.5px;
3657
+ color: var(--fdat-muted);
3658
+ }
3659
+
3660
+ .fontdue-admin-toolbar__switch {
3661
+ flex: none;
3662
+ width: 36px;
3663
+ height: 22px;
3664
+ border-radius: 999px;
3665
+ background: rgba(255, 255, 255, 0.18);
3666
+ position: relative;
3667
+ transition: background 0.15s;
3668
+ }
3669
+ .fontdue-admin-toolbar__switch::after {
3670
+ content: "";
3671
+ position: absolute;
3672
+ top: 3px;
3673
+ left: 3px;
3674
+ width: 16px;
3675
+ height: 16px;
3676
+ border-radius: 50%;
3677
+ background: #fff;
3678
+ transition: left 0.15s;
3679
+ }
3680
+ input:checked + .fontdue-admin-toolbar__switch {
3681
+ background: var(--fdat-accent);
3682
+ }
3683
+ input:checked + .fontdue-admin-toolbar__switch::after {
3684
+ left: 17px;
3685
+ }
3686
+ input:disabled + .fontdue-admin-toolbar__switch {
3687
+ opacity: 0.55;
3688
+ }
3689
+ @media (prefers-reduced-motion: reduce) {
3690
+ .fontdue-admin-toolbar__switch {
3691
+ transition: none;
3692
+ }
3693
+ .fontdue-admin-toolbar__switch::after {
3694
+ transition: none;
3695
+ }
3696
+ }
3697
+
3698
+ .fontdue-admin-toolbar__status {
3699
+ margin: 9px 0 0;
3700
+ font-size: 11px;
3701
+ color: #b3b3f2;
3702
+ }
3582
3703
 
3583
3704
  .fontdue-admin-toolbar__hint {
3584
3705
  margin: 10px 0 0;
@@ -3590,6 +3711,9 @@ textarea.text-field__input {
3590
3711
  display: flex;
3591
3712
  flex-direction: column;
3592
3713
  gap: 10px;
3714
+ padding: 11px 12px;
3715
+ background: rgba(224, 162, 60, 0.12);
3716
+ border-radius: 10px;
3593
3717
  }
3594
3718
 
3595
3719
  .fontdue-admin-toolbar__expired-text {
@@ -3597,8 +3721,8 @@ textarea.text-field__input {
3597
3721
  align-items: flex-start;
3598
3722
  gap: 8px;
3599
3723
  margin: 0;
3600
- font-size: 12px;
3601
- color: rgba(255, 255, 255, 0.85);
3724
+ font-size: 11.5px;
3725
+ color: #e8c07a;
3602
3726
  }
3603
3727
 
3604
3728
  .fontdue-admin-toolbar__warning-icon {
@@ -3660,7 +3784,7 @@ textarea.text-field__input {
3660
3784
  gap: 8px;
3661
3785
  width: 100%;
3662
3786
  margin: 0;
3663
- padding: 7px 10px;
3787
+ padding: 8px 11px;
3664
3788
  font: inherit;
3665
3789
  font-size: 12px;
3666
3790
  text-align: left;
@@ -3668,7 +3792,7 @@ textarea.text-field__input {
3668
3792
  color: #fff;
3669
3793
  background: transparent;
3670
3794
  border: 1px solid var(--fdat-border);
3671
- border-radius: 0;
3795
+ border-radius: 10px;
3672
3796
  cursor: pointer;
3673
3797
  }
3674
3798
  .fontdue-admin-toolbar__action:hover {
@@ -3694,7 +3818,7 @@ textarea.text-field__input {
3694
3818
  padding-top: 10px;
3695
3819
  border-top: 1px solid var(--fdat-border);
3696
3820
  color: var(--fdat-muted);
3697
- font-size: 11px;
3821
+ font-size: 10.5px;
3698
3822
  word-break: break-word;
3699
3823
  }
3700
3824
 
@@ -3702,7 +3826,7 @@ textarea.text-field__input {
3702
3826
  display: block;
3703
3827
  margin-top: 4px;
3704
3828
  color: rgba(255, 255, 255, 0.32);
3705
- font-size: 11px;
3829
+ font-size: 10.5px;
3706
3830
  }
3707
3831
 
3708
3832
  :root {
@@ -0,0 +1,11 @@
1
+ export declare const RECAPTCHA_TIMEOUT_MS = 15000;
2
+ export declare const RECAPTCHA_UNAVAILABLE_MESSAGE = "We couldn\u2019t verify that you\u2019re human. Please refresh the page and try again.";
3
+ export declare function isRecaptchaScriptLoaded(): boolean;
4
+ export interface RecaptchaTimeout {
5
+ /** (Re)start the timer. Any previously scheduled callback is cancelled. */
6
+ start(onTimeout: () => void): void;
7
+ /** Cancel a pending timer (e.g. once a token arrives). Safe to call twice. */
8
+ clear(): void;
9
+ }
10
+ export declare function createRecaptchaTimeout(timeoutMs?: number): RecaptchaTimeout;
11
+ export declare function useRecaptchaTimeout(timeoutMs?: number): RecaptchaTimeout;
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ // How long we wait for the invisible reCAPTCHA widget to hand back a token
4
+ // after calling `execute()` before we give up. reCAPTCHA normally resolves in
5
+ // a second or two; a genuine challenge is handled by the readiness probe below
6
+ // (we don't cut a buyer off mid-challenge), so this only needs to be long
7
+ // enough to rule out a transient slow load.
8
+ export const RECAPTCHA_TIMEOUT_MS = 15000;
9
+
10
+ // User-facing message shown when the reCAPTCHA widget never produced a token
11
+ // (typically because Google's api.js failed to load). Curly apostrophes per
12
+ // the Fontdue house style.
13
+ export const RECAPTCHA_UNAVAILABLE_MESSAGE = 'We couldn’t verify that you’re human. Please refresh the page and try again.';
14
+
15
+ // True once Google's reCAPTCHA script (`window.grecaptcha`) has loaded. We use
16
+ // this to tell the two failure modes apart when the timeout fires:
17
+ // - script never loaded → the widget is dead and would hang forever; reset.
18
+ // - script loaded, no token yet → the buyer may be mid-challenge; keep
19
+ // waiting rather than cancelling on them.
20
+ export function isRecaptchaScriptLoaded() {
21
+ // Google's api.js defines `grecaptcha` on the global object; in a browser
22
+ // `globalThis === window`, so this reads the same property while staying
23
+ // safe under SSR and unit tests where `window` may be undefined.
24
+ return typeof globalThis !== 'undefined' && globalThis.grecaptcha != null;
25
+ }
26
+ // Framework-free timer wrapper. Kept as a plain factory (not a hook) so the
27
+ // resilience contract can be unit-tested with fake timers without a DOM.
28
+ export function createRecaptchaTimeout() {
29
+ let timeoutMs = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : RECAPTCHA_TIMEOUT_MS;
30
+ let handle = null;
31
+ const clear = () => {
32
+ if (handle !== null) {
33
+ clearTimeout(handle);
34
+ handle = null;
35
+ }
36
+ };
37
+ const start = onTimeout => {
38
+ clear();
39
+ handle = setTimeout(() => {
40
+ handle = null;
41
+ onTimeout();
42
+ }, timeoutMs);
43
+ };
44
+ return {
45
+ start,
46
+ clear
47
+ };
48
+ }
49
+
50
+ // React hook wrapper: one stable timer per component that auto-clears on
51
+ // unmount (so a pending timeout never fires setState after teardown).
52
+ export function useRecaptchaTimeout(timeoutMs) {
53
+ const ref = useRef(null);
54
+ if (ref.current === null) {
55
+ ref.current = createRecaptchaTimeout(timeoutMs);
56
+ }
57
+ const timeout = ref.current;
58
+ useEffect(() => () => timeout.clear(), [timeout]);
59
+ return timeout;
60
+ }