docs(faucet): inline the testnet faucet widget as a Mintlify snippet - #71
Conversation
The faucet was the last page still iframing sei-widgets.vercel.app. The widget repo kept it there because it imported npm packages that Mintlify snippets cannot bundle (@hcaptcha/react-hcaptcha, viem, sonner), but none of them are actually required: - hCaptcha loads from js.hcaptcha.com and runs through window.hcaptcha.render / .execute. hcaptcha.com and *.hcaptcha.com are already on Mintlify's default CSP for script-src, frame-src, style-src and connect-src. - address validation is a 0x + 40-hex regex instead of viem's isAddress - sonner toasts become inline status banners The faucet backend is unchanged: requests still POST to faucet-v3.seinetwork.io/atlantic-2 and poll /message/:id for the tx hash. Also drops iframe-resizer.js and the aspect-ratio override in style.css, which existed only to size the sei-widgets iframes. Note: the hCaptcha sitekey needs docs.sei.io (plus localhost for `mint dev`) added to its allowed hostnames, since the captcha now runs on the docs origin rather than inside the widget iframe. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
The sitekey is not hostname-restricted, so moving the challenge from the widget iframe to the docs origin needs no dashboard change. Removes the note from the snippet header and rewrites the load-failure message, which told readers to edit a sitekey they have no access to. Co-authored-by: Cursor <cursoragent@cursor.com>
PR SummaryMedium Risk Overview The new snippet talks to the same
Reviewed by Cursor Bugbot for commit 3f7e07c. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Solid, self-contained replacement of the sei-widgets faucet iframe with an in-repo Mintlify snippet: the effect/interval lifecycle is cleaned up correctly, the deleted iframe-resizer.js and CSS override have no remaining consumers, and the snippet matches the repo's existing hook/token conventions. No blockers found — remaining notes are accessibility, error-surfacing, and polish items, plus the fact that both second-opinion passes produced no output.
Findings: 0 blocking | 12 non-blocking | 7 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Both second-opinion review files are empty —
codex-review.mdandcursor-review.mdcontain no content, so this review is from a single pass with no Codex/Cursor findings merged in.REVIEW_GUIDELINES.mdis also empty; I appliedAGENTS.mdconventions instead. - Verified the cleanup is safe: no
sei-widgets.vercel.apporlocalhost:3100iframes remain anywhere in the repo, and neitheriframe-resizer.jsnor the deletedstyle.cssblock was referenced fromdocs.json(Mintlify auto-injects root-level assets), so removing both is a no-op for the remaining Symphony/YouTube iframes. - There is no automated coverage for snippets — validation is entirely the manual preview check in the PR description. Worth having a reviewer actually complete a captcha + request on the preview build before merge, since the failure modes here (hCaptcha overlay rendering, CSP on the docs origin) are invisible to CI and the production domain differs from the preview domain.
learn/faucet.mdx:14still says "Enter your wallet address below" while the widget now accepts only0xEVM addresses. The placeholder and the bech32 hint carry that, but the prose could say "EVM (0x) address" so readers know before they paste asei1…address.- The polling
pollOncecan overlap itself if a/message/:idrequest takes longer than the 3s interval. Harmless for a read-only status poll, but an in-flight guard would avoid piling up requests on a slow faucet. - 7 suggestion(s)/nit(s) flagged inline on specific lines.
| <div className='not-prose w-full flex flex-col gap-4 my-4' style={{ position: 'relative' }}> | ||
| <div | ||
| ref={captchaNode} | ||
| aria-hidden='true' |
There was a problem hiding this comment.
[suggestion] aria-hidden='true' on the element that hCaptcha renders into means the invisible challenge — which is injected as a child of this container when execute() runs — sits inside an aria-hidden subtree, so screen readers won't see the image/audio challenge and can't complete it. The container needs to be hidden visually but not from assistive tech; consider dropping aria-hidden (the container is empty until the challenge opens) rather than hiding the whole subtree.
Separately, worth confirming on the preview build that width: 0; height: 0; overflow: hidden doesn't clip the challenge. hCaptcha positions the overlay fixed, so it should escape the clip as long as no Mintlify ancestor sets transform/filter/contain — but that's exactly the kind of thing only a real render will tell you.
There was a problem hiding this comment.
Fixed in 15a366a — dropped aria-hidden from the container. It stays out of the layout (position: absolute; width: 0; height: 0) but is now in the accessibility tree, so the image/audio challenge is reachable.
On the clipping question: a manual run against the preview build completed the captcha and a token request successfully, so the fixed-positioned overlay does escape the 0×0 clip on the Mintlify page — no ancestor transform/filter/contain in the way.
| } else if (responseJson && responseJson.data && responseJson.data.nextAllowedUseDate) { | ||
| setNextUseTime(responseJson.data.nextAllowedUseDate); | ||
| } else { | ||
| setErrorMsg('Error requesting tokens. Please try again later.'); |
There was a problem hiding this comment.
[suggestion] Any non-success response that isn't a rate limit collapses into the same generic "Error requesting tokens." — including the ones a user could act on, e.g. a rejected captcha token or a rejected address. If the faucet returns a message/error field, surfacing it here (falling back to the generic string) would make failures self-service instead of sending people to Discord. This is a step back from the previous behavior in one respect: the PR moves toast copy inline precisely so it persists, but the persisted copy no longer says what went wrong.
There was a problem hiding this comment.
Fixed in 15a366a. Added a faucetError(payload) helper that reads the first non-empty string among message, error, data.message, data.error, data.reason and falls back to the generic copy only when the response says nothing useful. You are right that this was the one place the inline-banner change regressed against the toasts it replaced.
| const formatNextUseTime = (iso) => { | ||
| const date = new Date(iso); | ||
| const diffMs = date.getTime() - Date.now(); | ||
| if (!isFinite(diffMs) || diffMs <= 0) return 'now'; |
There was a problem hiding this comment.
[nit] If nextAllowedUseDate is missing or unparseable, getTime() is NaN, !isFinite is true, and the banner reads "You can request tokens again in now" — which contradicts the button being disabled. A distinct fallback ("later" / "soon") would read correctly. Also note this string is computed at render time and never re-renders, so a "23h 59m" notice stays frozen while the user sits on the page.
There was a problem hiding this comment.
Fixed in 15a366a, both halves.
The NaN case is now separated from the expiry case and returns "a little while", so it no longer contradicts the disabled button. And the notice is no longer frozen: a 60s interval (only while nextUseTime is set) drives a nowMs state, and once the deadline passes it clears nextUseTime, which re-enables the button rather than stranding the reader on an expired countdown.
| }) | ||
| .catch(() => { | ||
| if (cancelled) return; | ||
| window.__seiHCaptchaPromise = null; |
There was a problem hiding this comment.
[nit] Clearing window.__seiHCaptchaPromise on failure allows a retry, but the script[data-sei-hcaptcha] tag is left in the DOM — so the next attempt takes the document.querySelector branch and polls waitForApi() for the full 15s before failing again, rather than re-requesting the script. Removing the script element here would make the retry meaningful.
There was a problem hiding this comment.
Fixed in 15a366a — the script[data-sei-hcaptcha] tag is now removed alongside the cached promise, so a retry actually re-fetches instead of polling out the full 15s against a script that already failed.
| } | ||
| }; | ||
|
|
||
| const isSubmitDisabled = !!nextUseTime || !isValidAddress || !captchaToken || isPolling || sendingRequest; |
There was a problem hiding this comment.
[nit] Because isSubmitDisabled already covers !captchaToken and !isValidAddress, the two matching guards at the top of handleSubmit (lines 251-258) are unreachable — the button can't be clicked in those states. The flip side is that a user with a valid address and no captcha sees a greyed-out button with no explanation of why. Consider either dropping the dead guards, or keeping the button enabled so the guards can do their job of explaining the blocker.
There was a problem hiding this comment.
Fixed in 15a366a. Took both halves of the suggestion: the unreachable guards are gone, and the card now names the missing precondition ("Complete the captcha verification to enable the request.", or the address hint) so the disabled button is explained rather than silent. Kept the button disabled since that reads better than one that is enabled only to reject you.
| className='flex-1 min-w-0 px-5 py-5 outline-none bg-transparent text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500' | ||
| placeholder='Enter your EVM (0x...) address' | ||
| value={destAddress} | ||
| onChange={handleAddressChange} |
There was a problem hiding this comment.
[nit] The input isn't wrapped in a <form> and has no onKeyDown handler, so pressing Enter after pasting an address does nothing — the user has to mouse over to the buttons. An onKeyDown that fires handleCaptchaVerification (or handleSubmit when already verified) would match the muscle memory for a single-field form.
There was a problem hiding this comment.
Fixed in 15a366a — added an onKeyDown on the input. Enter opens the captcha when the address is valid but unverified, and submits when already verified. No-ops on an invalid address so it cannot fire a request that would just bounce.
| const [requestHover, setRequestHover] = useState(false); | ||
|
|
||
| const trimmed = destAddress.trim(); | ||
| const isValidAddress = /^0x[0-9a-fA-F]{40}$/.test(trimmed); |
There was a problem hiding this comment.
[nit] Worth flagging that this is a slight relaxation, not a like-for-like swap: viem's isAddress validates the EIP-55 checksum by default, so mixed-case addresses with a typo were previously rejected client-side and now pass through to the faucet. Fine if you're happy to let the backend be the authority (and the tokens are worthless testnet SEI), but the PR description presents the regex as an equivalent replacement.
There was a problem hiding this comment.
Fair, and the PR description overstated it — corrected there and in the snippet header, which now says this is a shape test rather than an equivalent swap. EIP-55 verification needs keccak256, which is not reachable from a Mintlify snippet (no npm, and SubtleCrypto has no keccak), so the faucet stays the authority on the address. Happy to accept that for valueless testnet SEI.
Bugbot (medium): a /message/:id response could land after polling had already stopped, so a stale confirmation could appear next to the timeout error, or after the reader had changed the address. stopPolling now bumps a generation counter that pollOnce checks before it writes any state. seidroid: - the hCaptcha container was aria-hidden, which put the image and audio challenge inside a hidden subtree and out of reach of screen readers. It is still visually hidden, but now stays in the accessibility tree. - non-rate-limit failures all collapsed into one generic string, so a rejected captcha or address said nothing actionable. The faucet's own message field is surfaced when it sends one. - an unparseable nextAllowedUseDate rendered "request again in now" while the button stayed disabled; it now reads "a little while". The notice also ticks, and clears itself once the window has passed, instead of freezing at whatever it said on first render. - a failed hCaptcha load left its script tag behind, so the next attempt waited out the full 15s timeout rather than re-fetching. The tag is removed with the cached promise. - the two guards at the top of handleSubmit were unreachable, because the button is disabled in exactly those states. Dropped them, and the card now names the missing precondition instead of showing an unexplained greyed-out button. - Enter in the address field does nothing; it now advances to the captcha, or submits when already verified. - a poll slower than the 3s interval could stack up behind it. Guarded. Also notes in the header comment that the regex is a shape test, not a like-for-like swap for viem's isAddress: EIP-55 needs keccak256, which is not available here, so the faucet stays the authority. learn/faucet.mdx said "wallet address" while the field takes 0x only. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks both — all 8 inline items plus the two summary-level ones are addressed in 15a366a, and I have replied in each thread. Bugbot, medium — stale poll updates UI. seidroid inline
seidroid summary
Snippet verified to compile with |
crate-ci/typos rejects "unparseable" in favour of "unparsable". Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Solid, well-commented migration of the faucet from a third-party iframe to an in-repo Mintlify snippet; the stale-response guards, cleanup paths, and CSP/dependency reasoning are all sound, and I confirmed the iframe-resizer.js and style.css deletions leave no dangling references. Four non-blocking issues, mostly around the rate-limit countdown clock and accessibility of the status banners.
Findings: 0 blocking | 8 non-blocking | 4 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Both configured second-opinion passes produced no output —
codex-review.mdandcursor-review.mdare empty files, andREVIEW_GUIDELINES.mdis also empty, so no repo-specific standards or external findings could be merged into this review. - The snippet carries non-trivial async logic (poll generation tracking, in-flight guard, 5min timeout, hCaptcha lifecycle) but has no automated coverage — verification is manual against a preview build. That matches existing practice for
snippets/*.jsxin this repo, so it's a note rather than a request, but the polling/captcha state machine is the most test-worthy code added tosnippets/so far. llms-full.txt:2905still contains the oldsei-widgets.vercel.app/faucetiframe markup. No action needed —.github/workflows/regenerate-llms.ymlrebuilds it — just flagging so the stale entry isn't mistaken for a missed reference.- The
viemisAddress→ shape-test tradeoff is correctly reasoned and documented in both the PR body and the file header. Agreed it's acceptable for valueless testnet SEI with the faucet as the authority; no change requested. - 4 suggestion(s)/nit(s) flagged inline on specific lines.
| const deadline = new Date(nextUseTime).getTime(); | ||
| const id = setInterval(() => { | ||
| const now = Date.now(); | ||
| setNowMs(now); |
There was a problem hiding this comment.
[suggestion] nowMs is seeded once at mount (useState(() => Date.now())) and only refreshed inside this interval, whose first tick lands 60s after nextUseTime is set. So the rate-limit banner's first render — and its entire first minute on screen — computes diffMs against a clock that is as old as the page session.
Concretely: a reader opens /learn/faucet, reads for 20 minutes, requests tokens, and gets rate-limited with a 2h window. formatNextUseTime returns deadline - nowMs = 2h20m, so the banner claims "2h 20m". Docs pages are long-lived tabs, so the skew can be much larger than that. It always over-states the wait, never under-states it, so it's cosmetic rather than functional — but it's the one number in the widget the reader is asked to act on.
Seeding the clock at the top of the effect body fixes it:
useEffect(() => {
if (!nextUseTime) return;
const deadline = new Date(nextUseTime).getTime();
setNowMs(Date.now()); // don't render the first frame against a mount-time clock
const id = setInterval(() => {
...There was a problem hiding this comment.
Good catch, and my bug — fixed in 8386020. setNowMs(Date.now()) now happens in the same update as setNextUseTime(deadline), so the first paint of the banner is already on a fresh clock and the interval only maintains it from there. Your 20-minute-tab example is exactly right: it would have read "2h 20m" for the first minute.
| setNowMs(now); | ||
| // Clear the notice once the window has passed so the button comes | ||
| // back, instead of stranding the reader on an expired countdown. | ||
| if (isFinite(deadline) && now >= deadline) setNextUseTime(null); |
There was a problem hiding this comment.
[nit] If the faucet returns a nextAllowedUseDate that new Date() can't parse, deadline is NaN, this guard is permanently false, and nextUseTime never self-clears. Since isSubmitDisabled includes !!nextUseTime, the request button stays disabled indefinitely under a banner reading "You can request tokens again in a little while" — the reader's only escape is editing the address field, which isn't discoverable.
The !isFinite(diffMs) branch in formatNextUseTime already anticipates an unparseable date; this path should too. Clearing rather than latching would be consistent:
if (!isFinite(deadline) || now >= deadline) setNextUseTime(null);Or reject the value at the source in handleSubmit before calling setNextUseTime.
There was a problem hiding this comment.
Also mine, also fixed in 8386020 — and I took the stronger version of your suggestion.
Rather than clearing on a NaN deadline, the unreadable date never reaches state at all: nextUseTime is now a parsed timestamp via a parseDeadline() helper, and if the faucet sends something new Date() cannot read we show a plain "Rate limited by the faucet. Try again later." instead. That removes the latch, and it also removes the !isFinite(diffMs) branch you had me add earlier, since the invariant is now enforced where the value enters state rather than re-checked at render.
| if (pollGenRef.current !== generation) return; | ||
| setPollingMessage('Checking transaction status...'); | ||
| } finally { | ||
| pollInFlightRef.current = false; |
There was a problem hiding this comment.
[nit] The finally clears pollInFlightRef unconditionally, including for a poll whose generation is already stale — which can hand a newer polling run an incorrectly-cleared flag.
Sequence: poll A is awaiting fetch, pollInFlightRef === true. The reader edits the address → stopPolling() sets the flag false and bumps the generation. A resubmit calls startPolling, and poll B sets the flag true and awaits. A's fetch now resolves, hits the generation check at line 232, returns — and this finally sets the flag to false while B is still in flight. The next 3s tick then issues a second concurrent /message/:id for the same run, which is exactly what the guard at line 224 exists to prevent.
Self-limiting (one extra request, and stale results are already filtered by generation), but the fix is a one-liner:
} finally {
if (pollGenRef.current === generation) pollInFlightRef.current = false;
}There was a problem hiding this comment.
Correct, and a genuine hole in the fix I pushed for the Bugbot finding — the generation check protected state writes but not the flag itself. Fixed in 8386020:
} finally {
if (pollGenRef.current === generation) pollInFlightRef.current = false;
}A superseded run now leaves the flag to whoever owns the current generation, so your A/B sequence can no longer let the interval issue a second request while B is still awaiting.
|
|
||
| {blockedReason ? <p className='text-sm text-neutral-600 dark:text-neutral-400'>{blockedReason}</p> : null} | ||
|
|
||
| {errorMsg ? ( |
There was a problem hiding this comment.
[nit] None of the four status regions — blockedReason (478), errorMsg (here), the rate-limit notice (486), and the polling/confirmed banner (496) — are announced to assistive tech. A screen-reader user presses "Request SEI", and the only feedback is the button label flipping to "Processing..."; the failure reason, the rate-limit countdown, and the eventual "Transaction confirmed" plus explorer link all appear silently below the focused element.
The captcha-container comment at line 424 shows a11y was deliberately considered here, so this reads as an oversight rather than a decision. Wrapping the banner stack in a live region covers all four at once:
<div role='status' aria-live='polite' className='flex flex-col gap-4'>
{/* blockedReason, errorMsg, nextUseTime, polling/txHash banners */}
</div>There was a problem hiding this comment.
Agreed, and it was an oversight rather than a decision — fixed in 8386020.
All four regions are now inside persistent live regions: role="alert" for errorMsg, role="status" for the blocker hint, the rate-limit notice, and the polling/confirmed banner. They are wrapped rather than annotated in place, because the blocks mount and unmount and a live region that appears together with its own text is not reliably announced — the region has to already exist for the subtree change to be picked up. The wrappers are display: contents so they hold the roles without becoming flex children, leaving the gap-4 layout exactly as it was.
…ixes Three of these were introduced by the previous commit. - nowMs was seeded at mount and only refreshed by the 60s interval, whose first tick lands a minute after the banner appears. A docs page is a long-lived tab, so the first minute of the countdown was off by however long the reader had been on the page. It is now set in the same update as the deadline. - an unreadable nextAllowedUseDate left deadline as NaN, so the expiry guard was never true and the notice never cleared — the button stayed disabled with no way back except editing the address. nextUseTime is now a parsed timestamp, and a date we cannot read never enters state; the reader gets a plain rate-limit message instead. - the finally block released the in-flight flag even for a superseded poll, so a stale run could clear the flag while a newer run was still awaiting, letting the interval fire a second overlapping request. Only the run that still owns the generation releases it now. Also: none of the four status regions were announced to assistive tech, so a screen-reader user got no feedback beyond the button label. Each is now inside a persistent live region — display: contents, so the flex layout is untouched — with role=alert for errors and role=status for the rest. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Solid, well-documented migration of the faucet from a third-party iframe to an in-repo Mintlify snippet; the cleanup of iframe-resizer.js and the style.css aspect-ratio override is complete (no dangling references remain). No blocking correctness or security issues — the remaining findings are edge-case UX/a11y polish in snippets/faucet.jsx.
Findings: 0 blocking | 10 non-blocking | 5 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Both second-opinion passes produced no output:
codex-review.mdandcursor-review.mdare empty, so nothing was merged from Codex or Cursor.REVIEW_GUIDELINES.mdis also empty, so no repo-specific standards were applied beyondAGENTS.md/STYLE_GUIDE.md. - Dropping viem's EIP-55 checksum check is called out in the PR description and is a reasonable trade for a valueless testnet faucet — the shape test plus server-side authority is fine. No objection, just confirming the trade-off was deliberate and documented.
- Verified the removals are safe:
grepfinds no remaining references toiframe-resizerorsei-widgets.vercel.appin MDX/CSS/JS/JSON, so deleting the resizer and theaspect-ratio: autooverride strands nothing. Explorer pathtestnet.seiscan.io/tx/<hash>matches the convention used elsewhere in the repo, and the--sei-*tokens referenced all exist instyle.css. llms-full.txt:2905still contains the oldsei-widgets.vercel.app/faucetiframe markup. PerAGENTS.mdthat file is regenerated by the weeklyregenerate-llms.ymlaction and must not be hand-edited — flagging only so someone can trigger the workflow manually if the stale snippet matters before the next run.- This snippet is now first-party code with a nontrivial state machine (poll generations, in-flight guard, captcha lifecycle) and the repo has no JS test or lint job in CI — verification is manual against the preview build. Consistent with existing practice for
snippets/*.jsx, but worth noting as the snippet directory grows. - 5 suggestion(s)/nit(s) flagged inline on specific lines.
| <div className='flex items-stretch'> | ||
| <button | ||
| type='button' | ||
| onClick={handleCaptchaVerification} |
There was a problem hiding this comment.
[suggestion] The Verify Captcha button is never disabled. Two consequences: (1) clicking it while sendingRequest/isPolling opens a fresh challenge mid-request, and the resulting token is immediately discarded by the resetCaptcha() in handleSubmit; (2) after "Verified" it still calls hcaptcha.execute(id), re-running the challenge on a token that was already good. handleCaptchaVerification guards !captchaReady with an error banner, but a disabled={!captchaReady || !!captchaToken || sendingRequest || isPolling} would make the state visible instead of only reporting it after the click.
There was a problem hiding this comment.
Fixed in 3f7e07c. Added isVerifyDisabled = !captchaReady || !!captchaToken || sendingRequest || isPolling || !!nextUseTime, which covers both cases you list plus the rate-limited one. The !captchaReady error banner stays as the keyboard-reachable explanation for the one state a reader might hit early; the rest are now simply not clickable.
| setCaptchaReady(true); | ||
| }) | ||
| .catch(() => { | ||
| if (cancelled) return; |
There was a problem hiding this comment.
[suggestion] The if (cancelled) return; guard runs before the window.__seiHCaptchaPromise = null reset, so if the component unmounts while the script load is failing (Mintlify client-side nav away during the 15s wait), the rejected promise stays cached on window for the life of the tab. Every later mount then short-circuits to that rejection at loadHCaptcha's if (window.__seiHCaptchaPromise) check and can never re-fetch the script. Moving the promise/tag cleanup above the cancelled check — and keeping only setErrorMsg behind it — makes the retry path survive an unmount.
There was a problem hiding this comment.
Real bug, fixed in 3f7e07c — the cache reset and tag removal moved above the cancelled check, so an unmount during a failing load no longer poisons window.__seiHCaptchaPromise for the life of the tab. Only the setErrorMsg stays behind the guard, which is the one thing that actually needs the component to still be mounted.
| className='flex-1 min-w-0 px-5 py-5 outline-none bg-transparent text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500' | ||
| placeholder='Enter your EVM (0x...) address' | ||
| value={destAddress} | ||
| onChange={handleAddressChange} |
There was a problem hiding this comment.
[nit] The address input stays editable while sendingRequest is true. trimmed is captured in the closure at click time, so a keystroke during the in-flight POST clears txHash/errorMsg and calls stopPolling(), but the response then calls startPolling() with a fresh generation and eventually renders "Transaction confirmed" for an address the field no longer shows. Disabling the input (or the field's reset side effects) while sendingRequest || isPolling would keep the banner and the field in agreement.
There was a problem hiding this comment.
Fixed in 3f7e07c — the input is now disabled while sendingRequest || isPolling. That also removes the reset side effects you point at, since handleAddressChange cannot fire, so the confirmation banner can no longer describe an address the field has stopped showing.
| // Clear the notice once the window has passed so the button comes | ||
| // back, instead of stranding the reader on an expired countdown. | ||
| if (now >= nextUseTime) setNextUseTime(null); | ||
| }, 60000); |
There was a problem hiding this comment.
[nit] The countdown ticks every 60s, which matches the minute-granularity display, but it also gates the setNextUseTime(null) that re-enables the request button. A deadline that expires just after a tick leaves the button disabled and the notice on screen for up to a full extra minute. A shorter interval (or a setTimeout scheduled for the exact deadline alongside the 60s display tick) clears it on time.
There was a problem hiding this comment.
Fixed in 3f7e07c, taking the setTimeout option: the 60s interval now only drives the display, and expiry is a separate timeout scheduled for nextUseTime - Date.now(). The button comes back on the deadline rather than on the next minute boundary.
| its own text — the region has to be there already. These wrappers | ||
| are display: contents, so they hold the roles without taking part | ||
| in the flex layout. */} | ||
| <div style={{ display: 'contents' }} role='status'> |
There was a problem hiding this comment.
[nit] Keeping the live regions permanently mounted is the right call, but display: contents is a risky carrier for an ARIA role — browsers historically dropped display: contents elements from the accessibility tree, and support for preserving roles on them is still uneven. Since the announcements are the whole point of these wrappers, a zero-height wrapper (or hoisting role='status'/role='alert' onto persistent siblings that already participate in the flex layout) would be more dependable than relying on display: contents to keep the region addressable.
There was a problem hiding this comment.
Agreed — that was the weak point of the previous commit and you are right that display: contents has a poor history in the accessibility tree. Changed in 3f7e07c to ordinary always-mounted <div role="status"> / <div role="alert"> wrappers.
The reason for contents was to avoid the parents gap-4adding spacing around empty wrappers. That is solved by moving the spacing off the parent: the parent is now plainflex flex-coland each visible status block carriesmt-4`. An empty wrapper is a zero-height flex item contributing no margin, so the rendered layout is unchanged, and the roles now sit on elements that browsers reliably expose.
- the Verify Captcha button was never disabled, so it could re-run a solved challenge (discarding a good token) or open one mid-request that handleSubmit's resetCaptcha would immediately throw away. - the hCaptcha catch checked for unmount before clearing the cached promise, so navigating away during a failing load left the rejection on window for the life of the tab and no later mount could re-fetch. The cache and script tag are cleared first now. - the address field stayed editable during an in-flight request, so a keystroke could reset the banners while the POST resolved against the address captured at click time, confirming an address no longer shown. Disabled while sending or polling. - re-enabling the request button hung off the 60s display tick, leaving it dead for up to a minute past the deadline. Expiry is its own timeout, scheduled for the deadline itself. - display: contents is a poor carrier for an ARIA role — browsers have historically dropped such elements from the accessibility tree. The live regions are now ordinary always-mounted wrappers, with spacing moved from a parent gap onto the blocks themselves so an empty wrapper still occupies no height. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3f7e07c. Configure here.
| // Separate from the display tick: hanging the re-enable off a 60s | ||
| // interval would leave the button dead for up to a minute past the | ||
| // deadline. This fires on the deadline itself. | ||
| const expiry = setTimeout(() => setNextUseTime(null), Math.max(0, nextUseTime - Date.now())); |
There was a problem hiding this comment.
Rate-limit release ignores wall clock
Medium Severity
The rate-limit unlock is now a one-shot relative setTimeout, while the countdown still compares nextUseTime to wall-clock Date.now(). After system sleep, browsers often freeze that timer, so the notice can read expired while nextUseTime stays set and both request and verify remain disabled.
Reviewed by Cursor Bugbot for commit 3f7e07c. Configure here.
There was a problem hiding this comment.
Solid, well-reasoned migration of the last sei-widgets iframe into an in-repo Mintlify snippet: the lifecycle handling (poll generation guard, in-flight lock, deadline timer, captcha teardown) is unusually careful, and both deletions are safe — I confirmed no remaining references to iframe-resizer.js or to sei-widgets.vercel.app/localhost:3100 iframes. No blockers; a handful of minor polling/UX/a11y nits below.
Findings: 0 blocking | 12 non-blocking | 4 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Both second-opinion passes produced no output —
codex-review.mdandcursor-review.mdare empty files.REVIEW_GUIDELINES.mdis empty too, so no repo-specific standards were applied beyondAGENTS.md. All findings here come from this pass alone. - Verified the two deletions are safe: no file references
iframe-resizer.js, and nosei-widgets.vercel.app/localhost:3100iframe remains. The removed CSS selector was scoped to those hosts, so the surviving iframes (YouTube, symph.ag, seistream) are unaffected. - Design tokens used by the snippet (
--sei-maroon-50/100/200,--sei-gold-100,--sei-live,--sei-font-mono) all exist instyle.css, the explorer host matches the repo'stestnet.seiscan.ioconvention, and the hooks-without-import + inline-icon-component style matches the other snippets. handleSubmitnever consultsresponse.ok— an HTTP-level failure is only distinguishable if the body happens to be JSON with a message field. ThefaucetErrorfallback keeps this from being wrong, but folding the status code in (e.g. a distinct message for 429/5xx) would make faucet outages read as outages rather than as a generic retry prompt.- Dropping viem's EIP-55 checksum check is a real (if small) validation regression, but it's documented in the PR, the faucet remains authoritative, and the
describeBlockerhint catches the commonsei1…paste — reasonable for valueless testnet SEI. - No automated coverage: CI only lints prose, links and spelling, so the whole flow rests on the author's manual preview run. Fine for this repo's conventions — noting it because 565 lines of stateful async code now ship untested.
learn/faucet.mdx:20still states the rate limit as "once per day / wait 24 hours" (pre-existing, outside the diff). Now that the UI surfaces a live countdown fromnextAllowedUseDate, it'd be worth confirming the prose still matches the backend's actual window.- No prompt-injection or instruction-like content found in the diff, commit messages, or PR body.
- 4 suggestion(s)/nit(s) flagged inline on specific lines.
| // Polling stopped while this was in flight — the address | ||
| // changed, or the timeout fired. Its result is stale. | ||
| if (pollGenRef.current !== generation) return; | ||
| if (responseJson && responseJson.status === 'success') { |
There was a problem hiding this comment.
[suggestion] A non-success top-level envelope isn't handled. If /message/:id returns e.g. { status: 'error', message: 'unknown message id' }, this if is skipped and control falls to the generic setPollingMessage('Checking transaction status...') on line 256, so the widget keeps polling every 3s for the full 5-minute timeout before telling the reader anything went wrong.
Worth adding an else if (responseJson && responseJson.status === 'error') that calls stopPolling() and surfaces faucetError(responseJson) — you already have that helper, and it would turn a 5-minute silent spinner into an immediate, specific message.
| // The request button is disabled until every precondition is met, so say | ||
| // which one is missing rather than leaving a greyed-out button unexplained. | ||
| const describeBlocker = () => { | ||
| if (nextUseTime || isPolling || sendingRequest || txHash) return null; |
There was a problem hiding this comment.
[nit] txHash in the early-return means that after a successful request the Request button goes back to disabled (captcha was reset on line 343) with no accompanying hint, since describeBlocker bails here. The enabled Verify Captcha button makes it recoverable, but dropping txHash from this condition — or returning the "Complete the captcha verification" hint when txHash && !captchaToken — would keep the stated goal of "never leave a greyed-out button unexplained" true for the repeat-request case too.
| if (pollInFlightRef.current) return; | ||
| pollInFlightRef.current = true; | ||
| try { | ||
| const response = await fetch(`${FAUCET_API_URL}/message/${messageId}`); |
There was a problem hiding this comment.
[nit] messageId comes straight from the faucet response and is interpolated into the path unencoded; same for txHash in the explorer href on line 549. Not exploitable here (the https:// prefix is fixed, so neither can become a javascript: URL), but encodeURIComponent(...) on both would make a stray ?, # or / in an API value fail loudly instead of silently hitting a different path.
| autoCapitalize='off' | ||
| autoCorrect='off' | ||
| spellCheck={false} | ||
| aria-label='Sei EVM address' |
There was a problem hiding this comment.
[nit] The validation hint rendered at line 512 is announced via role="status", but it isn't programmatically tied to the field it describes. Giving that <p> an id and adding aria-describedby here would let a screen reader surface "Enter a valid EVM address…" when focus lands on the input, not only at the moment the text appears.


Closes PLT-1141
Summary
The faucet was the last page still iframing
sei-widgets.vercel.app. This replaces that embed with an in-repo Mintlify snippet, finishing the widget migration started in 24fb87e.The widgets repo kept
/faucetas an iframe because it imported npm packages that Mintlify snippets cannot bundle (@hcaptcha/react-hcaptcha,viem,sonner). hCaptcha itself was never the blocker — none of those packages are actually required:@hcaptcha/react-hcaptchajs.hcaptcha.com/1/api.jsloaded on mount, driven viawindow.hcaptcha.render/.executeviemisAddress0x+ 40-hex shape test — not a like-for-like swap, see belowsonnertoastsMintlify's default CSP already allowlists
hcaptcha.comand*.hcaptcha.comforscript-src,frame-src,style-srcandconnect-src, so the invisible challenge renders on the docs origin without any CSP or sitekey work.On address validation:
viem'sisAddressalso verifies the EIP-55 checksum, which needs keccak256 and is not reachable from a snippet (no npm, and SubtleCrypto has no keccak). So a mixed-case address with a typo now reaches the faucet instead of being caught client-side, and the faucet is the authority on whether an address is real. Acceptable for valueless testnet SEI.Changes
snippets/faucet.jsx(new) — the faucet UI: address input, invisible hCaptcha, request button, rate-limit notice, tx polling, and an explorer link. Uses only browser globals and the--sei-*design tokens, matching the other snippets.learn/faucet.mdx— imports<Faucet />instead of the iframe.iframe-resizer.js(deleted) — thepostMessageheight bridge existed only for the sei-widgets iframes.style.css— drops theaspect-ratio: autooverride that unpicked Mintlify's 16:9 iframe wrapper for those same embeds.The faucet backend is untouched: requests still
POSTtofaucet-v3.seinetwork.io/atlantic-2and poll/message/:iduntil a tx hash comes back.Behavior notes
sei1…paste gets a specific hint rather than a generic invalid-address error.message, that is shown instead of generic copy.Testing
Manually exercised against the preview build: captcha completed, tokens requested, tx hash and explorer link resolved. This also confirms the invisible-challenge overlay escapes the 0×0 container clip on a Mintlify page.
Follow-up
Once this lands,
sei-docs-widgetshas no remaining routes and can be archived.