Skip to content

feat(ui): give the approval gate a surface and a confirmation beat (#290) - #488

Merged
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page
Jul 31, 2026
Merged

feat(ui): give the approval gate a surface and a confirmation beat (#290)#488
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Sign-up ends on /pending, and the page was 42 lines with no container — a sprout, an <h1>, a paragraph and a button floating directly on a radial gradient. Beta glow into silence, with no "you're in" moment anywhere in it.

What changed

A surface. The content now sits on a real .card. .card supplies bg/border/radius/shadow but no padding — that's the caller's job — so the card sets var(--pad-lg).

A confirmation beat. The sapling draws itself once, then the message steps in behind it. Everything settles by ~540ms: single pass, no loop.

Copy that does the confirmation work the layout can't. "Your account is ready" states what actually happened; "We'll email you" names the channel that the old "We'll reach out" left vague.

Why CSS and not JS

globals.css already carries a global prefers-reduced-motion reset, so a CSS entrance is automatically safe — a JS-driven one would have to re-implement that guard itself.

It reuses the motion vocabulary that already exists (the fade-in/slide-up keyframes and the .anim-d* delay utilities) instead of inventing a parallel one. The new .pending-* rules are declared above.anim-d* deliberately: the animation shorthand resets animation-delay, so the delay utilities have to come later to win.

The sprout's stroke carries pathLength={1} so the draw keyframe can dash it without measuring, and its three subpaths run stem-then-veins — so it reads as the sapling growing rather than a generic fade.

Anchors

data-testid="pending-gate" and data-testid="pending-signout" are unchanged. Both are already registered in docs/frontend-testids.md (surface table and inventory) and app/pending/page.tsx is already in the eslint enforcement list, so neither half needed updating.

src/app/pending/page.test.tsx (new, red-first) pins the anchors, the sign-out behaviour, the card surface, and the beat's finiteness — it fails if a future pass introduces an infinite animation or drops a fill mode from a delayed step.

Gates

  • tsc --noEmit — clean
  • npm run lint — 0 errors (36 pre-existing warnings)
  • npx vitest run — 56 files, 403 tests passed
  • Full local e2e cycle — recorded in a comment below

part of #290

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fef0d5c-1f4b-405a-873f-078171d84a82

📥 Commits

Reviewing files that changed from the base of the PR and between 162078a and 30d38f7.

📒 Files selected for processing (3)
  • frontend/src/app/globals.css
  • frontend/src/app/pending/page.test.tsx
  • frontend/src/app/pending/page.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging30d38f7Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:08 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Automated review pass (Claude Code). Verified findings only — overall this is a careful, well-tested change: scope matches #290 exactly, middleware gating is untouched (middleware.ts:91 still bounces unapproved users; /pending stays outside the protected matcher), the .pending-*-before-.anim-d* cascade reasoning is correct and documented, testids are pre-registered, and 4/4 unit tests + tsc + eslint reproduce clean at head.

Should fix before merge

  1. pending/page.tsx:100 — the copy promises an email the system never sends. "We'll email you as soon as your access is approved" names a specific channel, but approval is PATCH /users/{user_id}/approve (backend/routes/admin.py), which only flips is_approved — there is no email-sending code anywhere in the repo (verified: the only smtp/mailer-ish grep hits are SESSION_SECRET false positives). If admins manually email approved users, ignore this; otherwise soften to "We'll let you know…" — a user waiting on an email that never arrives is worse than vague copy.

Minor

  1. globals.css:511-518 — the reduced-motion reset zeroes animation-duration but not animation-delay, so under reduced motion the both-fill steps still stagger-reveal over ~240ms (held invisible, then snapping in). Consistent with existing .fade-up surfaces, but the comments claiming the sequence fully collapses overstate it. Cheap global fix: add animation-delay: 0.01ms !important to the reset.
  2. pending/page.tsx:102-108 — the sole interactive element (.btn, ~37px) is under the 44px touch-target convention. Pre-existing class, not a regression, but this page is likely hit on mobile right after sign-up; a min-height on this instance is a cheap win.

Follow-up worth an issue (pre-existing, not this PR): nothing on /pending ever re-checks approval — an approved user with the tab open (or even refreshing /pending) stays on the waitlist screen until they manually navigate elsewhere. A one-shot "check again" against /api/auth/me would close the loop the confirmation beat opens. Also: no e2e journey directly visits /pending (only negative-space assertions); a seed-unapproved → pending-gate → sign-out journey would make coverage direct.

Verdict: ready to merge once the email-copy question is resolved.

AndresL230and others added 2 commits July 30, 2026 20:04
)
Sign-up ENDS on /pending, and the page was 42 lines with no container: a
sprout, an h1, a paragraph and a button floating directly on a radial
gradient. Beta glow into silence, with no "you're in" moment anywhere in it.
The content now sits on a real `.card` surface (which supplies bg/border/
radius/shadow but no padding — that is the caller's job, so the card sets
`var(--pad-lg)`), and the screen plays a one-shot confirmation beat: the
sapling draws itself, then the message steps in behind it.
The beat is CSS, not JS, and that is the point. globals.css already carries a
global prefers-reduced-motion reset, so a CSS entrance is automatically safe
where a JS-driven one would have to re-implement that guard. It reuses the
motion vocabulary that already exists (`fade-in`/`slide-up` keyframes, the
`.anim-d*` delay utilities) rather than inventing a parallel one; the new
`.pending-*` rules are declared ABOVE `.anim-d*` on purpose, since the
animation shorthand resets animation-delay and the delay utilities have to
win. The sprout's stroke uses `pathLength={1}` so the draw keyframe can dash
it without measuring, and its three subpaths run stem-then-veins so it reads
as the sapling growing rather than a generic fade. Everything settles by
~540ms, single pass, no loop.
Copy does the confirmation work the layout can't: "Your account is ready"
states what actually happened, and "We'll email you" names the channel the
old "We'll reach out" left vague.
Both E2E anchors — `pending-gate` and `pending-signout` — are unchanged, and
the new page.test.tsx pins them, the card surface, and the beat's finiteness
so a future pass can't quietly reintroduce an infinite animation.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the original copy. Issue #290's proposal says "Keep the copy" in so
many words, and the previous revision rewrote both the heading and the body.
The rewrite may still be worth making, but it is a separate call and not one
this PR was scoped to take.
Make the sprout actually sequence. The stem and the two leaf veins were three
subpaths of ONE path sharing a single dash animation, and a dash pattern
restarts its phase at every `M` — so all three drew simultaneously, which is
the opposite of what the comment claimed. Verified in headless Chromium
during review. They are now two separate paths on staggered delays, so the
stem really does lead and the leaves follow.
Split animation-fill-mode out of the shorthand. f4ac696 did exactly this to
.fade-up ("split animation-fill-mode both so stagger works with CSS var
timing") 20 minutes after the stagger utilities shipped, and every rule added
since has avoided var()-timing plus a trailing keyword in one shorthand. It
resolves correctly in Chromium — which is the only engine the e2e lane runs —
but this file's own history says not to write it that way.
Beat now settles at ~560ms (was ~540ms), still inside the issue's 600ms cap.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/290-pending-page branch from bce12e3 to 30d38f7CompareJuly 31, 2026 03:04
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Local e2e cycle — green

Full cycle inside one flock of the stack lock, at 30d38f7 (rebased on main), SAPLING_MODEL_MODE=function.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Gates: tsc --noEmit clean · npm run lint 0 errors · npx vitest run 56 files / 403 tests.

Note there is no journey that drives /pending directly (only incidental references in auth-session.spec.ts and smoke.spec.ts), so the cycle here is a regression check that the globals.css additions didn't disturb anything else — the beat itself is covered by page.test.tsx, which pins its finiteness rather than its appearance.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): give the approval gate a surface and a confirmation beat (#290) by AndresL230 · Pull Request #488 · SaplingLearn/Sapling · GitHub
Skip to content

feat(ui): give the approval gate a surface and a confirmation beat (#290) - #488

Merged
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page
Jul 31, 2026
Merged

feat(ui): give the approval gate a surface and a confirmation beat (#290)#488
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Sign-up ends on /pending, and the page was 42 lines with no container — a sprout, an <h1>, a paragraph and a button floating directly on a radial gradient. Beta glow into silence, with no "you're in" moment anywhere in it.

What changed

A surface. The content now sits on a real .card. .card supplies bg/border/radius/shadow but no padding — that's the caller's job — so the card sets var(--pad-lg).

A confirmation beat. The sapling draws itself once, then the message steps in behind it. Everything settles by ~540ms: single pass, no loop.

Copy that does the confirmation work the layout can't. "Your account is ready" states what actually happened; "We'll email you" names the channel that the old "We'll reach out" left vague.

Why CSS and not JS

globals.css already carries a global prefers-reduced-motion reset, so a CSS entrance is automatically safe — a JS-driven one would have to re-implement that guard itself.

It reuses the motion vocabulary that already exists (the fade-in/slide-up keyframes and the .anim-d* delay utilities) instead of inventing a parallel one. The new .pending-* rules are declared above.anim-d* deliberately: the animation shorthand resets animation-delay, so the delay utilities have to come later to win.

The sprout's stroke carries pathLength={1} so the draw keyframe can dash it without measuring, and its three subpaths run stem-then-veins — so it reads as the sapling growing rather than a generic fade.

Anchors

data-testid="pending-gate" and data-testid="pending-signout" are unchanged. Both are already registered in docs/frontend-testids.md (surface table and inventory) and app/pending/page.tsx is already in the eslint enforcement list, so neither half needed updating.

src/app/pending/page.test.tsx (new, red-first) pins the anchors, the sign-out behaviour, the card surface, and the beat's finiteness — it fails if a future pass introduces an infinite animation or drops a fill mode from a delayed step.

Gates

  • tsc --noEmit — clean
  • npm run lint — 0 errors (36 pre-existing warnings)
  • npx vitest run — 56 files, 403 tests passed
  • Full local e2e cycle — recorded in a comment below

part of #290

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fef0d5c-1f4b-405a-873f-078171d84a82

📥 Commits

Reviewing files that changed from the base of the PR and between 162078a and 30d38f7.

📒 Files selected for processing (3)
  • frontend/src/app/globals.css
  • frontend/src/app/pending/page.test.tsx
  • frontend/src/app/pending/page.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging30d38f7Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:08 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Automated review pass (Claude Code). Verified findings only — overall this is a careful, well-tested change: scope matches #290 exactly, middleware gating is untouched (middleware.ts:91 still bounces unapproved users; /pending stays outside the protected matcher), the .pending-*-before-.anim-d* cascade reasoning is correct and documented, testids are pre-registered, and 4/4 unit tests + tsc + eslint reproduce clean at head.

Should fix before merge

  1. pending/page.tsx:100 — the copy promises an email the system never sends. "We'll email you as soon as your access is approved" names a specific channel, but approval is PATCH /users/{user_id}/approve (backend/routes/admin.py), which only flips is_approved — there is no email-sending code anywhere in the repo (verified: the only smtp/mailer-ish grep hits are SESSION_SECRET false positives). If admins manually email approved users, ignore this; otherwise soften to "We'll let you know…" — a user waiting on an email that never arrives is worse than vague copy.

Minor

  1. globals.css:511-518 — the reduced-motion reset zeroes animation-duration but not animation-delay, so under reduced motion the both-fill steps still stagger-reveal over ~240ms (held invisible, then snapping in). Consistent with existing .fade-up surfaces, but the comments claiming the sequence fully collapses overstate it. Cheap global fix: add animation-delay: 0.01ms !important to the reset.
  2. pending/page.tsx:102-108 — the sole interactive element (.btn, ~37px) is under the 44px touch-target convention. Pre-existing class, not a regression, but this page is likely hit on mobile right after sign-up; a min-height on this instance is a cheap win.

Follow-up worth an issue (pre-existing, not this PR): nothing on /pending ever re-checks approval — an approved user with the tab open (or even refreshing /pending) stays on the waitlist screen until they manually navigate elsewhere. A one-shot "check again" against /api/auth/me would close the loop the confirmation beat opens. Also: no e2e journey directly visits /pending (only negative-space assertions); a seed-unapproved → pending-gate → sign-out journey would make coverage direct.

Verdict: ready to merge once the email-copy question is resolved.

AndresL230and others added 2 commits July 30, 2026 20:04
)
Sign-up ENDS on /pending, and the page was 42 lines with no container: a
sprout, an h1, a paragraph and a button floating directly on a radial
gradient. Beta glow into silence, with no "you're in" moment anywhere in it.
The content now sits on a real `.card` surface (which supplies bg/border/
radius/shadow but no padding — that is the caller's job, so the card sets
`var(--pad-lg)`), and the screen plays a one-shot confirmation beat: the
sapling draws itself, then the message steps in behind it.
The beat is CSS, not JS, and that is the point. globals.css already carries a
global prefers-reduced-motion reset, so a CSS entrance is automatically safe
where a JS-driven one would have to re-implement that guard. It reuses the
motion vocabulary that already exists (`fade-in`/`slide-up` keyframes, the
`.anim-d*` delay utilities) rather than inventing a parallel one; the new
`.pending-*` rules are declared ABOVE `.anim-d*` on purpose, since the
animation shorthand resets animation-delay and the delay utilities have to
win. The sprout's stroke uses `pathLength={1}` so the draw keyframe can dash
it without measuring, and its three subpaths run stem-then-veins so it reads
as the sapling growing rather than a generic fade. Everything settles by
~540ms, single pass, no loop.
Copy does the confirmation work the layout can't: "Your account is ready"
states what actually happened, and "We'll email you" names the channel the
old "We'll reach out" left vague.
Both E2E anchors — `pending-gate` and `pending-signout` — are unchanged, and
the new page.test.tsx pins them, the card surface, and the beat's finiteness
so a future pass can't quietly reintroduce an infinite animation.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the original copy. Issue #290's proposal says "Keep the copy" in so
many words, and the previous revision rewrote both the heading and the body.
The rewrite may still be worth making, but it is a separate call and not one
this PR was scoped to take.
Make the sprout actually sequence. The stem and the two leaf veins were three
subpaths of ONE path sharing a single dash animation, and a dash pattern
restarts its phase at every `M` — so all three drew simultaneously, which is
the opposite of what the comment claimed. Verified in headless Chromium
during review. They are now two separate paths on staggered delays, so the
stem really does lead and the leaves follow.
Split animation-fill-mode out of the shorthand. f4ac696 did exactly this to
.fade-up ("split animation-fill-mode both so stagger works with CSS var
timing") 20 minutes after the stagger utilities shipped, and every rule added
since has avoided var()-timing plus a trailing keyword in one shorthand. It
resolves correctly in Chromium — which is the only engine the e2e lane runs —
but this file's own history says not to write it that way.
Beat now settles at ~560ms (was ~540ms), still inside the issue's 600ms cap.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/290-pending-page branch from bce12e3 to 30d38f7CompareJuly 31, 2026 03:04
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Local e2e cycle — green

Full cycle inside one flock of the stack lock, at 30d38f7 (rebased on main), SAPLING_MODEL_MODE=function.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Gates: tsc --noEmit clean · npm run lint 0 errors · npx vitest run 56 files / 403 tests.

Note there is no journey that drives /pending directly (only incidental references in auth-session.spec.ts and smoke.spec.ts), so the cycle here is a regression check that the globals.css additions didn't disturb anything else — the beat itself is covered by page.test.tsx, which pins its finiteness rather than its appearance.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): give the approval gate a surface and a confirmation beat (#290) by AndresL230 · Pull Request #488 · SaplingLearn/Sapling · GitHub
Skip to content

feat(ui): give the approval gate a surface and a confirmation beat (#290) - #488

Merged
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page
Jul 31, 2026
Merged

feat(ui): give the approval gate a surface and a confirmation beat (#290)#488
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Sign-up ends on /pending, and the page was 42 lines with no container — a sprout, an <h1>, a paragraph and a button floating directly on a radial gradient. Beta glow into silence, with no "you're in" moment anywhere in it.

What changed

A surface. The content now sits on a real .card. .card supplies bg/border/radius/shadow but no padding — that's the caller's job — so the card sets var(--pad-lg).

A confirmation beat. The sapling draws itself once, then the message steps in behind it. Everything settles by ~540ms: single pass, no loop.

Copy that does the confirmation work the layout can't. "Your account is ready" states what actually happened; "We'll email you" names the channel that the old "We'll reach out" left vague.

Why CSS and not JS

globals.css already carries a global prefers-reduced-motion reset, so a CSS entrance is automatically safe — a JS-driven one would have to re-implement that guard itself.

It reuses the motion vocabulary that already exists (the fade-in/slide-up keyframes and the .anim-d* delay utilities) instead of inventing a parallel one. The new .pending-* rules are declared above.anim-d* deliberately: the animation shorthand resets animation-delay, so the delay utilities have to come later to win.

The sprout's stroke carries pathLength={1} so the draw keyframe can dash it without measuring, and its three subpaths run stem-then-veins — so it reads as the sapling growing rather than a generic fade.

Anchors

data-testid="pending-gate" and data-testid="pending-signout" are unchanged. Both are already registered in docs/frontend-testids.md (surface table and inventory) and app/pending/page.tsx is already in the eslint enforcement list, so neither half needed updating.

src/app/pending/page.test.tsx (new, red-first) pins the anchors, the sign-out behaviour, the card surface, and the beat's finiteness — it fails if a future pass introduces an infinite animation or drops a fill mode from a delayed step.

Gates

  • tsc --noEmit — clean
  • npm run lint — 0 errors (36 pre-existing warnings)
  • npx vitest run — 56 files, 403 tests passed
  • Full local e2e cycle — recorded in a comment below

part of #290

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fef0d5c-1f4b-405a-873f-078171d84a82

📥 Commits

Reviewing files that changed from the base of the PR and between 162078a and 30d38f7.

📒 Files selected for processing (3)
  • frontend/src/app/globals.css
  • frontend/src/app/pending/page.test.tsx
  • frontend/src/app/pending/page.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging30d38f7Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:08 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Automated review pass (Claude Code). Verified findings only — overall this is a careful, well-tested change: scope matches #290 exactly, middleware gating is untouched (middleware.ts:91 still bounces unapproved users; /pending stays outside the protected matcher), the .pending-*-before-.anim-d* cascade reasoning is correct and documented, testids are pre-registered, and 4/4 unit tests + tsc + eslint reproduce clean at head.

Should fix before merge

  1. pending/page.tsx:100 — the copy promises an email the system never sends. "We'll email you as soon as your access is approved" names a specific channel, but approval is PATCH /users/{user_id}/approve (backend/routes/admin.py), which only flips is_approved — there is no email-sending code anywhere in the repo (verified: the only smtp/mailer-ish grep hits are SESSION_SECRET false positives). If admins manually email approved users, ignore this; otherwise soften to "We'll let you know…" — a user waiting on an email that never arrives is worse than vague copy.

Minor

  1. globals.css:511-518 — the reduced-motion reset zeroes animation-duration but not animation-delay, so under reduced motion the both-fill steps still stagger-reveal over ~240ms (held invisible, then snapping in). Consistent with existing .fade-up surfaces, but the comments claiming the sequence fully collapses overstate it. Cheap global fix: add animation-delay: 0.01ms !important to the reset.
  2. pending/page.tsx:102-108 — the sole interactive element (.btn, ~37px) is under the 44px touch-target convention. Pre-existing class, not a regression, but this page is likely hit on mobile right after sign-up; a min-height on this instance is a cheap win.

Follow-up worth an issue (pre-existing, not this PR): nothing on /pending ever re-checks approval — an approved user with the tab open (or even refreshing /pending) stays on the waitlist screen until they manually navigate elsewhere. A one-shot "check again" against /api/auth/me would close the loop the confirmation beat opens. Also: no e2e journey directly visits /pending (only negative-space assertions); a seed-unapproved → pending-gate → sign-out journey would make coverage direct.

Verdict: ready to merge once the email-copy question is resolved.

AndresL230and others added 2 commits July 30, 2026 20:04
)
Sign-up ENDS on /pending, and the page was 42 lines with no container: a
sprout, an h1, a paragraph and a button floating directly on a radial
gradient. Beta glow into silence, with no "you're in" moment anywhere in it.
The content now sits on a real `.card` surface (which supplies bg/border/
radius/shadow but no padding — that is the caller's job, so the card sets
`var(--pad-lg)`), and the screen plays a one-shot confirmation beat: the
sapling draws itself, then the message steps in behind it.
The beat is CSS, not JS, and that is the point. globals.css already carries a
global prefers-reduced-motion reset, so a CSS entrance is automatically safe
where a JS-driven one would have to re-implement that guard. It reuses the
motion vocabulary that already exists (`fade-in`/`slide-up` keyframes, the
`.anim-d*` delay utilities) rather than inventing a parallel one; the new
`.pending-*` rules are declared ABOVE `.anim-d*` on purpose, since the
animation shorthand resets animation-delay and the delay utilities have to
win. The sprout's stroke uses `pathLength={1}` so the draw keyframe can dash
it without measuring, and its three subpaths run stem-then-veins so it reads
as the sapling growing rather than a generic fade. Everything settles by
~540ms, single pass, no loop.
Copy does the confirmation work the layout can't: "Your account is ready"
states what actually happened, and "We'll email you" names the channel the
old "We'll reach out" left vague.
Both E2E anchors — `pending-gate` and `pending-signout` — are unchanged, and
the new page.test.tsx pins them, the card surface, and the beat's finiteness
so a future pass can't quietly reintroduce an infinite animation.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the original copy. Issue #290's proposal says "Keep the copy" in so
many words, and the previous revision rewrote both the heading and the body.
The rewrite may still be worth making, but it is a separate call and not one
this PR was scoped to take.
Make the sprout actually sequence. The stem and the two leaf veins were three
subpaths of ONE path sharing a single dash animation, and a dash pattern
restarts its phase at every `M` — so all three drew simultaneously, which is
the opposite of what the comment claimed. Verified in headless Chromium
during review. They are now two separate paths on staggered delays, so the
stem really does lead and the leaves follow.
Split animation-fill-mode out of the shorthand. f4ac696 did exactly this to
.fade-up ("split animation-fill-mode both so stagger works with CSS var
timing") 20 minutes after the stagger utilities shipped, and every rule added
since has avoided var()-timing plus a trailing keyword in one shorthand. It
resolves correctly in Chromium — which is the only engine the e2e lane runs —
but this file's own history says not to write it that way.
Beat now settles at ~560ms (was ~540ms), still inside the issue's 600ms cap.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/290-pending-page branch from bce12e3 to 30d38f7CompareJuly 31, 2026 03:04
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Local e2e cycle — green

Full cycle inside one flock of the stack lock, at 30d38f7 (rebased on main), SAPLING_MODEL_MODE=function.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Gates: tsc --noEmit clean · npm run lint 0 errors · npx vitest run 56 files / 403 tests.

Note there is no journey that drives /pending directly (only incidental references in auth-session.spec.ts and smoke.spec.ts), so the cycle here is a regression check that the globals.css additions didn't disturb anything else — the beat itself is covered by page.test.tsx, which pins its finiteness rather than its appearance.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): give the approval gate a surface and a confirmation beat (#290) by AndresL230 · Pull Request #488 · SaplingLearn/Sapling · GitHub
Skip to content

feat(ui): give the approval gate a surface and a confirmation beat (#290) - #488

Merged
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page
Jul 31, 2026
Merged

feat(ui): give the approval gate a surface and a confirmation beat (#290)#488
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Sign-up ends on /pending, and the page was 42 lines with no container — a sprout, an <h1>, a paragraph and a button floating directly on a radial gradient. Beta glow into silence, with no "you're in" moment anywhere in it.

What changed

A surface. The content now sits on a real .card. .card supplies bg/border/radius/shadow but no padding — that's the caller's job — so the card sets var(--pad-lg).

A confirmation beat. The sapling draws itself once, then the message steps in behind it. Everything settles by ~540ms: single pass, no loop.

Copy that does the confirmation work the layout can't. "Your account is ready" states what actually happened; "We'll email you" names the channel that the old "We'll reach out" left vague.

Why CSS and not JS

globals.css already carries a global prefers-reduced-motion reset, so a CSS entrance is automatically safe — a JS-driven one would have to re-implement that guard itself.

It reuses the motion vocabulary that already exists (the fade-in/slide-up keyframes and the .anim-d* delay utilities) instead of inventing a parallel one. The new .pending-* rules are declared above.anim-d* deliberately: the animation shorthand resets animation-delay, so the delay utilities have to come later to win.

The sprout's stroke carries pathLength={1} so the draw keyframe can dash it without measuring, and its three subpaths run stem-then-veins — so it reads as the sapling growing rather than a generic fade.

Anchors

data-testid="pending-gate" and data-testid="pending-signout" are unchanged. Both are already registered in docs/frontend-testids.md (surface table and inventory) and app/pending/page.tsx is already in the eslint enforcement list, so neither half needed updating.

src/app/pending/page.test.tsx (new, red-first) pins the anchors, the sign-out behaviour, the card surface, and the beat's finiteness — it fails if a future pass introduces an infinite animation or drops a fill mode from a delayed step.

Gates

  • tsc --noEmit — clean
  • npm run lint — 0 errors (36 pre-existing warnings)
  • npx vitest run — 56 files, 403 tests passed
  • Full local e2e cycle — recorded in a comment below

part of #290

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fef0d5c-1f4b-405a-873f-078171d84a82

📥 Commits

Reviewing files that changed from the base of the PR and between 162078a and 30d38f7.

📒 Files selected for processing (3)
  • frontend/src/app/globals.css
  • frontend/src/app/pending/page.test.tsx
  • frontend/src/app/pending/page.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging30d38f7Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:08 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Automated review pass (Claude Code). Verified findings only — overall this is a careful, well-tested change: scope matches #290 exactly, middleware gating is untouched (middleware.ts:91 still bounces unapproved users; /pending stays outside the protected matcher), the .pending-*-before-.anim-d* cascade reasoning is correct and documented, testids are pre-registered, and 4/4 unit tests + tsc + eslint reproduce clean at head.

Should fix before merge

  1. pending/page.tsx:100 — the copy promises an email the system never sends. "We'll email you as soon as your access is approved" names a specific channel, but approval is PATCH /users/{user_id}/approve (backend/routes/admin.py), which only flips is_approved — there is no email-sending code anywhere in the repo (verified: the only smtp/mailer-ish grep hits are SESSION_SECRET false positives). If admins manually email approved users, ignore this; otherwise soften to "We'll let you know…" — a user waiting on an email that never arrives is worse than vague copy.

Minor

  1. globals.css:511-518 — the reduced-motion reset zeroes animation-duration but not animation-delay, so under reduced motion the both-fill steps still stagger-reveal over ~240ms (held invisible, then snapping in). Consistent with existing .fade-up surfaces, but the comments claiming the sequence fully collapses overstate it. Cheap global fix: add animation-delay: 0.01ms !important to the reset.
  2. pending/page.tsx:102-108 — the sole interactive element (.btn, ~37px) is under the 44px touch-target convention. Pre-existing class, not a regression, but this page is likely hit on mobile right after sign-up; a min-height on this instance is a cheap win.

Follow-up worth an issue (pre-existing, not this PR): nothing on /pending ever re-checks approval — an approved user with the tab open (or even refreshing /pending) stays on the waitlist screen until they manually navigate elsewhere. A one-shot "check again" against /api/auth/me would close the loop the confirmation beat opens. Also: no e2e journey directly visits /pending (only negative-space assertions); a seed-unapproved → pending-gate → sign-out journey would make coverage direct.

Verdict: ready to merge once the email-copy question is resolved.

AndresL230and others added 2 commits July 30, 2026 20:04
)
Sign-up ENDS on /pending, and the page was 42 lines with no container: a
sprout, an h1, a paragraph and a button floating directly on a radial
gradient. Beta glow into silence, with no "you're in" moment anywhere in it.
The content now sits on a real `.card` surface (which supplies bg/border/
radius/shadow but no padding — that is the caller's job, so the card sets
`var(--pad-lg)`), and the screen plays a one-shot confirmation beat: the
sapling draws itself, then the message steps in behind it.
The beat is CSS, not JS, and that is the point. globals.css already carries a
global prefers-reduced-motion reset, so a CSS entrance is automatically safe
where a JS-driven one would have to re-implement that guard. It reuses the
motion vocabulary that already exists (`fade-in`/`slide-up` keyframes, the
`.anim-d*` delay utilities) rather than inventing a parallel one; the new
`.pending-*` rules are declared ABOVE `.anim-d*` on purpose, since the
animation shorthand resets animation-delay and the delay utilities have to
win. The sprout's stroke uses `pathLength={1}` so the draw keyframe can dash
it without measuring, and its three subpaths run stem-then-veins so it reads
as the sapling growing rather than a generic fade. Everything settles by
~540ms, single pass, no loop.
Copy does the confirmation work the layout can't: "Your account is ready"
states what actually happened, and "We'll email you" names the channel the
old "We'll reach out" left vague.
Both E2E anchors — `pending-gate` and `pending-signout` — are unchanged, and
the new page.test.tsx pins them, the card surface, and the beat's finiteness
so a future pass can't quietly reintroduce an infinite animation.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the original copy. Issue #290's proposal says "Keep the copy" in so
many words, and the previous revision rewrote both the heading and the body.
The rewrite may still be worth making, but it is a separate call and not one
this PR was scoped to take.
Make the sprout actually sequence. The stem and the two leaf veins were three
subpaths of ONE path sharing a single dash animation, and a dash pattern
restarts its phase at every `M` — so all three drew simultaneously, which is
the opposite of what the comment claimed. Verified in headless Chromium
during review. They are now two separate paths on staggered delays, so the
stem really does lead and the leaves follow.
Split animation-fill-mode out of the shorthand. f4ac696 did exactly this to
.fade-up ("split animation-fill-mode both so stagger works with CSS var
timing") 20 minutes after the stagger utilities shipped, and every rule added
since has avoided var()-timing plus a trailing keyword in one shorthand. It
resolves correctly in Chromium — which is the only engine the e2e lane runs —
but this file's own history says not to write it that way.
Beat now settles at ~560ms (was ~540ms), still inside the issue's 600ms cap.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/290-pending-page branch from bce12e3 to 30d38f7CompareJuly 31, 2026 03:04
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Local e2e cycle — green

Full cycle inside one flock of the stack lock, at 30d38f7 (rebased on main), SAPLING_MODEL_MODE=function.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Gates: tsc --noEmit clean · npm run lint 0 errors · npx vitest run 56 files / 403 tests.

Note there is no journey that drives /pending directly (only incidental references in auth-session.spec.ts and smoke.spec.ts), so the cycle here is a regression check that the globals.css additions didn't disturb anything else — the beat itself is covered by page.test.tsx, which pins its finiteness rather than its appearance.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): give the approval gate a surface and a confirmation beat (#290) by AndresL230 · Pull Request #488 · SaplingLearn/Sapling · GitHub
Skip to content

feat(ui): give the approval gate a surface and a confirmation beat (#290) - #488

Merged
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page
Jul 31, 2026
Merged

feat(ui): give the approval gate a surface and a confirmation beat (#290)#488
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Sign-up ends on /pending, and the page was 42 lines with no container — a sprout, an <h1>, a paragraph and a button floating directly on a radial gradient. Beta glow into silence, with no "you're in" moment anywhere in it.

What changed

A surface. The content now sits on a real .card. .card supplies bg/border/radius/shadow but no padding — that's the caller's job — so the card sets var(--pad-lg).

A confirmation beat. The sapling draws itself once, then the message steps in behind it. Everything settles by ~540ms: single pass, no loop.

Copy that does the confirmation work the layout can't. "Your account is ready" states what actually happened; "We'll email you" names the channel that the old "We'll reach out" left vague.

Why CSS and not JS

globals.css already carries a global prefers-reduced-motion reset, so a CSS entrance is automatically safe — a JS-driven one would have to re-implement that guard itself.

It reuses the motion vocabulary that already exists (the fade-in/slide-up keyframes and the .anim-d* delay utilities) instead of inventing a parallel one. The new .pending-* rules are declared above.anim-d* deliberately: the animation shorthand resets animation-delay, so the delay utilities have to come later to win.

The sprout's stroke carries pathLength={1} so the draw keyframe can dash it without measuring, and its three subpaths run stem-then-veins — so it reads as the sapling growing rather than a generic fade.

Anchors

data-testid="pending-gate" and data-testid="pending-signout" are unchanged. Both are already registered in docs/frontend-testids.md (surface table and inventory) and app/pending/page.tsx is already in the eslint enforcement list, so neither half needed updating.

src/app/pending/page.test.tsx (new, red-first) pins the anchors, the sign-out behaviour, the card surface, and the beat's finiteness — it fails if a future pass introduces an infinite animation or drops a fill mode from a delayed step.

Gates

  • tsc --noEmit — clean
  • npm run lint — 0 errors (36 pre-existing warnings)
  • npx vitest run — 56 files, 403 tests passed
  • Full local e2e cycle — recorded in a comment below

part of #290

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fef0d5c-1f4b-405a-873f-078171d84a82

📥 Commits

Reviewing files that changed from the base of the PR and between 162078a and 30d38f7.

📒 Files selected for processing (3)
  • frontend/src/app/globals.css
  • frontend/src/app/pending/page.test.tsx
  • frontend/src/app/pending/page.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging30d38f7Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:08 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Automated review pass (Claude Code). Verified findings only — overall this is a careful, well-tested change: scope matches #290 exactly, middleware gating is untouched (middleware.ts:91 still bounces unapproved users; /pending stays outside the protected matcher), the .pending-*-before-.anim-d* cascade reasoning is correct and documented, testids are pre-registered, and 4/4 unit tests + tsc + eslint reproduce clean at head.

Should fix before merge

  1. pending/page.tsx:100 — the copy promises an email the system never sends. "We'll email you as soon as your access is approved" names a specific channel, but approval is PATCH /users/{user_id}/approve (backend/routes/admin.py), which only flips is_approved — there is no email-sending code anywhere in the repo (verified: the only smtp/mailer-ish grep hits are SESSION_SECRET false positives). If admins manually email approved users, ignore this; otherwise soften to "We'll let you know…" — a user waiting on an email that never arrives is worse than vague copy.

Minor

  1. globals.css:511-518 — the reduced-motion reset zeroes animation-duration but not animation-delay, so under reduced motion the both-fill steps still stagger-reveal over ~240ms (held invisible, then snapping in). Consistent with existing .fade-up surfaces, but the comments claiming the sequence fully collapses overstate it. Cheap global fix: add animation-delay: 0.01ms !important to the reset.
  2. pending/page.tsx:102-108 — the sole interactive element (.btn, ~37px) is under the 44px touch-target convention. Pre-existing class, not a regression, but this page is likely hit on mobile right after sign-up; a min-height on this instance is a cheap win.

Follow-up worth an issue (pre-existing, not this PR): nothing on /pending ever re-checks approval — an approved user with the tab open (or even refreshing /pending) stays on the waitlist screen until they manually navigate elsewhere. A one-shot "check again" against /api/auth/me would close the loop the confirmation beat opens. Also: no e2e journey directly visits /pending (only negative-space assertions); a seed-unapproved → pending-gate → sign-out journey would make coverage direct.

Verdict: ready to merge once the email-copy question is resolved.

AndresL230and others added 2 commits July 30, 2026 20:04
)
Sign-up ENDS on /pending, and the page was 42 lines with no container: a
sprout, an h1, a paragraph and a button floating directly on a radial
gradient. Beta glow into silence, with no "you're in" moment anywhere in it.
The content now sits on a real `.card` surface (which supplies bg/border/
radius/shadow but no padding — that is the caller's job, so the card sets
`var(--pad-lg)`), and the screen plays a one-shot confirmation beat: the
sapling draws itself, then the message steps in behind it.
The beat is CSS, not JS, and that is the point. globals.css already carries a
global prefers-reduced-motion reset, so a CSS entrance is automatically safe
where a JS-driven one would have to re-implement that guard. It reuses the
motion vocabulary that already exists (`fade-in`/`slide-up` keyframes, the
`.anim-d*` delay utilities) rather than inventing a parallel one; the new
`.pending-*` rules are declared ABOVE `.anim-d*` on purpose, since the
animation shorthand resets animation-delay and the delay utilities have to
win. The sprout's stroke uses `pathLength={1}` so the draw keyframe can dash
it without measuring, and its three subpaths run stem-then-veins so it reads
as the sapling growing rather than a generic fade. Everything settles by
~540ms, single pass, no loop.
Copy does the confirmation work the layout can't: "Your account is ready"
states what actually happened, and "We'll email you" names the channel the
old "We'll reach out" left vague.
Both E2E anchors — `pending-gate` and `pending-signout` — are unchanged, and
the new page.test.tsx pins them, the card surface, and the beat's finiteness
so a future pass can't quietly reintroduce an infinite animation.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the original copy. Issue #290's proposal says "Keep the copy" in so
many words, and the previous revision rewrote both the heading and the body.
The rewrite may still be worth making, but it is a separate call and not one
this PR was scoped to take.
Make the sprout actually sequence. The stem and the two leaf veins were three
subpaths of ONE path sharing a single dash animation, and a dash pattern
restarts its phase at every `M` — so all three drew simultaneously, which is
the opposite of what the comment claimed. Verified in headless Chromium
during review. They are now two separate paths on staggered delays, so the
stem really does lead and the leaves follow.
Split animation-fill-mode out of the shorthand. f4ac696 did exactly this to
.fade-up ("split animation-fill-mode both so stagger works with CSS var
timing") 20 minutes after the stagger utilities shipped, and every rule added
since has avoided var()-timing plus a trailing keyword in one shorthand. It
resolves correctly in Chromium — which is the only engine the e2e lane runs —
but this file's own history says not to write it that way.
Beat now settles at ~560ms (was ~540ms), still inside the issue's 600ms cap.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/290-pending-page branch from bce12e3 to 30d38f7CompareJuly 31, 2026 03:04
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Local e2e cycle — green

Full cycle inside one flock of the stack lock, at 30d38f7 (rebased on main), SAPLING_MODEL_MODE=function.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Gates: tsc --noEmit clean · npm run lint 0 errors · npx vitest run 56 files / 403 tests.

Note there is no journey that drives /pending directly (only incidental references in auth-session.spec.ts and smoke.spec.ts), so the cycle here is a regression check that the globals.css additions didn't disturb anything else — the beat itself is covered by page.test.tsx, which pins its finiteness rather than its appearance.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): give the approval gate a surface and a confirmation beat (#290) by AndresL230 · Pull Request #488 · SaplingLearn/Sapling · GitHub
Skip to content

feat(ui): give the approval gate a surface and a confirmation beat (#290) - #488

Merged
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page
Jul 31, 2026
Merged

feat(ui): give the approval gate a surface and a confirmation beat (#290)#488
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Sign-up ends on /pending, and the page was 42 lines with no container — a sprout, an <h1>, a paragraph and a button floating directly on a radial gradient. Beta glow into silence, with no "you're in" moment anywhere in it.

What changed

A surface. The content now sits on a real .card. .card supplies bg/border/radius/shadow but no padding — that's the caller's job — so the card sets var(--pad-lg).

A confirmation beat. The sapling draws itself once, then the message steps in behind it. Everything settles by ~540ms: single pass, no loop.

Copy that does the confirmation work the layout can't. "Your account is ready" states what actually happened; "We'll email you" names the channel that the old "We'll reach out" left vague.

Why CSS and not JS

globals.css already carries a global prefers-reduced-motion reset, so a CSS entrance is automatically safe — a JS-driven one would have to re-implement that guard itself.

It reuses the motion vocabulary that already exists (the fade-in/slide-up keyframes and the .anim-d* delay utilities) instead of inventing a parallel one. The new .pending-* rules are declared above.anim-d* deliberately: the animation shorthand resets animation-delay, so the delay utilities have to come later to win.

The sprout's stroke carries pathLength={1} so the draw keyframe can dash it without measuring, and its three subpaths run stem-then-veins — so it reads as the sapling growing rather than a generic fade.

Anchors

data-testid="pending-gate" and data-testid="pending-signout" are unchanged. Both are already registered in docs/frontend-testids.md (surface table and inventory) and app/pending/page.tsx is already in the eslint enforcement list, so neither half needed updating.

src/app/pending/page.test.tsx (new, red-first) pins the anchors, the sign-out behaviour, the card surface, and the beat's finiteness — it fails if a future pass introduces an infinite animation or drops a fill mode from a delayed step.

Gates

  • tsc --noEmit — clean
  • npm run lint — 0 errors (36 pre-existing warnings)
  • npx vitest run — 56 files, 403 tests passed
  • Full local e2e cycle — recorded in a comment below

part of #290

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fef0d5c-1f4b-405a-873f-078171d84a82

📥 Commits

Reviewing files that changed from the base of the PR and between 162078a and 30d38f7.

📒 Files selected for processing (3)
  • frontend/src/app/globals.css
  • frontend/src/app/pending/page.test.tsx
  • frontend/src/app/pending/page.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging30d38f7Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:08 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Automated review pass (Claude Code). Verified findings only — overall this is a careful, well-tested change: scope matches #290 exactly, middleware gating is untouched (middleware.ts:91 still bounces unapproved users; /pending stays outside the protected matcher), the .pending-*-before-.anim-d* cascade reasoning is correct and documented, testids are pre-registered, and 4/4 unit tests + tsc + eslint reproduce clean at head.

Should fix before merge

  1. pending/page.tsx:100 — the copy promises an email the system never sends. "We'll email you as soon as your access is approved" names a specific channel, but approval is PATCH /users/{user_id}/approve (backend/routes/admin.py), which only flips is_approved — there is no email-sending code anywhere in the repo (verified: the only smtp/mailer-ish grep hits are SESSION_SECRET false positives). If admins manually email approved users, ignore this; otherwise soften to "We'll let you know…" — a user waiting on an email that never arrives is worse than vague copy.

Minor

  1. globals.css:511-518 — the reduced-motion reset zeroes animation-duration but not animation-delay, so under reduced motion the both-fill steps still stagger-reveal over ~240ms (held invisible, then snapping in). Consistent with existing .fade-up surfaces, but the comments claiming the sequence fully collapses overstate it. Cheap global fix: add animation-delay: 0.01ms !important to the reset.
  2. pending/page.tsx:102-108 — the sole interactive element (.btn, ~37px) is under the 44px touch-target convention. Pre-existing class, not a regression, but this page is likely hit on mobile right after sign-up; a min-height on this instance is a cheap win.

Follow-up worth an issue (pre-existing, not this PR): nothing on /pending ever re-checks approval — an approved user with the tab open (or even refreshing /pending) stays on the waitlist screen until they manually navigate elsewhere. A one-shot "check again" against /api/auth/me would close the loop the confirmation beat opens. Also: no e2e journey directly visits /pending (only negative-space assertions); a seed-unapproved → pending-gate → sign-out journey would make coverage direct.

Verdict: ready to merge once the email-copy question is resolved.

AndresL230and others added 2 commits July 30, 2026 20:04
)
Sign-up ENDS on /pending, and the page was 42 lines with no container: a
sprout, an h1, a paragraph and a button floating directly on a radial
gradient. Beta glow into silence, with no "you're in" moment anywhere in it.
The content now sits on a real `.card` surface (which supplies bg/border/
radius/shadow but no padding — that is the caller's job, so the card sets
`var(--pad-lg)`), and the screen plays a one-shot confirmation beat: the
sapling draws itself, then the message steps in behind it.
The beat is CSS, not JS, and that is the point. globals.css already carries a
global prefers-reduced-motion reset, so a CSS entrance is automatically safe
where a JS-driven one would have to re-implement that guard. It reuses the
motion vocabulary that already exists (`fade-in`/`slide-up` keyframes, the
`.anim-d*` delay utilities) rather than inventing a parallel one; the new
`.pending-*` rules are declared ABOVE `.anim-d*` on purpose, since the
animation shorthand resets animation-delay and the delay utilities have to
win. The sprout's stroke uses `pathLength={1}` so the draw keyframe can dash
it without measuring, and its three subpaths run stem-then-veins so it reads
as the sapling growing rather than a generic fade. Everything settles by
~540ms, single pass, no loop.
Copy does the confirmation work the layout can't: "Your account is ready"
states what actually happened, and "We'll email you" names the channel the
old "We'll reach out" left vague.
Both E2E anchors — `pending-gate` and `pending-signout` — are unchanged, and
the new page.test.tsx pins them, the card surface, and the beat's finiteness
so a future pass can't quietly reintroduce an infinite animation.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the original copy. Issue #290's proposal says "Keep the copy" in so
many words, and the previous revision rewrote both the heading and the body.
The rewrite may still be worth making, but it is a separate call and not one
this PR was scoped to take.
Make the sprout actually sequence. The stem and the two leaf veins were three
subpaths of ONE path sharing a single dash animation, and a dash pattern
restarts its phase at every `M` — so all three drew simultaneously, which is
the opposite of what the comment claimed. Verified in headless Chromium
during review. They are now two separate paths on staggered delays, so the
stem really does lead and the leaves follow.
Split animation-fill-mode out of the shorthand. f4ac696 did exactly this to
.fade-up ("split animation-fill-mode both so stagger works with CSS var
timing") 20 minutes after the stagger utilities shipped, and every rule added
since has avoided var()-timing plus a trailing keyword in one shorthand. It
resolves correctly in Chromium — which is the only engine the e2e lane runs —
but this file's own history says not to write it that way.
Beat now settles at ~560ms (was ~540ms), still inside the issue's 600ms cap.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/290-pending-page branch from bce12e3 to 30d38f7CompareJuly 31, 2026 03:04
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Local e2e cycle — green

Full cycle inside one flock of the stack lock, at 30d38f7 (rebased on main), SAPLING_MODEL_MODE=function.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Gates: tsc --noEmit clean · npm run lint 0 errors · npx vitest run 56 files / 403 tests.

Note there is no journey that drives /pending directly (only incidental references in auth-session.spec.ts and smoke.spec.ts), so the cycle here is a regression check that the globals.css additions didn't disturb anything else — the beat itself is covered by page.test.tsx, which pins its finiteness rather than its appearance.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): give the approval gate a surface and a confirmation beat (#290) by AndresL230 · Pull Request #488 · SaplingLearn/Sapling · GitHub
Skip to content

feat(ui): give the approval gate a surface and a confirmation beat (#290) - #488

Merged
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page
Jul 31, 2026
Merged

feat(ui): give the approval gate a surface and a confirmation beat (#290)#488
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Sign-up ends on /pending, and the page was 42 lines with no container — a sprout, an <h1>, a paragraph and a button floating directly on a radial gradient. Beta glow into silence, with no "you're in" moment anywhere in it.

What changed

A surface. The content now sits on a real .card. .card supplies bg/border/radius/shadow but no padding — that's the caller's job — so the card sets var(--pad-lg).

A confirmation beat. The sapling draws itself once, then the message steps in behind it. Everything settles by ~540ms: single pass, no loop.

Copy that does the confirmation work the layout can't. "Your account is ready" states what actually happened; "We'll email you" names the channel that the old "We'll reach out" left vague.

Why CSS and not JS

globals.css already carries a global prefers-reduced-motion reset, so a CSS entrance is automatically safe — a JS-driven one would have to re-implement that guard itself.

It reuses the motion vocabulary that already exists (the fade-in/slide-up keyframes and the .anim-d* delay utilities) instead of inventing a parallel one. The new .pending-* rules are declared above.anim-d* deliberately: the animation shorthand resets animation-delay, so the delay utilities have to come later to win.

The sprout's stroke carries pathLength={1} so the draw keyframe can dash it without measuring, and its three subpaths run stem-then-veins — so it reads as the sapling growing rather than a generic fade.

Anchors

data-testid="pending-gate" and data-testid="pending-signout" are unchanged. Both are already registered in docs/frontend-testids.md (surface table and inventory) and app/pending/page.tsx is already in the eslint enforcement list, so neither half needed updating.

src/app/pending/page.test.tsx (new, red-first) pins the anchors, the sign-out behaviour, the card surface, and the beat's finiteness — it fails if a future pass introduces an infinite animation or drops a fill mode from a delayed step.

Gates

  • tsc --noEmit — clean
  • npm run lint — 0 errors (36 pre-existing warnings)
  • npx vitest run — 56 files, 403 tests passed
  • Full local e2e cycle — recorded in a comment below

part of #290

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fef0d5c-1f4b-405a-873f-078171d84a82

📥 Commits

Reviewing files that changed from the base of the PR and between 162078a and 30d38f7.

📒 Files selected for processing (3)
  • frontend/src/app/globals.css
  • frontend/src/app/pending/page.test.tsx
  • frontend/src/app/pending/page.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging30d38f7Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:08 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Automated review pass (Claude Code). Verified findings only — overall this is a careful, well-tested change: scope matches #290 exactly, middleware gating is untouched (middleware.ts:91 still bounces unapproved users; /pending stays outside the protected matcher), the .pending-*-before-.anim-d* cascade reasoning is correct and documented, testids are pre-registered, and 4/4 unit tests + tsc + eslint reproduce clean at head.

Should fix before merge

  1. pending/page.tsx:100 — the copy promises an email the system never sends. "We'll email you as soon as your access is approved" names a specific channel, but approval is PATCH /users/{user_id}/approve (backend/routes/admin.py), which only flips is_approved — there is no email-sending code anywhere in the repo (verified: the only smtp/mailer-ish grep hits are SESSION_SECRET false positives). If admins manually email approved users, ignore this; otherwise soften to "We'll let you know…" — a user waiting on an email that never arrives is worse than vague copy.

Minor

  1. globals.css:511-518 — the reduced-motion reset zeroes animation-duration but not animation-delay, so under reduced motion the both-fill steps still stagger-reveal over ~240ms (held invisible, then snapping in). Consistent with existing .fade-up surfaces, but the comments claiming the sequence fully collapses overstate it. Cheap global fix: add animation-delay: 0.01ms !important to the reset.
  2. pending/page.tsx:102-108 — the sole interactive element (.btn, ~37px) is under the 44px touch-target convention. Pre-existing class, not a regression, but this page is likely hit on mobile right after sign-up; a min-height on this instance is a cheap win.

Follow-up worth an issue (pre-existing, not this PR): nothing on /pending ever re-checks approval — an approved user with the tab open (or even refreshing /pending) stays on the waitlist screen until they manually navigate elsewhere. A one-shot "check again" against /api/auth/me would close the loop the confirmation beat opens. Also: no e2e journey directly visits /pending (only negative-space assertions); a seed-unapproved → pending-gate → sign-out journey would make coverage direct.

Verdict: ready to merge once the email-copy question is resolved.

AndresL230and others added 2 commits July 30, 2026 20:04
)
Sign-up ENDS on /pending, and the page was 42 lines with no container: a
sprout, an h1, a paragraph and a button floating directly on a radial
gradient. Beta glow into silence, with no "you're in" moment anywhere in it.
The content now sits on a real `.card` surface (which supplies bg/border/
radius/shadow but no padding — that is the caller's job, so the card sets
`var(--pad-lg)`), and the screen plays a one-shot confirmation beat: the
sapling draws itself, then the message steps in behind it.
The beat is CSS, not JS, and that is the point. globals.css already carries a
global prefers-reduced-motion reset, so a CSS entrance is automatically safe
where a JS-driven one would have to re-implement that guard. It reuses the
motion vocabulary that already exists (`fade-in`/`slide-up` keyframes, the
`.anim-d*` delay utilities) rather than inventing a parallel one; the new
`.pending-*` rules are declared ABOVE `.anim-d*` on purpose, since the
animation shorthand resets animation-delay and the delay utilities have to
win. The sprout's stroke uses `pathLength={1}` so the draw keyframe can dash
it without measuring, and its three subpaths run stem-then-veins so it reads
as the sapling growing rather than a generic fade. Everything settles by
~540ms, single pass, no loop.
Copy does the confirmation work the layout can't: "Your account is ready"
states what actually happened, and "We'll email you" names the channel the
old "We'll reach out" left vague.
Both E2E anchors — `pending-gate` and `pending-signout` — are unchanged, and
the new page.test.tsx pins them, the card surface, and the beat's finiteness
so a future pass can't quietly reintroduce an infinite animation.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the original copy. Issue #290's proposal says "Keep the copy" in so
many words, and the previous revision rewrote both the heading and the body.
The rewrite may still be worth making, but it is a separate call and not one
this PR was scoped to take.
Make the sprout actually sequence. The stem and the two leaf veins were three
subpaths of ONE path sharing a single dash animation, and a dash pattern
restarts its phase at every `M` — so all three drew simultaneously, which is
the opposite of what the comment claimed. Verified in headless Chromium
during review. They are now two separate paths on staggered delays, so the
stem really does lead and the leaves follow.
Split animation-fill-mode out of the shorthand. f4ac696 did exactly this to
.fade-up ("split animation-fill-mode both so stagger works with CSS var
timing") 20 minutes after the stagger utilities shipped, and every rule added
since has avoided var()-timing plus a trailing keyword in one shorthand. It
resolves correctly in Chromium — which is the only engine the e2e lane runs —
but this file's own history says not to write it that way.
Beat now settles at ~560ms (was ~540ms), still inside the issue's 600ms cap.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/290-pending-page branch from bce12e3 to 30d38f7CompareJuly 31, 2026 03:04
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Local e2e cycle — green

Full cycle inside one flock of the stack lock, at 30d38f7 (rebased on main), SAPLING_MODEL_MODE=function.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Gates: tsc --noEmit clean · npm run lint 0 errors · npx vitest run 56 files / 403 tests.

Note there is no journey that drives /pending directly (only incidental references in auth-session.spec.ts and smoke.spec.ts), so the cycle here is a regression check that the globals.css additions didn't disturb anything else — the beat itself is covered by page.test.tsx, which pins its finiteness rather than its appearance.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ui): give the approval gate a surface and a confirmation beat (#290) by AndresL230 · Pull Request #488 · SaplingLearn/Sapling · GitHub
Skip to content

feat(ui): give the approval gate a surface and a confirmation beat (#290) - #488

Merged
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page
Jul 31, 2026
Merged

feat(ui): give the approval gate a surface and a confirmation beat (#290)#488
AndresL230 merged 2 commits into
mainfrom
feat/290-pending-page

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Sign-up ends on /pending, and the page was 42 lines with no container — a sprout, an <h1>, a paragraph and a button floating directly on a radial gradient. Beta glow into silence, with no "you're in" moment anywhere in it.

What changed

A surface. The content now sits on a real .card. .card supplies bg/border/radius/shadow but no padding — that's the caller's job — so the card sets var(--pad-lg).

A confirmation beat. The sapling draws itself once, then the message steps in behind it. Everything settles by ~540ms: single pass, no loop.

Copy that does the confirmation work the layout can't. "Your account is ready" states what actually happened; "We'll email you" names the channel that the old "We'll reach out" left vague.

Why CSS and not JS

globals.css already carries a global prefers-reduced-motion reset, so a CSS entrance is automatically safe — a JS-driven one would have to re-implement that guard itself.

It reuses the motion vocabulary that already exists (the fade-in/slide-up keyframes and the .anim-d* delay utilities) instead of inventing a parallel one. The new .pending-* rules are declared above.anim-d* deliberately: the animation shorthand resets animation-delay, so the delay utilities have to come later to win.

The sprout's stroke carries pathLength={1} so the draw keyframe can dash it without measuring, and its three subpaths run stem-then-veins — so it reads as the sapling growing rather than a generic fade.

Anchors

data-testid="pending-gate" and data-testid="pending-signout" are unchanged. Both are already registered in docs/frontend-testids.md (surface table and inventory) and app/pending/page.tsx is already in the eslint enforcement list, so neither half needed updating.

src/app/pending/page.test.tsx (new, red-first) pins the anchors, the sign-out behaviour, the card surface, and the beat's finiteness — it fails if a future pass introduces an infinite animation or drops a fill mode from a delayed step.

Gates

  • tsc --noEmit — clean
  • npm run lint — 0 errors (36 pre-existing warnings)
  • npx vitest run — 56 files, 403 tests passed
  • Full local e2e cycle — recorded in a comment below

part of #290

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fef0d5c-1f4b-405a-873f-078171d84a82

📥 Commits

Reviewing files that changed from the base of the PR and between 162078a and 30d38f7.

📒 Files selected for processing (3)
  • frontend/src/app/globals.css
  • frontend/src/app/pending/page.test.tsx
  • frontend/src/app/pending/page.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging30d38f7Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:08 AM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Automated review pass (Claude Code). Verified findings only — overall this is a careful, well-tested change: scope matches #290 exactly, middleware gating is untouched (middleware.ts:91 still bounces unapproved users; /pending stays outside the protected matcher), the .pending-*-before-.anim-d* cascade reasoning is correct and documented, testids are pre-registered, and 4/4 unit tests + tsc + eslint reproduce clean at head.

Should fix before merge

  1. pending/page.tsx:100 — the copy promises an email the system never sends. "We'll email you as soon as your access is approved" names a specific channel, but approval is PATCH /users/{user_id}/approve (backend/routes/admin.py), which only flips is_approved — there is no email-sending code anywhere in the repo (verified: the only smtp/mailer-ish grep hits are SESSION_SECRET false positives). If admins manually email approved users, ignore this; otherwise soften to "We'll let you know…" — a user waiting on an email that never arrives is worse than vague copy.

Minor

  1. globals.css:511-518 — the reduced-motion reset zeroes animation-duration but not animation-delay, so under reduced motion the both-fill steps still stagger-reveal over ~240ms (held invisible, then snapping in). Consistent with existing .fade-up surfaces, but the comments claiming the sequence fully collapses overstate it. Cheap global fix: add animation-delay: 0.01ms !important to the reset.
  2. pending/page.tsx:102-108 — the sole interactive element (.btn, ~37px) is under the 44px touch-target convention. Pre-existing class, not a regression, but this page is likely hit on mobile right after sign-up; a min-height on this instance is a cheap win.

Follow-up worth an issue (pre-existing, not this PR): nothing on /pending ever re-checks approval — an approved user with the tab open (or even refreshing /pending) stays on the waitlist screen until they manually navigate elsewhere. A one-shot "check again" against /api/auth/me would close the loop the confirmation beat opens. Also: no e2e journey directly visits /pending (only negative-space assertions); a seed-unapproved → pending-gate → sign-out journey would make coverage direct.

Verdict: ready to merge once the email-copy question is resolved.

AndresL230and others added 2 commits July 30, 2026 20:04
)
Sign-up ENDS on /pending, and the page was 42 lines with no container: a
sprout, an h1, a paragraph and a button floating directly on a radial
gradient. Beta glow into silence, with no "you're in" moment anywhere in it.
The content now sits on a real `.card` surface (which supplies bg/border/
radius/shadow but no padding — that is the caller's job, so the card sets
`var(--pad-lg)`), and the screen plays a one-shot confirmation beat: the
sapling draws itself, then the message steps in behind it.
The beat is CSS, not JS, and that is the point. globals.css already carries a
global prefers-reduced-motion reset, so a CSS entrance is automatically safe
where a JS-driven one would have to re-implement that guard. It reuses the
motion vocabulary that already exists (`fade-in`/`slide-up` keyframes, the
`.anim-d*` delay utilities) rather than inventing a parallel one; the new
`.pending-*` rules are declared ABOVE `.anim-d*` on purpose, since the
animation shorthand resets animation-delay and the delay utilities have to
win. The sprout's stroke uses `pathLength={1}` so the draw keyframe can dash
it without measuring, and its three subpaths run stem-then-veins so it reads
as the sapling growing rather than a generic fade. Everything settles by
~540ms, single pass, no loop.
Copy does the confirmation work the layout can't: "Your account is ready"
states what actually happened, and "We'll email you" names the channel the
old "We'll reach out" left vague.
Both E2E anchors — `pending-gate` and `pending-signout` — are unchanged, and
the new page.test.tsx pins them, the card surface, and the beat's finiteness
so a future pass can't quietly reintroduce an infinite animation.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the original copy. Issue #290's proposal says "Keep the copy" in so
many words, and the previous revision rewrote both the heading and the body.
The rewrite may still be worth making, but it is a separate call and not one
this PR was scoped to take.
Make the sprout actually sequence. The stem and the two leaf veins were three
subpaths of ONE path sharing a single dash animation, and a dash pattern
restarts its phase at every `M` — so all three drew simultaneously, which is
the opposite of what the comment claimed. Verified in headless Chromium
during review. They are now two separate paths on staggered delays, so the
stem really does lead and the leaves follow.
Split animation-fill-mode out of the shorthand. f4ac696 did exactly this to
.fade-up ("split animation-fill-mode both so stagger works with CSS var
timing") 20 minutes after the stagger utilities shipped, and every rule added
since has avoided var()-timing plus a trailing keyword in one shorthand. It
resolves correctly in Chromium — which is the only engine the e2e lane runs —
but this file's own history says not to write it that way.
Beat now settles at ~560ms (was ~540ms), still inside the issue's 600ms cap.
part of #290
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/290-pending-page branch from bce12e3 to 30d38f7CompareJuly 31, 2026 03:04
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Local e2e cycle — green

Full cycle inside one flock of the stack lock, at 30d38f7 (rebased on main), SAPLING_MODEL_MODE=function.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Gates: tsc --noEmit clean · npm run lint 0 errors · npx vitest run 56 files / 403 tests.

Note there is no journey that drives /pending directly (only incidental references in auth-session.spec.ts and smoke.spec.ts), so the cycle here is a regression check that the globals.css additions didn't disturb anything else — the beat itself is covered by page.test.tsx, which pins its finiteness rather than its appearance.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez